From 3ed569ac3c7adad2ad210c5e216771ab28920d45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:09:17 +0100 Subject: [PATCH] fix(plugins): respect allowlist for web provider fallback --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/gateway/configuration-reference.md | 4 ++ docs/tools/plugin.md | 7 +++ src/config/schema.base.generated.ts | 12 ++++ src/config/schema.labels.ts | 1 + src/plugins/activation-context.ts | 1 + ...provider-public-artifacts.fallback.test.ts | 58 +++++++++++++++++++ src/plugins/web-provider-public-artifacts.ts | 32 +++++++--- .../web-search-providers.runtime.test.ts | 57 ++++++++++++++++++ 10 files changed, 167 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0135a9efa..dbc9a44a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. +- Plugins/providers: add `plugins.bundledDiscovery: "allowlist"` so restrictive `plugins.allow` deployments can keep bundled provider and web-search provider discovery from auto-loading omitted bundled plugins. Thanks @dougbtv. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. - Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2a1b748608a..28f30f0ee76 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2c78fb7af01e2ee9e919be5ab7b675347b36cae1e347f97fd2640a6f7c72f3ac config-baseline.json -31ec333df9f8b92c7656ac7107cecd5860dd02e08f7e18c7c674dc47a8811baa config-baseline.core.json +ddea4f1ae40a4099baa9f216cdae69ac35a5e93aa254903227ce168e2fd5b8db config-baseline.json +b6b71095384ad98410bbfd520eebac43e244aeb47761c74325ff133be6ccd858 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1afaa8d8581..b742b2550b7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -166,6 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], + bundledMode: "compat", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -187,6 +188,9 @@ 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 + bundled provider plugins, including web-search runtime providers. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 54d924315a0..a80beba80a7 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -264,6 +264,7 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community). | ---------------- | --------------------------------------------------------- | | `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`) | @@ -275,6 +276,12 @@ 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.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. + Config changes made through `/plugins enable` or `/plugins disable` trigger an in-process Gateway plugin reload. New agent turns rebuild their tool list from the refreshed plugin registry. Source-changing operations such as install, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index fccb1e06d4e..b8fc590c78e 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24186,6 +24186,13 @@ 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: { + type: "string", + enum: ["compat", "respect-allow"], + title: "Bundled Plugin Mode", + 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.', + }, }, additionalProperties: false, title: "Plugins", @@ -28865,6 +28872,11 @@ 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.', + tags: ["advanced"], + }, "plugins.deny": { label: "Plugin Denylist", help: "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ac38254f107..a3d9eaacd31 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -905,6 +905,7 @@ export const FIELD_LABELS: Record = { plugins: "Plugins", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", + "plugins.bundledMode": "Bundled Plugin Mode", "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index 5753174e772..bcace14890d 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -79,6 +79,7 @@ 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; diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index d200de9fc46..91ab8f82aee 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -87,4 +87,62 @@ describe("web provider public artifact manifest fallback", () => { pluginId: "fallback-fetch", }); }); + + it("keeps explicit bundled web-search public artifact candidates inside respect-allow", () => { + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts.mockImplementation( + (params: { onlyPluginIds: readonly string[] }) => + params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), + ); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-search"], + bundledMode: "respect-allow", + }, + }, + onlyPluginIds: ["blocked-search", "fallback-search"], + }); + + expect(providers).toEqual([{ id: "fallback-search", pluginId: "fallback-search" }]); + expect(mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts).toHaveBeenCalledWith({ + onlyPluginIds: ["fallback-search"], + }); + }); + + it("keeps manifest bundled web-fetch public artifact candidates inside respect-allow", () => { + mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ + diagnostics: [], + plugins: [ + { + id: "blocked-fetch", + origin: "bundled", + rootDir: "/tmp/blocked-fetch", + contracts: { webFetchProviders: ["blocked-fetch"] }, + }, + { + id: "fallback-fetch", + origin: "bundled", + rootDir: "/tmp/fallback-fetch", + contracts: { webFetchProviders: ["fallback-fetch"] }, + }, + ], + }); + + const providers = resolveBundledWebFetchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-fetch"], + bundledMode: "respect-allow", + }, + }, + }); + + expect(providers).toEqual([{ id: "fallback-fetch", pluginId: "fallback-fetch" }]); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledOnce(); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledWith({ + dirName: "fallback-fetch", + pluginId: "fallback-fetch", + }); + }); }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index bcd89d29a0c..ed927e9f766 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -26,6 +26,22 @@ type BundledCandidateResolution = { manifestRecords?: readonly PluginManifestRecord[]; }; +function filterRespectAllowBundledPluginIds( + config: PluginLoadOptions["config"] | undefined, + pluginIds: readonly string[], +) { + const allow = config?.plugins?.allow; + if ( + config?.plugins?.bundledMode !== "respect-allow" || + !Array.isArray(allow) || + allow.length === 0 + ) { + return [...pluginIds]; + } + const allowedPluginIds = new Set(allow.map((pluginId) => pluginId.trim()).filter(Boolean)); + return pluginIds.filter((pluginId) => allowedPluginIds.has(pluginId)); +} + function resolveBundledCandidatePluginIds(params: { contract: "webSearchProviders" | "webFetchProviders"; configKey: "webSearch" | "webFetch"; @@ -35,17 +51,17 @@ function resolveBundledCandidatePluginIds(params: { bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }): BundledCandidateResolution { - if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { - return { - pluginIds: [...new Set(params.onlyPluginIds)].toSorted((left, right) => - left.localeCompare(right), - ), - }; - } const resolvedConfig = params.contract === "webSearchProviders" ? resolveBundledWebSearchResolutionConfig(params).config : resolveBundledWebFetchResolutionConfig(params).config; + if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { + return { + pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, [ + ...new Set(params.onlyPluginIds), + ]).toSorted((left, right) => left.localeCompare(right)), + }; + } const candidates = resolveManifestDeclaredWebProviderCandidates({ contract: params.contract, configKey: params.configKey, @@ -56,7 +72,7 @@ function resolveBundledCandidatePluginIds(params: { origin: "bundled", }); return { - pluginIds: candidates.pluginIds ?? [], + pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []), ...(candidates.manifestRecords ? { manifestRecords: candidates.manifestRecords } : {}), }; } diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index b537b330366..91abc6c6700 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -182,6 +182,27 @@ function createManifestRegistryFixture(): PluginManifestRegistry { }; } +function createWebSearchManifestRecord(params: { + id: string; + providerId: string; +}): PluginManifestRegistry["plugins"][number] { + return { + id: params.id, + origin: "bundled", + rootDir: `/tmp/${params.id}`, + source: `/tmp/${params.id}/index.js`, + manifestPath: `/tmp/${params.id}/openclaw.plugin.json`, + channels: [], + providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], + skills: [], + hooks: [], + contracts: { webSearchProviders: [params.providerId] }, + }; +} + function expectLoaderCallCount(count: number) { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(count); } @@ -461,6 +482,42 @@ describe("resolvePluginWebSearchProviders", () => { expectScopedWebSearchCandidates(["brave"]); }); + it("keeps respect-allow web-search provider discovery scoped to the configured allowlist", () => { + loadInstalledPluginManifestRegistryMock.mockReturnValueOnce({ + plugins: [ + createWebSearchManifestRecord({ id: "brave", providerId: "brave" }), + createWebSearchManifestRecord({ id: "google", providerId: "gemini" }), + ], + diagnostics: [], + }); + + const providers = resolvePluginWebSearchProviders({ + config: { + plugins: { + allow: ["brave"], + bundledMode: "respect-allow", + }, + }, + bundledAllowlistCompat: true, + env: createWebSearchEnv(), + workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, + }); + + expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]); + expectScopedWebSearchCandidates(["brave"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["brave"], + bundledMode: "respect-allow", + entries: { brave: { enabled: true } }, + }), + }), + }), + ); + }); + it("uses the active registry workspace for candidate discovery and snapshot loads when workspaceDir is omitted", () => { const env = createWebSearchEnv(); const rawConfig = createBraveAllowConfig();