mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:40:43 +00:00
fix(plugins): respect allowlist for web provider fallback
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user