Compare commits

...

3 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
a1e6efb0fb test(plugins): cover capability allowlist fallback paths 2026-04-03 16:22:32 -04:00
Gustavo Madeira Santana
476ea0d097 fix(plugins): respect capability provider allowlists 2026-04-03 16:15:37 -04:00
YimingPan
4be2c52041 fix(plugins): honor explicit capability allowlists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:15:37 -04:00
5 changed files with 192 additions and 25 deletions

View File

@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
## 2026.4.2

View File

@@ -0,0 +1,76 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
withBundledPluginEnablementCompat: vi.fn(({ config }) => config),
withBundledPluginVitestCompat: vi.fn(({ config }) => config),
}));
vi.mock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mocks.loadPluginManifestRegistry,
}));
vi.mock("../plugins/bundled-compat.js", () => ({
withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat,
withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat,
}));
let getImageGenerationProvider: typeof import("./provider-registry.js").getImageGenerationProvider;
let listImageGenerationProviders: typeof import("./provider-registry.js").listImageGenerationProviders;
describe("image-generation provider registry allowlist fallback", () => {
beforeAll(async () => {
({ getImageGenerationProvider, listImageGenerationProviders } =
await import("./provider-registry.js"));
});
beforeEach(() => {
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
mocks.withBundledPluginEnablementCompat.mockReset();
mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config);
mocks.withBundledPluginVitestCompat.mockReset();
mocks.withBundledPluginVitestCompat.mockImplementation(({ config }) => config);
});
it("honors explicit allowlists when fallback loading bundled providers", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const compatConfig = {
plugins: {
allow: ["custom-plugin"],
entries: { openai: { enabled: true } },
},
};
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
contracts: { imageGenerationProviders: ["openai"] },
},
] as never,
diagnostics: [],
});
mocks.withBundledPluginEnablementCompat.mockReturnValue(compatConfig);
mocks.withBundledPluginVitestCompat.mockReturnValue(compatConfig);
mocks.resolveRuntimePluginRegistry.mockImplementation(() => createEmptyPluginRegistry());
expect(listImageGenerationProviders(cfg)).toEqual([]);
expect(getImageGenerationProvider("openai", cfg)).toBeUndefined();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: compatConfig,
});
});
});

View File

@@ -0,0 +1,77 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
withBundledPluginEnablementCompat: vi.fn(({ config }) => config),
withBundledPluginVitestCompat: vi.fn(({ config }) => config),
}));
vi.mock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mocks.loadPluginManifestRegistry,
}));
vi.mock("../plugins/bundled-compat.js", () => ({
withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat,
withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat,
}));
let buildMediaUnderstandingRegistry: typeof import("./provider-registry.js").buildMediaUnderstandingRegistry;
let getMediaUnderstandingProvider: typeof import("./provider-registry.js").getMediaUnderstandingProvider;
describe("media-understanding provider registry allowlist fallback", () => {
beforeAll(async () => {
({ buildMediaUnderstandingRegistry, getMediaUnderstandingProvider } =
await import("./provider-registry.js"));
});
beforeEach(() => {
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
mocks.withBundledPluginEnablementCompat.mockReset();
mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config);
mocks.withBundledPluginVitestCompat.mockReset();
mocks.withBundledPluginVitestCompat.mockImplementation(({ config }) => config);
});
it("honors explicit allowlists when fallback loading bundled providers", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const compatConfig = {
plugins: {
allow: ["custom-plugin"],
entries: { openai: { enabled: true } },
},
};
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
contracts: { mediaUnderstandingProviders: ["openai"] },
},
] as never,
diagnostics: [],
});
mocks.withBundledPluginEnablementCompat.mockReturnValue(compatConfig);
mocks.withBundledPluginVitestCompat.mockReturnValue(compatConfig);
mocks.resolveRuntimePluginRegistry.mockImplementation(() => createEmptyPluginRegistry());
const registry = buildMediaUnderstandingRegistry(undefined, cfg);
expect(getMediaUnderstandingProvider("openai", registry)).toBeUndefined();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: compatConfig,
});
});
});

View File

@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn<() => MockManifestRegistry>(() =>
createEmptyMockManifestRegistry(),
),
withBundledPluginAllowlistCompat: vi.fn(({ config }) => config),
withBundledPluginEnablementCompat: vi.fn(({ config }) => config),
withBundledPluginVitestCompat: vi.fn(({ config }) => config),
}));
@@ -32,7 +31,6 @@ vi.mock("./manifest-registry.js", () => ({
}));
vi.mock("./bundled-compat.js", () => ({
withBundledPluginAllowlistCompat: mocks.withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat,
withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat,
}));
@@ -49,10 +47,9 @@ function expectNoResolvedCapabilityProviders(providers: Array<{ id: string }>) {
function expectBundledCompatLoadPath(params: {
cfg: OpenClawConfig;
allowlistCompat: { plugins: { allow: string[] } };
enablementCompat: {
plugins: {
allow: string[];
allow?: string[];
entries: { openai: { enabled: boolean } };
};
};
@@ -61,12 +58,8 @@ function expectBundledCompatLoadPath(params: {
config: params.cfg,
env: process.env,
});
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
config: params.cfg,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
config: params.allowlistCompat,
config: params.cfg,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
@@ -81,14 +74,13 @@ function expectBundledCompatLoadPath(params: {
function createCompatChainConfig() {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const allowlistCompat = { plugins: { allow: ["custom-plugin", "openai"] } };
const enablementCompat = {
plugins: {
allow: ["custom-plugin", "openai"],
allow: ["custom-plugin"],
entries: { openai: { enabled: true } },
},
};
return { cfg, allowlistCompat, enablementCompat };
return { cfg, enablementCompat };
}
function setBundledCapabilityFixture(contractKey: string) {
@@ -113,16 +105,14 @@ function expectCompatChainApplied(params: {
key: "speechProviders" | "mediaUnderstandingProviders" | "imageGenerationProviders";
contractKey: string;
cfg: OpenClawConfig;
allowlistCompat: { plugins: { allow: string[] } };
enablementCompat: {
plugins: {
allow: string[];
allow?: string[];
entries: { openai: { enabled: boolean } };
};
};
}) {
setBundledCapabilityFixture(params.contractKey);
mocks.withBundledPluginAllowlistCompat.mockReturnValue(params.allowlistCompat);
mocks.withBundledPluginEnablementCompat.mockReturnValue(params.enablementCompat);
mocks.withBundledPluginVitestCompat.mockReturnValue(params.enablementCompat);
expectNoResolvedCapabilityProviders(
@@ -141,8 +131,6 @@ describe("resolvePluginCapabilityProviders", () => {
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry());
mocks.withBundledPluginAllowlistCompat.mockReset();
mocks.withBundledPluginAllowlistCompat.mockImplementation(({ config }) => config);
mocks.withBundledPluginEnablementCompat.mockReset();
mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config);
mocks.withBundledPluginVitestCompat.mockReset();
@@ -216,16 +204,46 @@ describe("resolvePluginCapabilityProviders", () => {
["mediaUnderstandingProviders", "mediaUnderstandingProviders"],
["imageGenerationProviders", "imageGenerationProviders"],
] as const)("applies bundled compat before fallback loading for %s", (key, contractKey) => {
const { cfg, allowlistCompat, enablementCompat } = createCompatChainConfig();
const { cfg, enablementCompat } = createCompatChainConfig();
expectCompatChainApplied({
key,
contractKey,
cfg,
allowlistCompat,
enablementCompat,
});
});
it("does not re-add bundled capability plugins excluded by an explicit allowlist", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const enablementCompat = {
plugins: {
allow: ["custom-plugin"],
entries: { openai: { enabled: true } },
},
};
setBundledCapabilityFixture("speechProviders");
mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat);
mocks.withBundledPluginVitestCompat.mockReturnValue(enablementCompat);
expectNoResolvedCapabilityProviders(
resolvePluginCapabilityProviders({ key: "speechProviders", cfg }),
);
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
config: cfg,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
config: enablementCompat,
pluginIds: ["openai"],
env: process.env,
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: enablementCompat,
});
});
it("reuses a compatible active registry even when the capability list is empty", () => {
const active = createEmptyPluginRegistry();
mocks.resolveRuntimePluginRegistry.mockReturnValue(active);

View File

@@ -1,6 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
@@ -48,12 +47,8 @@ function resolveCapabilityProviderConfig(params: {
cfg?: OpenClawConfig;
}) {
const pluginIds = resolveBundledCapabilityCompatPluginIds(params);
const allowlistCompat = withBundledPluginAllowlistCompat({
config: params.cfg,
pluginIds,
});
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
config: params.cfg,
pluginIds,
});
return withBundledPluginVitestCompat({