fix(plugins): rename bundled allowlist discovery policy

This commit is contained in:
Peter Steinberger
2026-05-04 09:42:37 +01:00
parent 76e0bcd2de
commit 41257a5f6f
20 changed files with 114 additions and 63 deletions

View File

@@ -1,4 +1,4 @@
ddea4f1ae40a4099baa9f216cdae69ac35a5e93aa254903227ce168e2fd5b8db config-baseline.json
b6b71095384ad98410bbfd520eebac43e244aeb47761c74325ff133be6ccd858 config-baseline.core.json
14558f9777b400fe4a1ef163a44e90ac0c59b56920ceb24b99675647d19d73a8 config-baseline.json
0c46cd7aeae83eb3afddd19209bf3520cecccc265903b2fe001ce458bc592ea5 config-baseline.core.json
cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json
9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json

View File

@@ -166,7 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
plugins: {
enabled: true,
allow: ["voice-call"],
bundledMode: "compat",
bundledDiscovery: "compat",
deny: [],
load: {
paths: ["~/Projects/oss/voice-call-plugin"],
@@ -188,8 +188,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
- **Config changes require a gateway restart.**
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
- `bundledMode`: defaults to `"compat"` for legacy bundled provider activation.
Use `"respect-allow"` when a non-empty `plugins.allow` should also gate
- `bundledDiscovery`: defaults to `"compat"` for legacy bundled provider activation.
Use `"allowlist"` when a non-empty `plugins.allow` should also gate
bundled provider plugins, including web-search runtime providers.
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.

View File

@@ -169,7 +169,9 @@ That stages grounded durable candidates into the short-term dreaming store while
Doctor also warns when `plugins.allow` is non-empty and tool policy uses
wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools
from plugins that actually load; it does not bypass the exclusive plugin
allowlist.
allowlist. If bundled provider discovery is still in legacy compatibility
mode, doctor also points to the stricter `plugins.bundledDiscovery:
"allowlist"` setting.
</Accordion>
<Accordion title="2. Legacy config key migrations">

View File

@@ -260,15 +260,15 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community).
}
```
| Field | Description |
| ---------------- | --------------------------------------------------------- |
| `enabled` | Master toggle (default: `true`) |
| `allow` | Plugin allowlist (optional) |
| `bundledMode` | Bundled plugin allowlist mode (`compat` by default) |
| `deny` | Plugin denylist (optional; deny wins) |
| `load.paths` | Extra plugin files/directories |
| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) |
| `entries.\<id\>` | Per-plugin toggles + config |
| Field | Description |
| ------------------ | --------------------------------------------------------- |
| `enabled` | Master toggle (default: `true`) |
| `allow` | Plugin allowlist (optional) |
| `bundledDiscovery` | Bundled plugin discovery mode (`compat` by default) |
| `deny` | Plugin denylist (optional; deny wins) |
| `load.paths` | Extra plugin files/directories |
| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) |
| `entries.\<id\>` | Per-plugin toggles + config |
`plugins.allow` is exclusive. When it is non-empty, only listed plugins can load
or expose tools, even if `tools.allow` contains `"*"` or a specific plugin-owned
@@ -276,8 +276,8 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id
to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this
shape.
`plugins.bundledMode` defaults to `"compat"` so older configs keep legacy
bundled provider behavior. Set it to `"respect-allow"` when a restrictive
`plugins.bundledDiscovery` defaults to `"compat"` so older configs keep legacy
bundled provider behavior. Set it to `"allowlist"` when a restrictive
`plugins.allow` inventory should also block omitted bundled provider plugins,
including runtime web-search provider discovery. An empty `plugins.allow` is
still treated as unset/open.

View File

@@ -84,13 +84,13 @@ describe("implicit provider plugin allowlist compatibility", () => {
).toEqual(["kilocode", "moonshot", "openrouter"]);
});
it("respects allowlist for bundled plugins when bundledMode is respect-allow", () => {
it("respects allowlist for bundled plugins when bundledDiscovery is allowlist", () => {
const config = withBundledPluginEnablementCompat({
config: withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: ["openrouter"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
},
},
pluginIds: ["kilocode", "moonshot"],

View File

@@ -161,12 +161,15 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}));
}
const { collectPluginToolAllowlistWarnings } =
const { collectBundledProviderAllowlistPolicyWarnings, collectPluginToolAllowlistWarnings } =
await import("./doctor/shared/plugin-tool-allowlist-warnings.js");
const pluginToolAllowlistWarnings = collectPluginToolAllowlistWarnings({
cfg: candidate,
env: process.env,
});
const pluginToolAllowlistWarnings = [
...collectPluginToolAllowlistWarnings({
cfg: candidate,
env: process.env,
}),
...collectBundledProviderAllowlistPolicyWarnings({ cfg: candidate }),
];
if (pluginToolAllowlistWarnings.length > 0) {
note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings");
}

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js";
import { collectPluginToolAllowlistWarnings } from "./plugin-tool-allowlist-warnings.js";
import {
collectBundledProviderAllowlistPolicyWarnings,
collectPluginToolAllowlistWarnings,
} from "./plugin-tool-allowlist-warnings.js";
const manifestRegistry: PluginManifestRegistry = {
diagnostics: [],
@@ -109,4 +112,29 @@ describe("collectPluginToolAllowlistWarnings", () => {
expect(warnings).toEqual([]);
});
it("warns when restrictive plugins.allow leaves bundled provider discovery in compat mode", () => {
const warnings = collectBundledProviderAllowlistPolicyWarnings({
cfg: {
plugins: { allow: ["telegram"] },
},
});
expect(warnings).toEqual([
expect.stringContaining('set plugins.bundledDiscovery to "allowlist"'),
]);
});
it("does not warn when bundled provider discovery follows the allowlist", () => {
const warnings = collectBundledProviderAllowlistPolicyWarnings({
cfg: {
plugins: {
allow: ["telegram"],
bundledDiscovery: "allowlist",
},
},
});
expect(warnings).toEqual([]);
});
});

View File

@@ -193,3 +193,21 @@ export function collectPluginToolAllowlistWarnings(params: {
return warnings;
}
export function collectBundledProviderAllowlistPolicyWarnings(params: {
cfg: OpenClawConfig;
}): string[] {
if (params.cfg.plugins?.enabled === false) {
return [];
}
const allow = params.cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0) {
return [];
}
if (params.cfg.plugins?.bundledDiscovery === "allowlist") {
return [];
}
return [
'- plugins.allow is restrictive, but bundled provider discovery is still in legacy compatibility mode. Bundled provider plugins can still appear in runtime provider inventories; set plugins.bundledDiscovery to "allowlist" after confirming omitted bundled providers are intentionally blocked.',
];
}

View File

@@ -24186,12 +24186,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.",
},
bundledMode: {
bundledDiscovery: {
type: "string",
enum: ["compat", "respect-allow"],
title: "Bundled Plugin Mode",
enum: ["compat", "allowlist"],
title: "Bundled Plugin Discovery",
description:
'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.',
'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.',
},
},
additionalProperties: false,
@@ -28872,9 +28872,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Configured bundled chat channels can still activate their bundled plugin when the channel is explicitly enabled in config. Use this to enforce approved extension inventories in controlled environments.",
tags: ["access"],
},
"plugins.bundledMode": {
label: "Bundled Plugin Mode",
help: 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.',
"plugins.bundledDiscovery": {
label: "Bundled Plugin Discovery",
help: 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.',
tags: ["advanced"],
},
"plugins.deny": {

View File

@@ -1212,8 +1212,8 @@ export const FIELD_HELP: Record<string, string> = {
'Select the active memory plugin by id, or "none" to disable memory plugins.',
"plugins.slots.contextEngine":
"Selects the active context engine plugin by id so one plugin provides context orchestration behavior.",
"plugins.bundledMode":
'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.',
"plugins.bundledDiscovery":
'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.',
"plugins.entries":
"Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.",
"plugins.entries.*.enabled":

View File

@@ -905,7 +905,7 @@ export const FIELD_LABELS: Record<string, string> = {
plugins: "Plugins",
"plugins.enabled": "Enable Plugins",
"plugins.allow": "Plugin Allowlist",
"plugins.bundledMode": "Bundled Plugin Mode",
"plugins.bundledDiscovery": "Bundled Plugin Discovery",
"plugins.deny": "Plugin Denylist",
"plugins.load": "Plugin Loader",
"plugins.load.paths": "Plugin Load Paths",

View File

@@ -52,15 +52,15 @@ export type PluginsConfig = {
/** Optional plugin denylist (plugin ids). */
deny?: string[];
/**
* Controls whether bundled plugins bypass `allow` / `entries` on runtime
* provider discovery paths.
* Controls how bundled plugins participate in runtime provider discovery when
* `allow` is configured.
*
* - `"compat"` (default): bundled provider plugins are force-loaded on
* every chat turn regardless of the allowlist (legacy behavior).
* - `"respect-allow"`: bundled provider plugins are gated by `allow` and
* `entries.<id>.enabled` the same way third-party plugins are.
* - `"allowlist"`: bundled provider plugins are gated by `allow` and
* `entries.<id>.enabled` like third-party plugins.
*/
bundledMode?: "compat" | "respect-allow";
bundledDiscovery?: "compat" | "allowlist";
load?: PluginsLoadConfig;
slots?: PluginSlotsConfig;
entries?: Record<string, PluginEntryConfig>;

View File

@@ -1073,7 +1073,7 @@ export const OpenClawSchema = z
.strict()
.optional(),
entries: z.record(z.string(), PluginEntrySchema).optional(),
bundledMode: z.enum(["compat", "respect-allow"]).optional(),
bundledDiscovery: z.enum(["compat", "allowlist"]).optional(),
})
.strict()
.optional(),

View File

@@ -79,10 +79,10 @@ export function withActivatedPluginIds(params: {
return params.config;
}
const originalAllow = params.config?.plugins?.allow ?? [];
// Empty allowlists are still open; respect-allow only stops compat from widening configured allowlists.
const respectAllow =
params.config?.plugins?.bundledMode === "respect-allow" && originalAllow.length > 0;
const originalAllowSet = respectAllow ? new Set(originalAllow) : undefined;
// Empty allowlists are still open; allowlist mode only stops compat from widening configured allowlists.
const useAllowlistDiscovery =
params.config?.plugins?.bundledDiscovery === "allowlist" && originalAllow.length > 0;
const originalAllowSet = useAllowlistDiscovery ? new Set(originalAllow) : undefined;
const allow = new Set(originalAllow);
const entries = {
...params.config?.plugins?.entries,

View File

@@ -6,7 +6,7 @@ export function withBundledPluginAllowlistCompat(params: {
config: OpenClawConfig | undefined;
pluginIds: readonly string[];
}): OpenClawConfig | undefined {
if (params.config?.plugins?.bundledMode === "respect-allow") {
if (params.config?.plugins?.bundledDiscovery === "allowlist") {
return params.config;
}
const allow = params.config?.plugins?.allow;
@@ -42,8 +42,8 @@ export function withBundledPluginEnablementCompat(params: {
}): OpenClawConfig | undefined {
const existingEntries = params.config?.plugins?.entries ?? {};
const forcePluginsEnabled = params.config?.plugins?.enabled === false;
const respectAllow = params.config?.plugins?.bundledMode === "respect-allow";
const allowSet = respectAllow ? new Set(params.config?.plugins?.allow ?? []) : undefined;
const useAllowlistDiscovery = params.config?.plugins?.bundledDiscovery === "allowlist";
const allowSet = useAllowlistDiscovery ? new Set(params.config?.plugins?.allow ?? []) : undefined;
let changed = false;
const nextEntries: Record<string, PluginEntryConfig> = { ...existingEntries };

View File

@@ -593,7 +593,7 @@ describe("resolvePluginProviders", () => {
).toEqual(["legacy-auth-owner"]);
});
it("filters bundled provider plugins by allowlist when bundledMode is respect-allow", () => {
it("filters bundled provider plugins by allowlist when bundledDiscovery is allowlist", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "kilocode",
@@ -619,7 +619,7 @@ describe("resolvePluginProviders", () => {
config: {
plugins: {
allow: ["openrouter"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
},
},
env: {} as NodeJS.ProcessEnv,

View File

@@ -255,7 +255,7 @@ export function resolveDiscoveredProviderPluginIds(params: {
const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params);
const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry });
const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false;
const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow";
const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist";
const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry);
return listRegistryPluginIds(registry, (plugin) => {
if (
@@ -313,7 +313,7 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: {
includeUntrustedWorkspacePlugins?: boolean;
}): string[] {
const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false;
const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow";
const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist";
return resolveProviderOwnerPluginIds({
...params,
isEligible: (plugin, normalizedConfig) =>

View File

@@ -88,7 +88,7 @@ describe("web provider public artifact manifest fallback", () => {
});
});
it("keeps explicit bundled web-search public artifact candidates inside respect-allow", () => {
it("keeps explicit bundled web-search public artifact candidates inside allowlist discovery", () => {
const resolveExplicitWebSearchProviders =
mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as {
mockImplementation: (
@@ -105,7 +105,7 @@ describe("web provider public artifact manifest fallback", () => {
config: {
plugins: {
allow: ["fallback-search"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
},
},
onlyPluginIds: ["blocked-search", "fallback-search"],
@@ -117,7 +117,7 @@ describe("web provider public artifact manifest fallback", () => {
});
});
it("keeps manifest bundled web-fetch public artifact candidates inside respect-allow", () => {
it("keeps manifest bundled web-fetch public artifact candidates inside allowlist discovery", () => {
mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({
diagnostics: [],
plugins: [
@@ -140,7 +140,7 @@ describe("web provider public artifact manifest fallback", () => {
config: {
plugins: {
allow: ["fallback-fetch"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
},
},
});

View File

@@ -26,13 +26,13 @@ type BundledCandidateResolution = {
manifestRecords?: readonly PluginManifestRecord[];
};
function filterRespectAllowBundledPluginIds(
function filterAllowlistedBundledPluginIds(
config: PluginLoadOptions["config"] | undefined,
pluginIds: readonly string[],
) {
const allow = config?.plugins?.allow;
if (
config?.plugins?.bundledMode !== "respect-allow" ||
config?.plugins?.bundledDiscovery !== "allowlist" ||
!Array.isArray(allow) ||
allow.length === 0
) {
@@ -57,7 +57,7 @@ function resolveBundledCandidatePluginIds(params: {
: resolveBundledWebFetchResolutionConfig(params).config;
if (params.onlyPluginIds && params.onlyPluginIds.length > 0) {
return {
pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, [
pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, [
...new Set(params.onlyPluginIds),
]).toSorted((left, right) => left.localeCompare(right)),
};
@@ -72,7 +72,7 @@ function resolveBundledCandidatePluginIds(params: {
origin: "bundled",
});
return {
pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []),
pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []),
...(candidates.manifestRecords ? { manifestRecords: candidates.manifestRecords } : {}),
};
}

View File

@@ -482,7 +482,7 @@ describe("resolvePluginWebSearchProviders", () => {
expectScopedWebSearchCandidates(["brave"]);
});
it("keeps respect-allow web-search provider discovery scoped to the configured allowlist", () => {
it("keeps allowlist web-search provider discovery scoped to the configured allowlist", () => {
loadInstalledPluginManifestRegistryMock.mockReturnValueOnce({
plugins: [
createWebSearchManifestRecord({ id: "brave", providerId: "brave" }),
@@ -495,7 +495,7 @@ describe("resolvePluginWebSearchProviders", () => {
config: {
plugins: {
allow: ["brave"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
},
},
bundledAllowlistCompat: true,
@@ -510,7 +510,7 @@ describe("resolvePluginWebSearchProviders", () => {
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["brave"],
bundledMode: "respect-allow",
bundledDiscovery: "allowlist",
entries: { brave: { enabled: true } },
}),
}),