Skip to content

Commit 6a189ee

Browse files
authored
fix(plugins): centralize explicit plugin scope handling (#65298)
* fix(plugins): centralize explicit plugin scope handling * fix(plugins): preserve explicit empty web scopes * fix(plugins): preserve runtime web provider scopes without config * fix(plugins): preserve web provider runtime filtering * fix(plugins): preserve scoped web runtime fallback * fix(plugins): harden plugin scope normalization
1 parent 659bcc5 commit 6a189ee

19 files changed

Lines changed: 475 additions & 56 deletions

src/plugins/activation-planner.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,16 @@ describe("resolveManifestActivationPluginIds", () => {
167167
}),
168168
).toEqual(["demo-channel"]);
169169
});
170+
171+
it("treats explicit empty plugin scopes as scoped-empty", () => {
172+
expect(
173+
resolveManifestActivationPluginIds({
174+
trigger: {
175+
kind: "provider",
176+
provider: "openai",
177+
},
178+
onlyPluginIds: [],
179+
}),
180+
).toEqual([]);
181+
});
170182
});

src/plugins/activation-planner.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
44
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
55
import type { PluginManifestActivationCapability } from "./manifest.js";
66
import type { PluginOrigin } from "./plugin-origin.types.js";
7+
import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js";
78

89
export type PluginActivationPlannerTrigger =
910
| { kind: "command"; command: string }
@@ -20,10 +21,7 @@ export function resolveManifestActivationPluginIds(params: {
2021
origin?: PluginOrigin;
2122
onlyPluginIds?: readonly string[];
2223
}): string[] {
23-
const onlyPluginIds =
24-
params.onlyPluginIds && params.onlyPluginIds.length > 0
25-
? new Set(params.onlyPluginIds.map((pluginId) => pluginId.trim()).filter(Boolean))
26-
: null;
24+
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
2725

2826
return [
2927
...new Set(
@@ -35,7 +33,7 @@ export function resolveManifestActivationPluginIds(params: {
3533
.plugins.filter(
3634
(plugin) =>
3735
(!params.origin || plugin.origin === params.origin) &&
38-
(!onlyPluginIds || onlyPluginIds.has(plugin.id)) &&
36+
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
3937
matchesManifestActivationTrigger(plugin, params.trigger),
4038
)
4139
.map((plugin) => plugin.id),

src/plugins/loader.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ import {
5858
} from "./memory-state.js";
5959
import { unwrapDefaultModuleExport } from "./module-export.js";
6060
import { isPathInside, safeStatSync } from "./path-safety.js";
61+
import {
62+
createPluginIdScopeSet,
63+
hasExplicitPluginIdScope,
64+
normalizePluginIdScope,
65+
serializePluginIdScope,
66+
} from "./plugin-scope.js";
6167
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
6268
import { resolvePluginCacheInputs } from "./roots.js";
6369
import {
@@ -360,8 +366,7 @@ function buildCacheKey(params: {
360366
},
361367
]),
362368
);
363-
const scopeKey =
364-
params.onlyPluginIds === undefined ? "__unscoped__" : JSON.stringify(params.onlyPluginIds);
369+
const scopeKey = serializePluginIdScope(params.onlyPluginIds);
365370
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
366371
const startupChannelMode =
367372
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
@@ -376,14 +381,6 @@ function buildCacheKey(params: {
376381
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
377382
}
378383

379-
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
380-
if (!ids) {
381-
return undefined;
382-
}
383-
const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).toSorted();
384-
return normalized;
385-
}
386-
387384
function matchesScopedPluginRequest(params: {
388385
onlyPluginIdSet: ReadonlySet<string> | null;
389386
pluginId: string;
@@ -451,7 +448,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
451448
options.autoEnabledReasons !== undefined ||
452449
options.workspaceDir !== undefined ||
453450
options.env !== undefined ||
454-
options.onlyPluginIds !== undefined ||
451+
hasExplicitPluginIdScope(options.onlyPluginIds) ||
455452
options.runtimeOptions !== undefined ||
456453
options.pluginSdkResolution !== undefined ||
457454
options.coreGatewayHandlers !== undefined ||
@@ -469,7 +466,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
469466
const activationSource = createPluginActivationSource({
470467
config: activationSourceConfig,
471468
});
472-
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
469+
const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
473470
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
474471
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
475472
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
@@ -1097,7 +1094,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
10971094
} = resolvePluginLoadCacheContext(options);
10981095
const logger = options.logger ?? defaultLogger();
10991096
const validateOnly = options.mode === "validate";
1100-
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
1097+
const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds);
11011098
const cacheEnabled = options.cache !== false;
11021099
if (cacheEnabled) {
11031100
const cached = getCachedPluginRegistry(cacheKey);
@@ -1833,7 +1830,7 @@ export async function loadOpenClawPluginCliRegistry(
18331830
cache: false,
18341831
});
18351832
const logger = options.logger ?? defaultLogger();
1836-
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
1833+
const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds);
18371834
const getJiti = createPluginJitiLoader(options);
18381835
const { registry, registerCli } = createPluginRegistry({
18391836
logger,

src/plugins/plugin-scope.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from "vitest";
2+
import { normalizePluginIdScope } from "./plugin-scope.js";
3+
4+
describe("normalizePluginIdScope", () => {
5+
it("normalizes logical duplicates into a stable scope", () => {
6+
expect(normalizePluginIdScope([" beta ", "alpha", "beta", ""])).toEqual(["alpha", "beta"]);
7+
});
8+
9+
it("ignores non-string scope values instead of throwing", () => {
10+
expect(
11+
normalizePluginIdScope(["alpha", null, 42, { id: "beta" }, " beta "] as unknown[]),
12+
).toEqual(["alpha", "beta"]);
13+
});
14+
});

src/plugins/plugin-scope.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export type PluginIdScope = readonly string[] | undefined;
2+
3+
export function normalizePluginIdScope(ids?: readonly unknown[]): string[] | undefined {
4+
if (ids === undefined) {
5+
return undefined;
6+
}
7+
return Array.from(
8+
new Set(
9+
ids
10+
.filter((id): id is string => typeof id === "string")
11+
.map((id) => id.trim())
12+
.filter(Boolean),
13+
),
14+
).toSorted();
15+
}
16+
17+
export function hasExplicitPluginIdScope(ids?: readonly string[]): boolean {
18+
return ids !== undefined;
19+
}
20+
21+
export function hasNonEmptyPluginIdScope(ids?: readonly string[]): boolean {
22+
return ids !== undefined && ids.length > 0;
23+
}
24+
25+
export function createPluginIdScopeSet(ids?: readonly string[]): ReadonlySet<string> | null {
26+
if (ids === undefined) {
27+
return null;
28+
}
29+
return new Set(ids);
30+
}
31+
32+
export function serializePluginIdScope(ids?: readonly string[]): string {
33+
return ids === undefined ? "__unscoped__" : JSON.stringify(ids);
34+
}

src/plugins/provider-runtime.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").preparePr
6969
let resetProviderRuntimeHookCacheForTest: typeof import("./provider-runtime.js").resetProviderRuntimeHookCacheForTest;
7070
let refreshProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").refreshProviderOAuthCredentialWithPlugin;
7171
let resolveProviderRuntimePlugin: typeof import("./provider-runtime.js").resolveProviderRuntimePlugin;
72+
let providerRuntimeTesting: typeof import("./provider-runtime.js").__testing;
7273
let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel;
7374
let validateProviderReplayTurnsWithPlugin: typeof import("./provider-runtime.js").validateProviderReplayTurnsWithPlugin;
7475
let wrapProviderStreamFn: typeof import("./provider-runtime.js").wrapProviderStreamFn;
@@ -282,6 +283,7 @@ describe("provider-runtime", () => {
282283
resetProviderRuntimeHookCacheForTest,
283284
refreshProviderOAuthCredentialWithPlugin,
284285
resolveProviderRuntimePlugin,
286+
__testing: providerRuntimeTesting,
285287
runProviderDynamicModel,
286288
validateProviderReplayTurnsWithPlugin,
287289
wrapProviderStreamFn,
@@ -330,6 +332,26 @@ describe("provider-runtime", () => {
330332
});
331333
});
332334

335+
it("normalizes plugin scopes in provider hook cache keys", () => {
336+
const base = {
337+
workspaceDir: "/tmp/workspace",
338+
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
339+
providerRefs: ["demo"],
340+
};
341+
342+
expect(
343+
providerRuntimeTesting.buildHookProviderCacheKey({
344+
...base,
345+
onlyPluginIds: [" beta ", "alpha", "beta"],
346+
}),
347+
).toBe(
348+
providerRuntimeTesting.buildHookProviderCacheKey({
349+
...base,
350+
onlyPluginIds: ["alpha", "beta"],
351+
}),
352+
);
353+
});
354+
333355
it("returns provider-prepared runtime auth for the matched provider", async () => {
334356
const prepareRuntimeAuth = vi.fn(async () => ({
335357
apiKey: "runtime-token",

src/plugins/provider-runtime.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ProviderSystemPromptContribution } from "../agents/system-prompt-c
88
import type { ModelProviderConfig } from "../config/types.js";
99
import type { OpenClawConfig } from "../config/types.openclaw.js";
1010
import { normalizeOptionalString } from "../shared/string-coerce.js";
11+
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
1112
import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js";
1213
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
1314
import { resolveCatalogHookProviderPluginIds } from "./providers.js";
@@ -123,7 +124,8 @@ function buildHookProviderCacheKey(params: {
123124
workspaceDir: params.workspaceDir,
124125
env: params.env,
125126
});
126-
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${JSON.stringify(params.onlyPluginIds ?? [])}::${JSON.stringify(params.providerRefs ?? [])}`;
127+
const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds);
128+
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`;
127129
}
128130

129131
export function clearProviderRuntimeHookCache(): void {
@@ -141,6 +143,10 @@ export function resetProviderRuntimeHookCacheForTest(): void {
141143
clearProviderRuntimeHookCache();
142144
}
143145

146+
export const __testing = {
147+
buildHookProviderCacheKey,
148+
} as const;
149+
144150
function resolveProviderPluginsForHooks(params: {
145151
config?: OpenClawConfig;
146152
workspaceDir?: string;

src/plugins/providers.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resolveRuntimePluginRegistry,
88
type PluginLoadOptions,
99
} from "./loader.js";
10+
import { hasExplicitPluginIdScope } from "./plugin-scope.js";
1011
import {
1112
resolveActivatableProviderOwnerPluginIds,
1213
resolveDiscoverableProviderOwnerPluginIds,
@@ -99,7 +100,7 @@ function resolvePluginProviderLoadBase(params: {
99100
})
100101
: [];
101102
const requestedPluginIds =
102-
params.onlyPluginIds ||
103+
hasExplicitPluginIdScope(params.onlyPluginIds) ||
103104
params.providerRefs?.length ||
104105
params.modelRefs?.length ||
105106
providerOwnedPluginIds.length > 0 ||

src/plugins/providers.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOw
2323
let resolveOwningPluginIdsForModelRef: typeof import("./providers.js").resolveOwningPluginIdsForModelRef;
2424
let resolveActivatableProviderOwnerPluginIds: typeof import("./providers.js").resolveActivatableProviderOwnerPluginIds;
2525
let resolveEnabledProviderPluginIds: typeof import("./providers.js").resolveEnabledProviderPluginIds;
26+
let resolveDiscoveredProviderPluginIds: typeof import("./providers.js").resolveDiscoveredProviderPluginIds;
2627
let resolveDiscoverableProviderOwnerPluginIds: typeof import("./providers.js").resolveDiscoverableProviderOwnerPluginIds;
2728
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
2829
let setActivePluginRegistry: SetActivePluginRegistry;
@@ -143,7 +144,7 @@ function expectLastRuntimeRegistryLoad(params?: {
143144
cache: false,
144145
activate: false,
145146
...(params?.env ? { env: params.env } : {}),
146-
...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}),
147+
...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}),
147148
}),
148149
);
149150
}
@@ -157,7 +158,7 @@ function expectLastSetupRegistryLoad(params?: {
157158
cache: false,
158159
activate: false,
159160
...(params?.env ? { env: params.env } : {}),
160-
...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}),
161+
...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}),
161162
}),
162163
);
163164
}
@@ -194,7 +195,7 @@ function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly
194195
},
195196
},
196197
bundledProviderAllowlistCompat: true,
197-
...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}),
198+
...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}),
198199
};
199200
}
200201

@@ -290,6 +291,7 @@ describe("resolvePluginProviders", () => {
290291
resolveOwningPluginIdsForProvider,
291292
resolveOwningPluginIdsForModelRef,
292293
resolveEnabledProviderPluginIds,
294+
resolveDiscoveredProviderPluginIds,
293295
resolveDiscoverableProviderOwnerPluginIds,
294296
} = await import("./providers.js"));
295297
({ resolvePluginProviders } = await import("./providers.runtime.js"));
@@ -385,6 +387,23 @@ describe("resolvePluginProviders", () => {
385387
]);
386388
});
387389

390+
it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => {
391+
expect(
392+
resolveEnabledProviderPluginIds({
393+
config: {},
394+
env: {} as NodeJS.ProcessEnv,
395+
onlyPluginIds: [],
396+
}),
397+
).toEqual([]);
398+
expect(
399+
resolveDiscoveredProviderPluginIds({
400+
config: {},
401+
env: {} as NodeJS.ProcessEnv,
402+
onlyPluginIds: [],
403+
}),
404+
).toEqual([]);
405+
});
406+
388407
it.each([
389408
{
390409
name: "can augment restrictive allowlists for bundled provider compatibility",

src/plugins/providers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type PluginManifestRecord,
88
type PluginManifestRegistry,
99
} from "./manifest-registry.js";
10+
import { createPluginIdScopeSet } from "./plugin-scope.js";
1011

1112
export function withBundledProviderVitestCompat(params: {
1213
config: PluginLoadOptions["config"];
@@ -22,7 +23,7 @@ export function resolveBundledProviderCompatPluginIds(params: {
2223
env?: PluginLoadOptions["env"];
2324
onlyPluginIds?: readonly string[];
2425
}): string[] {
25-
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
26+
const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds);
2627
const registry = loadPluginManifestRegistry({
2728
config: params.config,
2829
workspaceDir: params.workspaceDir,
@@ -45,7 +46,7 @@ export function resolveEnabledProviderPluginIds(params: {
4546
env?: PluginLoadOptions["env"];
4647
onlyPluginIds?: readonly string[];
4748
}): string[] {
48-
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
49+
const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds);
4950
const registry = loadPluginManifestRegistry({
5051
config: params.config,
5152
workspaceDir: params.workspaceDir,
@@ -76,7 +77,7 @@ export function resolveDiscoveredProviderPluginIds(params: {
7677
onlyPluginIds?: readonly string[];
7778
includeUntrustedWorkspacePlugins?: boolean;
7879
}): string[] {
79-
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
80+
const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds);
8081
const registry = loadPluginManifestRegistry({
8182
config: params.config,
8283
workspaceDir: params.workspaceDir,

0 commit comments

Comments
 (0)