mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
refactor(plugins): simplify plugin cache boundaries
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
111
src/plugins/manifest-model-id-normalization.test.ts
Normal file
111
src/plugins/manifest-model-id-normalization.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user