fix(plugins): respect allowlist for web provider fallback

This commit is contained in:
Peter Steinberger
2026-05-04 09:09:17 +01:00
parent f738663c79
commit 3ed569ac3c
10 changed files with 167 additions and 10 deletions

View File

@@ -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.",

View File

@@ -905,6 +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.deny": "Plugin Denylist",
"plugins.load": "Plugin Loader",
"plugins.load.paths": "Plugin Load Paths",

View File

@@ -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;

View File

@@ -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",
});
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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();