mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
1260 lines
37 KiB
TypeScript
1260 lines
37 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
|
|
import { loggingState } from "../logging/state.js";
|
|
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
|
|
|
|
type MockRegistryToolEntry = {
|
|
pluginId: string;
|
|
optional: boolean;
|
|
source: string;
|
|
names: string[];
|
|
declaredNames?: 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 buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
|
|
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
|
|
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
|
|
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
|
|
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
|
|
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
|
|
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
|
|
|
|
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"] },
|
|
slots: { memory: "none" },
|
|
},
|
|
},
|
|
workspaceDir: "/tmp",
|
|
};
|
|
}
|
|
|
|
function createResolveToolsParams(params?: {
|
|
context?: ReturnType<typeof createContext> & Record<string, unknown>;
|
|
toolAllowlist?: readonly string[];
|
|
existingToolNames?: Set<string>;
|
|
env?: NodeJS.ProcessEnv;
|
|
suppressNameConflicts?: boolean;
|
|
allowGatewaySubagentBinding?: boolean;
|
|
}) {
|
|
return {
|
|
context: (params?.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);
|
|
installToolManifestSnapshots({
|
|
config: createContext().config,
|
|
plugins: entries
|
|
.map((entry) => ({
|
|
id: entry.pluginId,
|
|
origin: "bundled",
|
|
enabledByDefault: true,
|
|
channels: [],
|
|
providers: [],
|
|
contracts: {
|
|
tools: entry.declaredNames ?? entry.names,
|
|
},
|
|
}))
|
|
.filter((plugin) => plugin.contracts.tools.length > 0),
|
|
});
|
|
return registry;
|
|
}
|
|
|
|
function setMultiToolRegistry() {
|
|
return setRegistry([
|
|
{
|
|
pluginId: "multi",
|
|
optional: false,
|
|
source: "/tmp/multi.js",
|
|
names: ["message", "other_tool"],
|
|
factory: () => [makeTool("message"), makeTool("other_tool")],
|
|
},
|
|
]);
|
|
}
|
|
|
|
function createOptionalDemoEntry(): MockRegistryToolEntry {
|
|
return {
|
|
pluginId: "optional-demo",
|
|
names: ["optional_tool"],
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
factory: () => makeTool("optional_tool"),
|
|
};
|
|
}
|
|
|
|
function createMalformedTool(name: string) {
|
|
return {
|
|
name,
|
|
description: `${name} tool`,
|
|
inputSchema: { type: "object", properties: {} },
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "bad" }] };
|
|
},
|
|
};
|
|
}
|
|
|
|
function installConsoleMethodSpy(method: "log" | "warn") {
|
|
const spy = vi.fn();
|
|
loggingState.rawConsole = {
|
|
log: method === "log" ? spy : vi.fn(),
|
|
info: vi.fn(),
|
|
warn: method === "warn" ? spy : vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
return spy;
|
|
}
|
|
|
|
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();
|
|
installToolManifestSnapshot({
|
|
config: autoEnabledConfig,
|
|
plugin: {
|
|
id: "optional-demo",
|
|
origin: "bundled",
|
|
enabledByDefault: true,
|
|
channels: [],
|
|
providers: [],
|
|
contracts: {
|
|
tools: ["optional_tool"],
|
|
},
|
|
},
|
|
});
|
|
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() {
|
|
installToolManifestSnapshot({
|
|
config: createContext().config,
|
|
plugin: {
|
|
id: "optional-demo",
|
|
origin: "bundled",
|
|
enabledByDefault: true,
|
|
channels: [],
|
|
providers: [],
|
|
contracts: {
|
|
tools: ["optional_tool"],
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
plugins: [{ id: "optional-demo", status: "loaded" }],
|
|
tools: [createOptionalDemoEntry()],
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function installToolManifestSnapshot(params: {
|
|
config: ReturnType<typeof createContext>["config"];
|
|
env?: NodeJS.ProcessEnv;
|
|
plugin: Record<string, unknown>;
|
|
}) {
|
|
installToolManifestSnapshots({
|
|
config: params.config,
|
|
env: params.env,
|
|
plugins: [params.plugin],
|
|
});
|
|
}
|
|
|
|
function installToolManifestSnapshots(params: {
|
|
config: ReturnType<typeof createContext>["config"];
|
|
env?: NodeJS.ProcessEnv;
|
|
plugins: Record<string, unknown>[];
|
|
}) {
|
|
const plugins = params.plugins;
|
|
setCurrentPluginMetadataSnapshot(
|
|
{
|
|
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
|
|
workspaceDir: "/tmp",
|
|
index: {
|
|
version: 1,
|
|
hostContractVersion: "test",
|
|
compatRegistryVersion: "test",
|
|
migrationVersion: 1,
|
|
policyHash: "test",
|
|
generatedAtMs: 0,
|
|
installRecords: {},
|
|
plugins: [],
|
|
diagnostics: [],
|
|
},
|
|
registryDiagnostics: [],
|
|
manifestRegistry: { plugins, diagnostics: [] },
|
|
plugins,
|
|
diagnostics: [],
|
|
byPluginId: new Map(plugins.map((plugin) => [String(plugin.id), plugin])),
|
|
normalizePluginId: (id: string) => id,
|
|
owners: {
|
|
channels: new Map(),
|
|
channelConfigs: new Map(),
|
|
providers: new Map(),
|
|
modelCatalogProviders: new Map(),
|
|
cliBackends: new Map(),
|
|
setupProviders: new Map(),
|
|
commandAliases: new Map(),
|
|
contracts: new Map(),
|
|
},
|
|
metrics: {
|
|
registrySnapshotMs: 0,
|
|
manifestRegistryMs: 0,
|
|
ownerMapsMs: 0,
|
|
totalMs: 0,
|
|
indexPluginCount: 0,
|
|
manifestPluginCount: plugins.length,
|
|
},
|
|
} as never,
|
|
{ config: params.config, env: params.env ?? process.env, workspaceDir: "/tmp" },
|
|
);
|
|
}
|
|
|
|
function createXaiToolManifest() {
|
|
return {
|
|
id: "xai",
|
|
origin: "bundled",
|
|
enabledByDefault: true,
|
|
channels: [],
|
|
providers: ["xai"],
|
|
providerAuthEnvVars: {
|
|
xai: ["XAI_API_KEY"],
|
|
},
|
|
contracts: {
|
|
tools: ["x_search"],
|
|
},
|
|
toolMetadata: {
|
|
x_search: {
|
|
authSignals: [{ provider: "xai" }],
|
|
configSignals: [
|
|
{
|
|
rootPath: "plugins.entries.xai.config",
|
|
overlayPath: "webSearch",
|
|
required: ["apiKey"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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", () => {
|
|
beforeAll(async () => {
|
|
({ buildPluginToolMetadataKey, resetPluginToolFactoryCache, resolvePluginTools } =
|
|
await import("./tools.js"));
|
|
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
|
|
await import("./runtime.js"));
|
|
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
|
|
await import("./current-plugin-metadata-snapshot.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
loadOpenClawPluginsMock.mockClear();
|
|
resolveRuntimePluginRegistryMock.mockReset();
|
|
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
|
|
loadOpenClawPluginsMock(params),
|
|
);
|
|
applyPluginAutoEnableMock.mockReset();
|
|
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
|
|
config,
|
|
changes: [],
|
|
}));
|
|
resetPluginRuntimeStateForTest?.();
|
|
clearCurrentPluginMetadataSnapshot?.();
|
|
resetPluginToolFactoryCache?.();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetPluginRuntimeStateForTest?.();
|
|
clearCurrentPluginMetadataSnapshot?.();
|
|
resetPluginToolFactoryCache?.();
|
|
setLoggerOverride(null);
|
|
loggingState.rawConsole = null;
|
|
resetLogger();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not load plugin-owned tools whose manifest metadata has no available signal", () => {
|
|
const config = createContext().config;
|
|
installToolManifestSnapshot({
|
|
config,
|
|
env: {},
|
|
plugin: createXaiToolManifest(),
|
|
});
|
|
const factory = vi.fn(() => makeTool("x_search"));
|
|
loadOpenClawPluginsMock.mockImplementation((params) =>
|
|
Array.isArray((params as { onlyPluginIds?: string[] }).onlyPluginIds) &&
|
|
(params as { onlyPluginIds?: string[] }).onlyPluginIds?.length === 0
|
|
? { tools: [], diagnostics: [] }
|
|
: {
|
|
tools: [
|
|
{
|
|
pluginId: "xai",
|
|
optional: false,
|
|
source: "/tmp/xai.js",
|
|
names: ["x_search"],
|
|
factory,
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
},
|
|
);
|
|
|
|
const tools = resolvePluginTools({
|
|
context: {
|
|
...createContext(),
|
|
config,
|
|
} as never,
|
|
env: {},
|
|
});
|
|
|
|
expect(tools).toEqual([]);
|
|
expect(factory).not.toHaveBeenCalled();
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onlyPluginIds: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
|
|
const config = createContext().config;
|
|
installToolManifestSnapshot({
|
|
config,
|
|
env: {},
|
|
plugin: createXaiToolManifest(),
|
|
});
|
|
const factory = vi.fn(() => makeTool("x_search"));
|
|
pinActivePluginChannelRegistry({
|
|
plugins: [{ id: "xai", status: "loaded" }],
|
|
tools: [
|
|
{
|
|
pluginId: "xai",
|
|
optional: false,
|
|
source: "/tmp/xai.js",
|
|
names: ["x_search"],
|
|
factory,
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
} as never);
|
|
loadOpenClawPluginsMock.mockReturnValue({ tools: [], diagnostics: [] });
|
|
|
|
const tools = resolvePluginTools({
|
|
context: {
|
|
...createContext(),
|
|
config,
|
|
} as never,
|
|
env: {},
|
|
allowGatewaySubagentBinding: true,
|
|
});
|
|
|
|
expect(tools).toEqual([]);
|
|
expect(factory).not.toHaveBeenCalled();
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onlyPluginIds: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => {
|
|
const config = createContext().config;
|
|
installToolManifestSnapshot({
|
|
config,
|
|
env: { XAI_API_KEY: "test-key" },
|
|
plugin: createXaiToolManifest(),
|
|
});
|
|
const factory = vi.fn(() => makeTool("x_search"));
|
|
loadOpenClawPluginsMock.mockReturnValue({
|
|
tools: [
|
|
{
|
|
pluginId: "xai",
|
|
optional: false,
|
|
source: "/tmp/xai.js",
|
|
names: ["x_search"],
|
|
factory,
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
});
|
|
|
|
const tools = resolvePluginTools({
|
|
context: {
|
|
...createContext(),
|
|
config,
|
|
} as never,
|
|
env: {
|
|
XAI_API_KEY: "test-key",
|
|
},
|
|
});
|
|
|
|
expectResolvedToolNames(tools, ["x_search"]);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onlyPluginIds: ["xai"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("loads plugin-owned tools when manifest config signals point at configured non-env SecretRefs", () => {
|
|
const base = createContext();
|
|
const config = {
|
|
...base.config,
|
|
plugins: {
|
|
...base.config.plugins,
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "vault",
|
|
id: "/xai/tool-key",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
vault: {
|
|
source: "file",
|
|
path: "/tmp/openclaw-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
installToolManifestSnapshot({
|
|
config,
|
|
env: {},
|
|
plugin: createXaiToolManifest(),
|
|
});
|
|
const factory = vi.fn(() => makeTool("x_search"));
|
|
loadOpenClawPluginsMock.mockReturnValue({
|
|
tools: [
|
|
{
|
|
pluginId: "xai",
|
|
optional: false,
|
|
source: "/tmp/xai.js",
|
|
names: ["x_search"],
|
|
factory,
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
});
|
|
|
|
const tools = resolvePluginTools({
|
|
context: {
|
|
...base,
|
|
config,
|
|
} as never,
|
|
env: {},
|
|
});
|
|
|
|
expectResolvedToolNames(tools, ["x_search"]);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onlyPluginIds: ["xai"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("skips optional tools without explicit allowlist", () => {
|
|
setOptionalDemoRegistry();
|
|
const tools = resolveOptionalDemoTools();
|
|
|
|
expect(tools).toHaveLength(0);
|
|
});
|
|
|
|
it("does not invoke named optional tool factories without a matching allowlist", () => {
|
|
const factory = vi.fn(() => makeTool("optional_tool"));
|
|
setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
names: ["optional_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
expect(resolveOptionalDemoTools()).toHaveLength(0);
|
|
expect(resolveOptionalDemoTools(["other_tool"])).toHaveLength(0);
|
|
expect(factory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("invokes unnamed optional tool factories when a tool allowlist may match the result", () => {
|
|
const factory = vi.fn(() => makeTool("optional_tool"));
|
|
setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
names: [],
|
|
declaredNames: ["optional_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
const tools = resolveOptionalDemoTools(["optional_tool"]);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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: ["optional_tool", "tavily"],
|
|
},
|
|
] 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",
|
|
names: ["optional_tool"],
|
|
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("skips malformed plugin tools while keeping valid sibling tools", () => {
|
|
const registry = setRegistry([
|
|
{
|
|
pluginId: "schema-bug",
|
|
optional: false,
|
|
source: "/tmp/schema-bug.js",
|
|
names: ["broken_tool", "valid_tool"],
|
|
factory: () => [createMalformedTool("broken_tool"), makeTool("valid_tool")],
|
|
},
|
|
]);
|
|
|
|
const tools = resolvePluginTools(createResolveToolsParams());
|
|
|
|
expectResolvedToolNames(tools, ["valid_tool"]);
|
|
expectSingleDiagnosticMessage(
|
|
registry.diagnostics,
|
|
"plugin tool is malformed (schema-bug): broken_tool missing parameters object",
|
|
);
|
|
});
|
|
|
|
it("warns with plugin factory timing details when a factory is slow", () => {
|
|
vi.useFakeTimers({ now: 0 });
|
|
const warnSpy = installConsoleMethodSpy("warn");
|
|
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
|
|
setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
names: ["optional_tool"],
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
factory: () => {
|
|
vi.advanceTimersByTime(1200);
|
|
return makeTool("optional_tool");
|
|
},
|
|
},
|
|
]);
|
|
|
|
const tools = resolveOptionalDemoTools(["optional_tool"]);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
const message = String(warnSpy.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("[trace:plugin-tools] factory timings");
|
|
expect(message).toContain("totalMs=1200");
|
|
expect(message).toContain("optional-demo:1200ms@1200ms");
|
|
expect(message).toContain("names=[optional_tool]");
|
|
expect(message).toContain("result=single");
|
|
expect(message).toContain("count=1");
|
|
});
|
|
|
|
it("emits trace factory timings below the warn threshold when trace logging is enabled", () => {
|
|
vi.useFakeTimers({ now: 0 });
|
|
const logSpy = installConsoleMethodSpy("log");
|
|
setLoggerOverride({ level: "silent", consoleLevel: "trace" });
|
|
setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
names: ["optional_tool"],
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
factory: () => {
|
|
vi.advanceTimersByTime(5);
|
|
return makeTool("optional_tool");
|
|
},
|
|
},
|
|
]);
|
|
|
|
const tools = resolveOptionalDemoTools(["optional_tool"]);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
const message = String(logSpy.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("[trace:plugin-tools] factory timings");
|
|
expect(message).toContain("totalMs=5");
|
|
expect(message).toContain("optional-demo:5ms@5ms");
|
|
});
|
|
|
|
it("does not log plugin factory timings for fast factories without trace logging", () => {
|
|
vi.useFakeTimers({ now: 0 });
|
|
const warnSpy = installConsoleMethodSpy("warn");
|
|
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
|
|
setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
names: ["optional_tool"],
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
factory: () => {
|
|
vi.advanceTimersByTime(5);
|
|
return makeTool("optional_tool");
|
|
},
|
|
},
|
|
]);
|
|
|
|
const tools = resolveOptionalDemoTools(["optional_tool"]);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("caches plugin tool factory results for equivalent request context", () => {
|
|
const factory = vi.fn(() => makeTool("cached_tool"));
|
|
setRegistry([
|
|
{
|
|
pluginId: "cache-test",
|
|
optional: false,
|
|
source: "/tmp/cache-test.js",
|
|
names: ["cached_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
const first = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
|
|
const second = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
|
|
|
|
expectResolvedToolNames(first, ["cached_tool"]);
|
|
expectResolvedToolNames(second, ["cached_tool"]);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
expect(second[0]).toBe(first[0]);
|
|
});
|
|
|
|
it("does not reuse plugin tool factory results across sandbox context changes", () => {
|
|
const factory = vi.fn((rawCtx: unknown) => {
|
|
const ctx = rawCtx as { sandboxed?: boolean };
|
|
return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool");
|
|
});
|
|
setRegistry([
|
|
{
|
|
pluginId: "sandbox-sensitive",
|
|
optional: false,
|
|
source: "/tmp/sandbox-sensitive.js",
|
|
names: ["sandbox_sensitive_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
const hostTools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...createContext(), sandboxed: false },
|
|
}),
|
|
);
|
|
const sandboxedTools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...createContext(), sandboxed: true },
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(hostTools, ["sandbox_sensitive_tool"]);
|
|
expect(sandboxedTools).toEqual([]);
|
|
expect(factory).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not reuse plugin tool factory results across runtime config changes", () => {
|
|
const firstRuntimeConfig = {
|
|
...createContext().config,
|
|
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_tool"] },
|
|
};
|
|
const secondRuntimeConfig = {
|
|
...createContext().config,
|
|
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_next_tool"] },
|
|
};
|
|
const factory = vi.fn((rawCtx: unknown) => {
|
|
const ctx = rawCtx as { runtimeConfig?: { plugins?: { allow?: string[] } } };
|
|
return makeTool(ctx.runtimeConfig?.plugins?.allow?.[0] ?? "runtime_missing_tool");
|
|
});
|
|
setRegistry([
|
|
{
|
|
pluginId: "runtime-sensitive",
|
|
optional: false,
|
|
source: "/tmp/runtime-sensitive.js",
|
|
names: ["runtime_sensitive_tool", "runtime_sensitive_next_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
const first = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...createContext(), runtimeConfig: firstRuntimeConfig as never },
|
|
}),
|
|
);
|
|
const second = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...createContext(), runtimeConfig: secondRuntimeConfig as never },
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(first, ["runtime_sensitive_tool"]);
|
|
expectResolvedToolNames(second, ["runtime_sensitive_next_tool"]);
|
|
expect(factory).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("reuses plugin tool factory results when only runtime config getter identity changes", () => {
|
|
const runtimeConfig = {
|
|
...createContext().config,
|
|
plugins: { ...createContext().config.plugins, allow: ["getter_sensitive_tool"] },
|
|
};
|
|
const factory = vi.fn((rawCtx: unknown) => {
|
|
const ctx = rawCtx as { getRuntimeConfig?: () => { plugins?: { allow?: string[] } } };
|
|
return makeTool(ctx.getRuntimeConfig?.()?.plugins?.allow?.[0] ?? "getter_missing_tool");
|
|
});
|
|
setRegistry([
|
|
{
|
|
pluginId: "getter-sensitive",
|
|
optional: false,
|
|
source: "/tmp/getter-sensitive.js",
|
|
names: ["getter_sensitive_tool"],
|
|
factory,
|
|
},
|
|
]);
|
|
|
|
const context = createContext();
|
|
const first = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
|
|
}),
|
|
);
|
|
const second = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(first, ["getter_sensitive_tool"]);
|
|
expectResolvedToolNames(second, ["getter_sensitive_tool"]);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reads live runtime config once per plugin tool resolution for cache keys", () => {
|
|
const runtimeConfig = createContext().config;
|
|
const getRuntimeConfig = vi.fn(() => runtimeConfig);
|
|
setRegistry([
|
|
{
|
|
pluginId: "getter-a",
|
|
optional: false,
|
|
source: "/tmp/getter-a.js",
|
|
names: ["getter_a_tool"],
|
|
factory: () => makeTool("getter_a_tool"),
|
|
},
|
|
{
|
|
pluginId: "getter-b",
|
|
optional: false,
|
|
source: "/tmp/getter-b.js",
|
|
names: ["getter_b_tool"],
|
|
factory: () => makeTool("getter_b_tool"),
|
|
},
|
|
]);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
context: { ...createContext(), getRuntimeConfig: getRuntimeConfig as never },
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(tools, ["getter_a_tool", "getter_b_tool"]);
|
|
expect(getRuntimeConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips factory-returned tools outside the manifest tool contract", () => {
|
|
const registry = setRegistry([
|
|
{
|
|
pluginId: "dynamic-owner",
|
|
optional: false,
|
|
source: "/tmp/dynamic-owner.js",
|
|
names: ["declared_tool"],
|
|
declaredNames: ["declared_tool"],
|
|
factory: () => [makeTool("declared_tool"), makeTool("rogue_tool")],
|
|
},
|
|
]);
|
|
|
|
const tools = resolvePluginTools(createResolveToolsParams());
|
|
|
|
expectResolvedToolNames(tools, ["declared_tool"]);
|
|
expectSingleDiagnosticMessage(registry.diagnostics, "plugin tool is undeclared");
|
|
});
|
|
|
|
it("skips allowlisted optional malformed plugin tools", () => {
|
|
const registry = setRegistry([
|
|
{
|
|
pluginId: "optional-demo",
|
|
optional: true,
|
|
source: "/tmp/optional-demo.js",
|
|
names: ["optional_tool"],
|
|
factory: () => createMalformedTool("optional_tool"),
|
|
},
|
|
]);
|
|
|
|
const tools = resolveOptionalDemoTools(["optional_tool"]);
|
|
|
|
expect(tools).toHaveLength(0);
|
|
expectSingleDiagnosticMessage(
|
|
registry.diagnostics,
|
|
"plugin tool is malformed (optional-demo): optional_tool missing parameters object",
|
|
);
|
|
});
|
|
|
|
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 gateway-bindable registry when it covers the tool runtime scope", () => {
|
|
const activeRegistry = createOptionalDemoActiveRegistry();
|
|
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
|
|
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
toolAllowlist: ["optional_tool"],
|
|
allowGatewaySubagentBinding: true,
|
|
}),
|
|
);
|
|
|
|
expectResolvedToolNames(tools, ["optional_tool"]);
|
|
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
|
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not widen active registry reuse to non-matching plugin tool owners", () => {
|
|
installToolManifestSnapshot({
|
|
config: createContext().config,
|
|
plugin: {
|
|
id: "optional-demo",
|
|
origin: "bundled",
|
|
enabledByDefault: true,
|
|
channels: [],
|
|
providers: [],
|
|
contracts: {
|
|
tools: ["optional_tool"],
|
|
},
|
|
},
|
|
});
|
|
const heavyFactory = vi.fn(() => makeTool("heavy_tool"));
|
|
const activeRegistry = {
|
|
plugins: [
|
|
{ id: "optional-demo", status: "loaded" },
|
|
{ id: "heavy-startup", status: "loaded" },
|
|
],
|
|
tools: [
|
|
createOptionalDemoEntry(),
|
|
{
|
|
pluginId: "heavy-startup",
|
|
optional: false,
|
|
source: "/tmp/heavy-startup.js",
|
|
names: ["heavy_tool"],
|
|
factory: heavyFactory,
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
};
|
|
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(heavyFactory).not.toHaveBeenCalled();
|
|
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
|
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("adds enabled non-startup tool plugins to the active tool runtime scope", () => {
|
|
const activeRegistry = createOptionalDemoActiveRegistry();
|
|
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
|
|
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
|
|
|
resolvePluginTools({
|
|
context: {
|
|
...createContext(),
|
|
config: {
|
|
plugins: {
|
|
enabled: true,
|
|
allow: ["tavily"],
|
|
entries: {
|
|
tavily: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
} as never,
|
|
toolAllowlist: ["optional_tool", "tavily"],
|
|
allowGatewaySubagentBinding: true,
|
|
});
|
|
|
|
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
onlyPluginIds: ["tavily"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => {
|
|
const gatewayRegistry = createOptionalDemoActiveRegistry();
|
|
pinActivePluginChannelRegistry(gatewayRegistry as never);
|
|
setActivePluginRegistry(
|
|
{
|
|
tools: [],
|
|
diagnostics: [],
|
|
} as never,
|
|
"provider-runtime",
|
|
"default",
|
|
);
|
|
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("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => {
|
|
const gatewayRegistry = createOptionalDemoActiveRegistry();
|
|
pinActivePluginChannelRegistry(gatewayRegistry as never);
|
|
setActivePluginRegistry(
|
|
{
|
|
tools: [],
|
|
diagnostics: [],
|
|
} as never,
|
|
"provider-runtime",
|
|
"default",
|
|
);
|
|
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
|
|
|
|
const tools = resolvePluginTools(
|
|
createResolveToolsParams({
|
|
toolAllowlist: ["optional_tool"],
|
|
}),
|
|
);
|
|
|
|
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,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildPluginToolMetadataKey", () => {
|
|
beforeAll(async () => {
|
|
({ buildPluginToolMetadataKey } = await import("./tools.js"));
|
|
});
|
|
|
|
it("does not collide when ids or names contain separator-like characters", () => {
|
|
expect(buildPluginToolMetadataKey("plugin", "a\uE000b")).not.toBe(
|
|
buildPluginToolMetadataKey("plugin\uE000a", "b"),
|
|
);
|
|
expect(buildPluginToolMetadataKey("plugin", "a\u0000b")).not.toBe(
|
|
buildPluginToolMetadataKey("plugin\u0000a", "b"),
|
|
);
|
|
});
|
|
});
|