mirror of https://github.com/openclaw/openclaw.git
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type MockRegistryToolEntry = {
|
|
pluginId: string;
|
|
optional: boolean;
|
|
source: string;
|
|
factory: (ctx: unknown) => unknown;
|
|
};
|
|
|
|
const loadOpenClawPluginsMock = vi.fn();
|
|
const resolveRuntimePluginRegistryMock = vi.fn();
|
|
const applyPluginAutoEnableMock = vi.fn();
|
|
|
|
vi.mock("./loader.js", () => ({
|
|
resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params),
|
|
}));
|
|
|
|
vi.mock("../config/plugin-auto-enable.js", () => ({
|
|
applyPluginAutoEnable: (params: unknown) => applyPluginAutoEnableMock(params),
|
|
}));
|
|
|
|
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
|
|
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
|
|
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
|
|
|
|
function makeTool(name: string) {
|
|
return {
|
|
name,
|
|
description: `${name} tool`,
|
|
parameters: { type: "object", properties: {} },
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "ok" }] };
|
|
},
|
|
};
|
|
}
|
|
|
|
function createContext() {
|
|
return {
|
|
config: {
|
|
plugins: {
|
|
enabled: true,
|
|
allow: ["optional-demo", "message", "multi"],
|
|
load: { paths: ["/tmp/plugin.js"] },
|
|
},
|
|
},
|
|
workspaceDir: "/tmp",
|
|
};
|
|
}
|
|
|
|
function createResolveToolsParams(params?: {
|
|
toolAllowlist?: readonly string[];
|
|
existingToolNames?: Set<string>;
|
|
env?: NodeJS.ProcessEnv;
|
|
suppressNameConflicts?: boolean;
|
|
allowGatewaySubagentBinding?: boolean;
|
|
}) {
|
|
return {
|
|
context: createContext() as never,
|
|
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
|
|
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
|
|
...(params?.env ? { env: params.env } : {}),
|
|
...(params?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
|
|
...(params?.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
|
|
};
|
|
}
|
|
|
|
function setRegistry(entries: MockRegistryToolEntry[]) {
|
|
const registry = {
|
|
tools: entries,
|
|
diagnostics: [] as Array<{
|
|
level: string;
|
|
pluginId: string;
|
|
source: string;
|
|
message: string;
|
|
}>,
|
|
};
|
|
loadOpenClawPluginsMock.mockReturnValue(registry);
|
|
return registry;
|
|
}
|
|
|
|
function setMultiToolRegistry() {
|
|
return setRegistry([
|
|
{
|
|
pluginId: "multi",
|
|
optional: false,
|
|
source: "/tmp/multi.js",
|
|
factory: () => [makeTool("message"), makeTool("other_tool")],
|
|
},
|
|
]);
|
|
}
|
|
|
|
function createOptionalDemoEntry(): MockRegistryToolEntry {
|
|
return {
|
|
pluginId: "optional-demo",
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
factory: () => makeTool("optional_tool"),
|
|
};
|
|
}
|
|
|
|
function resolveWithConflictingCoreName(options?: { suppressNameConflicts?: boolean }) {
|
|
return resolvePluginTools(
|
|
createResolveToolsParams({
|
|
existingToolNames: new Set(["message"]),
|
|
...(options?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setOptionalDemoRegistry() {
|
|
setRegistry([createOptionalDemoEntry()]);
|
|
}
|
|
|
|
function resolveOptionalDemoTools(toolAllowlist?: readonly string[]) {
|
|
return resolvePluginTools(createResolveToolsParams({ toolAllowlist }));
|
|
}
|
|
|
|
function createAutoEnabledOptionalContext() {
|
|
const rawContext = createContext();
|
|
const autoEnabledConfig = {
|
|
...rawContext.config,
|
|
plugins: {
|
|
...rawContext.config.plugins,
|
|
entries: {
|
|
"optional-demo": { enabled: true },
|
|
},
|
|
},
|
|
};
|
|
return { rawContext, autoEnabledConfig };
|
|
}
|
|
|
|
function expectAutoEnabledOptionalLoad(autoEnabledConfig: unknown) {
|
|
expectLoaderCall({ config: autoEnabledConfig });
|
|
}
|
|
|
|
function resolveAutoEnabledOptionalDemoTools() {
|
|
setOptionalDemoRegistry();
|
|
const { rawContext, autoEnabledConfig } = createAutoEnabledOptionalContext();
|
|
applyPluginAutoEnableMock.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
|
|
|
const tools = resolvePluginTools({
|
|
context: {
|
|
...rawContext,
|
|
config: rawContext.config as never,
|
|
} as never,
|
|
toolAllowlist: ["optional_tool"],
|
|
});
|
|
|
|
return { rawContext, autoEnabledConfig, tools };
|
|
}
|
|
|
|
function createOptionalDemoActiveRegistry() {
|
|
return {
|
|
tools: [createOptionalDemoEntry()],
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function expectResolvedToolNames(
|
|
tools: ReturnType<typeof resolvePluginTools>,
|
|
expectedToolNames: readonly string[],
|
|
) {
|
|
expect(tools.map((tool) => tool.name)).toEqual(expectedToolNames);
|
|
}
|
|
|
|
function expectLoaderCall(overrides: Record<string, unknown>) {
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(expect.objectContaining(overrides));
|
|
}
|
|
|
|
function expectSingleDiagnosticMessage(
|
|
diagnostics: Array<{ message: string }>,
|
|
messageFragment: string,
|
|
) {
|
|
expect(diagnostics).toHaveLength(1);
|
|
expect(diagnostics[0]?.message).toContain(messageFragment);
|
|
}
|
|
|
|
function expectConflictingCoreNameResolution(params: {
|
|
suppressNameConflicts?: boolean;
|
|
expectedDiagnosticFragment?: string;
|
|
}) {
|
|
const registry = setMultiToolRegistry();
|
|
const tools = resolveWithConflictingCoreName({
|
|
suppressNameConflicts: params.suppressNameConflicts,
|
|
});
|
|
|
|
expectResolvedToolNames(tools, ["other_tool"]);
|
|
if (params.expectedDiagnosticFragment) {
|
|
expectSingleDiagnosticMessage(registry.diagnostics, params.expectedDiagnosticFragment);
|
|
return;
|
|
}
|
|
expect(registry.diagnostics).toHaveLength(0);
|
|
}
|
|
|
|
describe("resolvePluginTools optional tools", () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
loadOpenClawPluginsMock.mockClear();
|
|
resolveRuntimePluginRegistryMock.mockReset();
|
|
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
|
|
loadOpenClawPluginsMock(params),
|
|
);
|
|
applyPluginAutoEnableMock.mockReset();
|
|
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
|
|
config,
|
|
changes: [],
|
|
}));
|
|
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js"));
|
|
resetPluginRuntimeStateForTest();
|
|
({ resolvePluginTools } = await import("./tools.js"));
|
|
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js"));
|
|
resetPluginRuntimeStateForTest();
|
|
});
|
|
|
|
it("skips optional tools without explicit allowlist", () => {
|
|
setOptionalDemoRegistry();
|
|
const tools = resolveOptionalDemoTools();
|
|
|
|
expect(tools).toHaveLength(0);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "allows optional tools by tool name",
|
|
toolAllowlist: ["optional_tool"],
|
|
},
|
|
{
|
|
name: "allows optional tools via plugin id",
|
|
toolAllowlist: ["optional-demo"],
|
|
},
|
|
{
|
|
name: "allows optional tools via plugin-scoped allowlist entries",
|
|
toolAllowlist: ["group:plugins"],
|
|
},
|
|
] as const)("$name", ({ toolAllowlist }) => {
|
|
setOptionalDemoRegistry();
|
|
const tools = resolveOptionalDemoTools(toolAllowlist);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
});
|
|
|
|
it("rejects plugin id collisions with core tool names", () => {
|
|
const registry = setRegistry([
|
|
{
|
|
pluginId: "message",
|
|
optional: false,
|
|
source: "/tmp/message.js",
|
|
factory: () => makeTool("optional_tool"),
|
|
},
|
|
]);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
existingToolNames: new Set(["message"]),
|
|
}),
|
|
);
|
|
|
|
expect(tools).toHaveLength(0);
|
|
expectSingleDiagnosticMessage(registry.diagnostics, "plugin id conflicts with core tool name");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "skips conflicting tool names but keeps other tools",
|
|
expectedDiagnosticFragment: "plugin tool name conflict",
|
|
},
|
|
{
|
|
name: "suppresses conflict diagnostics when requested",
|
|
suppressNameConflicts: true,
|
|
},
|
|
] as const)("$name", ({ suppressNameConflicts, expectedDiagnosticFragment }) => {
|
|
expectConflictingCoreNameResolution({
|
|
suppressNameConflicts,
|
|
expectedDiagnosticFragment,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "forwards an explicit env to plugin loading",
|
|
params: {
|
|
env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv,
|
|
toolAllowlist: ["optional_tool"],
|
|
},
|
|
expectedLoaderCall: {
|
|
env: { OPENCLAW_HOME: "/srv/openclaw-home" },
|
|
},
|
|
},
|
|
{
|
|
name: "forwards gateway subagent binding to plugin runtime options",
|
|
params: {
|
|
allowGatewaySubagentBinding: true,
|
|
toolAllowlist: ["optional_tool"],
|
|
},
|
|
expectedLoaderCall: {
|
|
runtimeOptions: {
|
|
allowGatewaySubagentBinding: true,
|
|
},
|
|
},
|
|
},
|
|
])("$name", ({ params, expectedLoaderCall }) => {
|
|
setOptionalDemoRegistry();
|
|
|
|
resolvePluginTools(createResolveToolsParams(params));
|
|
|
|
expectLoaderCall(expectedLoaderCall);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "loads plugin tools from the auto-enabled config snapshot",
|
|
expectedToolNames: undefined,
|
|
},
|
|
{
|
|
name: "does not reuse a cached active registry when auto-enable changes the config snapshot",
|
|
expectedToolNames: ["optional_tool"],
|
|
},
|
|
] as const)("$name", ({ expectedToolNames }) => {
|
|
const { rawContext, autoEnabledConfig, tools } = resolveAutoEnabledOptionalDemoTools();
|
|
|
|
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
plugins: expect.objectContaining({
|
|
allow: rawContext.config.plugins?.allow,
|
|
load: rawContext.config.plugins?.load,
|
|
}),
|
|
}),
|
|
env: process.env,
|
|
}),
|
|
);
|
|
if (expectedToolNames) {
|
|
expectResolvedToolNames(tools, expectedToolNames);
|
|
}
|
|
expectAutoEnabledOptionalLoad(autoEnabledConfig);
|
|
});
|
|
|
|
it("reuses a compatible active registry instead of loading again", () => {
|
|
const activeRegistry = createOptionalDemoActiveRegistry();
|
|
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
toolAllowlist: ["optional_tool"],
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reuses the active registry for gateway-bindable tool loads before reloading", () => {
|
|
const activeRegistry = createOptionalDemoActiveRegistry();
|
|
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
|
|
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
toolAllowlist: ["optional_tool"],
|
|
allowGatewaySubagentBinding: true,
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
|
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads plugin tools when gateway-bindable tool loads have no active registry", () => {
|
|
setOptionalDemoRegistry();
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
toolAllowlist: ["optional_tool"],
|
|
allowGatewaySubagentBinding: true,
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expectLoaderCall({
|
|
runtimeOptions: {
|
|
allowGatewaySubagentBinding: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reloads when gateway binding would otherwise reuse a default-mode active registry", () => {
|
|
setActivePluginRegistry(
|
|
{
|
|
tools: [],
|
|
diagnostics: [],
|
|
} as never,
|
|
"default-registry",
|
|
"default",
|
|
);
|
|
setOptionalDemoRegistry();
|
|
|
|
resolvePluginTools({
|
|
context: createContext() as never,
|
|
allowGatewaySubagentBinding: true,
|
|
toolAllowlist: ["optional_tool"],
|
|
});
|
|
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runtimeOptions: {
|
|
allowGatewaySubagentBinding: true,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|