Skip to content

Commit 7a5b419

Browse files
committed
refactor(plugins): simplify plugin cache boundaries
1 parent 86c5f37 commit 7a5b419

92 files changed

Lines changed: 988 additions & 2626 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
4848
- 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.
4949
- 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.
5050
- 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.
51+
- 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.
5152
- 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.
5253
- fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987.
5354
- fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987.

docs/plugins/architecture-internals.md

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -87,35 +87,51 @@ discovery order. When setup runtime does execute, registry diagnostics report
8787
drift between `setup.providers` / `setup.cliBackends` and the providers or CLI
8888
backends registered by setup-api without blocking legacy plugins.
8989

90-
### What the loader caches
91-
92-
OpenClaw keeps short in-process caches for:
90+
### Plugin cache boundary
91+
92+
OpenClaw does not cache plugin discovery results or direct manifest registry
93+
data behind wall-clock windows. Installs, manifest edits, and load-path changes
94+
must become visible on the next explicit metadata read or snapshot rebuild.
95+
96+
The safe metadata fast path is explicit object ownership, not a hidden cache.
97+
Gateway startup hot paths should pass the current `PluginMetadataSnapshot`, the
98+
derived `PluginLookUpTable`, or an explicit manifest registry through the call
99+
chain. Config validation, startup auto-enable, plugin bootstrap, setup lookup,
100+
and provider selection can reuse those objects while they represent the current
101+
config and plugin inventory. When that input changes, rebuild and replace the
102+
snapshot instead of mutating it or keeping historical copies.
103+
Views over the active plugin registry and bundled channel bootstrap helpers
104+
should be recomputed from the current registry/root. Short-lived maps are fine
105+
inside one call to dedupe work or guard reentry; they must not become process
106+
metadata caches.
107+
108+
For plugin loading, the persistent cache layer is runtime loading. It may reuse
109+
loader state when code or installed artifacts are actually loaded, such as:
110+
111+
- `PluginLoaderCacheState` and compatible active runtime registries
112+
- jiti/module caches and public-surface loader caches used to avoid importing
113+
the same runtime surface repeatedly
114+
- runtime dependency mirrors and filesystem caches for installed plugin
115+
artifacts
116+
- short-lived per-call maps for path normalization or duplicate resolution
117+
118+
Those caches are data-plane implementation details. They must not answer
119+
control-plane questions such as "which plugin owns this provider?" unless the
120+
caller deliberately asked for runtime loading.
121+
122+
Do not add persistent or wall-clock caches for:
93123

94124
- discovery results
95-
- manifest registry data
96-
- loaded plugin registries
97-
98-
These caches reduce bursty startup and repeated command overhead. They are safe
99-
to think of as short-lived performance caches, not persistence.
100-
101-
Gateway startup hot paths should prefer the current `PluginMetadataSnapshot`,
102-
the derived `PluginLookUpTable`, or an explicit manifest registry passed through
103-
the call chain. Config validation, startup auto-enable, and plugin bootstrap use
104-
the same snapshot when available. For callers that still rebuild manifest
105-
metadata from the persisted installed plugin index, OpenClaw also keeps a small
106-
bounded fallback cache keyed by the installed index, request shape, config
107-
policy, runtime roots, and manifest/package file signatures. That cache is only a
108-
fallback for repeated installed-index reconstruction; it is not a mutable runtime
109-
plugin registry.
110-
111-
Performance note:
112-
113-
- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or
114-
`OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.
115-
- Set `OPENCLAW_DISABLE_INSTALLED_PLUGIN_MANIFEST_REGISTRY_CACHE=1` to disable
116-
only the installed-index manifest-registry fallback cache.
117-
- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and
118-
`OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.
125+
- direct manifest registries
126+
- manifest registries reconstructed from the installed plugin index
127+
- provider owner lookup, model suppression, provider policy, or public-artifact
128+
metadata
129+
- any other manifest-derived answer where a changed manifest, installed index,
130+
or load path should be visible on the next metadata read
131+
132+
Callers that rebuild manifest metadata from the persisted installed plugin
133+
index reconstruct that registry on demand. The installed index is durable
134+
source-plane state; it is not a hidden in-process metadata cache.
119135

120136
## Registry model
121137

@@ -944,7 +960,7 @@ source-plane diagnostics without adding a second raw filesystem-path disclosure
944960
surface. The persisted `plugins/installs.json` plugin index is the install
945961
source of truth and can be refreshed without loading plugin runtime modules.
946962
Its `installRecords` map is durable even when a plugin manifest is missing or
947-
invalid; its `plugins` array is a rebuildable manifest/cache view.
963+
invalid; its `plugins` array is a rebuildable manifest view.
948964

949965
## Context engine plugins
950966

docs/plugins/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ The snapshot and lookup table keep repeated startup decisions on the fast path:
165165

166166
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.
167167

168-
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.
168+
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.
169+
170+
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.
169171

170172
### Activation planning
171173

scripts/test-built-plugin-singleton.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ const registry = loadOpenClawPlugins({
118118
env: {
119119
...process.env,
120120
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"),
121-
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
122121
},
123122
config: {
124123
plugins: {

scripts/write-cli-startup-metadata.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,6 @@ function createIsolatedRootHelpRenderContext(
165165
NO_COLOR: "1",
166166
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
167167
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "",
168-
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
169-
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
170-
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0",
171-
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "0",
172168
OPENCLAW_STATE_DIR: stateDir,
173169
};
174170
const config: OpenClawConfig = {

src/agents/live-target-matcher.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ vi.mock("./live-provider-owner.js", () => {
1111
});
1212

1313
describe("createLiveTargetMatcher", () => {
14-
const env = {
15-
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
16-
} as NodeJS.ProcessEnv;
14+
const env = {} as NodeJS.ProcessEnv;
1715

1816
it("matches Anthropic-owned models for the claude-cli provider filter", () => {
1917
const matcher = createLiveTargetMatcher({

src/channels/plugins/bootstrap-registry.ts

Lines changed: 4 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,6 @@ import {
1010
import type { ChannelPlugin } from "./types.plugin.js";
1111
import type { ChannelId } from "./types.public.js";
1212

13-
type CachedBootstrapPlugins = {
14-
sortedIds: string[];
15-
byId: Map<string, ChannelPlugin>;
16-
secretsById: Map<string, ChannelPlugin["secrets"] | null>;
17-
missingIds: Set<string>;
18-
};
19-
20-
const cachedBootstrapPluginsByRoot = new Map<string, CachedBootstrapPlugins>();
21-
2213
function resolveBootstrapChannelId(id: ChannelId): string {
2314
return normalizeOptionalString(id) ?? "";
2415
}
@@ -68,37 +59,9 @@ function mergeBootstrapPlugin(
6859
} as ChannelPlugin;
6960
}
7061

71-
function buildBootstrapPlugins(
72-
cacheKey: string,
73-
env: NodeJS.ProcessEnv = process.env,
74-
): CachedBootstrapPlugins {
75-
return {
76-
sortedIds: listBundledChannelPluginIdsForRoot(cacheKey, env),
77-
byId: new Map(),
78-
secretsById: new Map(),
79-
missingIds: new Set(),
80-
};
81-
}
82-
83-
function getBootstrapPlugins(
84-
cacheKey = resolveBundledChannelRootScope().cacheKey,
85-
env: NodeJS.ProcessEnv = process.env,
86-
): CachedBootstrapPlugins {
87-
const cached = cachedBootstrapPluginsByRoot.get(cacheKey);
88-
if (cached) {
89-
return cached;
90-
}
91-
const created = buildBootstrapPlugins(cacheKey, env);
92-
cachedBootstrapPluginsByRoot.set(cacheKey, created);
93-
return created;
94-
}
95-
96-
function resolveActiveBootstrapPlugins(): CachedBootstrapPlugins {
97-
return getBootstrapPlugins(resolveBundledChannelRootScope().cacheKey);
98-
}
99-
10062
export function listBootstrapChannelPluginIds(): readonly string[] {
101-
return resolveActiveBootstrapPlugins().sortedIds;
63+
const rootScope = resolveBundledChannelRootScope();
64+
return listBundledChannelPluginIdsForRoot(rootScope.cacheKey);
10265
}
10366

10467
export function* iterateBootstrapChannelPlugins(): IterableIterator<ChannelPlugin> {
@@ -119,32 +82,18 @@ export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefi
11982
if (!resolvedId) {
12083
return undefined;
12184
}
122-
const registry = resolveActiveBootstrapPlugins();
123-
const cached = registry.byId.get(resolvedId);
124-
if (cached) {
125-
return cached;
126-
}
127-
if (registry.missingIds.has(resolvedId)) {
128-
return undefined;
129-
}
13085
let runtimePlugin: ChannelPlugin | undefined;
13186
let setupPlugin: ChannelPlugin | undefined;
13287
try {
13388
runtimePlugin = getBundledChannelPlugin(resolvedId);
13489
setupPlugin = getBundledChannelSetupPlugin(resolvedId);
13590
} catch {
136-
registry.missingIds.add(resolvedId);
13791
return undefined;
13892
}
13993
const merged =
14094
runtimePlugin && setupPlugin
14195
? mergeBootstrapPlugin(runtimePlugin, setupPlugin)
14296
: (setupPlugin ?? runtimePlugin);
143-
if (!merged) {
144-
registry.missingIds.add(resolvedId);
145-
return undefined;
146-
}
147-
registry.byId.set(resolvedId, merged);
14897
return merged;
14998
}
15099

@@ -153,31 +102,13 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret
153102
if (!resolvedId) {
154103
return undefined;
155104
}
156-
const registry = resolveActiveBootstrapPlugins();
157-
const cached = registry.secretsById.get(resolvedId);
158-
if (cached) {
159-
return cached;
160-
}
161-
if (registry.secretsById.has(resolvedId)) {
162-
return undefined;
163-
}
164-
if (registry.missingIds.has(resolvedId)) {
165-
registry.secretsById.set(resolvedId, null);
166-
return undefined;
167-
}
168105
try {
169106
const runtimeSecrets = getBundledChannelSecrets(resolvedId);
170107
const setupSecrets = getBundledChannelSetupSecrets(resolvedId);
171-
const merged = mergePluginSection(runtimeSecrets, setupSecrets);
172-
registry.secretsById.set(resolvedId, merged ?? null);
173-
return merged;
108+
return mergePluginSection(runtimeSecrets, setupSecrets);
174109
} catch {
175-
registry.missingIds.add(resolvedId);
176-
registry.secretsById.set(resolvedId, null);
177110
return undefined;
178111
}
179112
}
180113

181-
export function clearBootstrapChannelPluginCache(): void {
182-
cachedBootstrapPluginsByRoot.clear();
183-
}
114+
export function clearBootstrapChannelPluginCache(): void {}

src/channels/plugins/bundled-ids.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
22
import { resolveBundledChannelRootScope } from "./bundled-root.js";
33

4-
const bundledChannelPluginIdsByRoot = new Map<string, string[]>();
5-
64
export function listBundledChannelPluginIdsForRoot(
7-
packageRoot: string,
5+
_packageRoot: string,
86
env: NodeJS.ProcessEnv = process.env,
97
): string[] {
10-
const cached = bundledChannelPluginIdsByRoot.get(packageRoot);
11-
if (cached) {
12-
return [...cached];
13-
}
14-
const loaded = listChannelCatalogEntries({ origin: "bundled", env })
8+
return listChannelCatalogEntries({ origin: "bundled", env })
159
.map((entry) => entry.pluginId)
1610
.toSorted((left, right) => left.localeCompare(right));
17-
bundledChannelPluginIdsByRoot.set(packageRoot, loaded);
18-
return [...loaded];
1911
}
2012

2113
export function listBundledChannelPluginIds(): string[] {

src/channels/plugins/bundled-root-caches.test.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ afterEach(() => {
5353
vi.doUnmock("./bundled-ids.js");
5454
});
5555

56-
describe("bundled root-aware caches", () => {
57-
it("partitions bundled channel ids by active bundled root without re-importing", async () => {
56+
describe("bundled root-aware plugin lookups", () => {
57+
it("reads bundled channel ids from the active bundled root without re-importing", async () => {
5858
const rootA = makeBundledRoot("openclaw-bundled-ids-a-");
5959
const rootB = makeBundledRoot("openclaw-bundled-ids-b-");
6060

@@ -83,16 +83,16 @@ describe("bundled root-aware caches", () => {
8383
expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]);
8484
});
8585

86-
it("partitions bootstrap plugin caches by active bundled root without re-importing", async () => {
86+
it("reads bootstrap plugins from the active bundled root without re-importing", async () => {
8787
const rootA = makeBundledRoot("openclaw-bootstrap-a-");
8888
const rootB = makeBundledRoot("openclaw-bootstrap-b-");
8989

9090
vi.doMock("./bundled-ids.js", () => ({
91-
listBundledChannelPluginIdsForRoot: (cacheKey: string) => {
92-
if (cacheKey === rootA.pluginsDir) {
91+
listBundledChannelPluginIdsForRoot: () => {
92+
if (process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === rootA.pluginsDir) {
9393
return ["alpha"];
9494
}
95-
if (cacheKey === rootB.pluginsDir) {
95+
if (process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === rootB.pluginsDir) {
9696
return ["beta"];
9797
}
9898
return [];
@@ -154,12 +154,12 @@ describe("bundled root-aware caches", () => {
154154
).toBe("setup-beta-B");
155155
});
156156

157-
it("marks bundled plugin ids missing when bootstrap plugin loading throws", async () => {
157+
it("retries bootstrap plugin loading after an error", async () => {
158158
const root = makeBundledRoot("openclaw-bootstrap-plugin-throw-");
159159

160160
vi.doMock("./bundled-ids.js", () => ({
161-
listBundledChannelPluginIdsForRoot: (cacheKey: string) =>
162-
cacheKey === root.pluginsDir ? ["alpha"] : [],
161+
listBundledChannelPluginIdsForRoot: () =>
162+
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === root.pluginsDir ? ["alpha"] : [],
163163
}));
164164

165165
const getBundledChannelPluginMock = vi.fn(() => {
@@ -186,16 +186,16 @@ describe("bundled root-aware caches", () => {
186186
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
187187
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
188188
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
189-
expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(1);
190-
expect(getBundledChannelSecretsMock).not.toHaveBeenCalled();
189+
expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(2);
190+
expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(1);
191191
});
192192

193-
it("marks bundled plugin ids missing when bootstrap secrets loading throws", async () => {
193+
it("keeps plugin loading independent from bootstrap secrets loading errors", async () => {
194194
const root = makeBundledRoot("openclaw-bootstrap-secrets-throw-");
195195

196196
vi.doMock("./bundled-ids.js", () => ({
197-
listBundledChannelPluginIdsForRoot: (cacheKey: string) =>
198-
cacheKey === root.pluginsDir ? ["alpha"] : [],
197+
listBundledChannelPluginIdsForRoot: () =>
198+
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === root.pluginsDir ? ["alpha"] : [],
199199
}));
200200

201201
const getBundledChannelSecretsMock = vi.fn(() => {
@@ -223,8 +223,11 @@ describe("bundled root-aware caches", () => {
223223
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = root.pluginsDir;
224224
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
225225
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
226-
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
227-
expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(1);
228-
expect(getBundledChannelPluginMock).not.toHaveBeenCalled();
226+
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toMatchObject({
227+
id: "alpha",
228+
meta: { id: "alpha", label: "Alpha" },
229+
});
230+
expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(2);
231+
expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(1);
229232
});
230233
});

0 commit comments

Comments
 (0)