diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 614c09801..7d68c06d7 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1,183 +1,157 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolvePluginTools } from "./tools.js"; -type TempPlugin = { dir: string; file: string; id: string }; +type MockRegistryToolEntry = { + pluginId: string; + optional: boolean; + source: string; + factory: (ctx: unknown) => unknown; +}; -const fixtureRoot = path.join(os.tmpdir(), `openclaw-plugin-tools-${randomUUID()}`); -const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; +const loadOpenClawPluginsMock = vi.fn(); -function makeFixtureDir(id: string) { - const dir = path.join(fixtureRoot, id); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params), +})); -function writePlugin(params: { id: string; body: string }): TempPlugin { - const dir = makeFixtureDir(params.id); - const file = path.join(dir, `${params.id}.js`); - fs.writeFileSync(file, params.body, "utf-8"); - fs.writeFileSync( - path.join(dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: params.id, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - return { dir, file, id: params.id }; -} - -const pluginBody = ` -export default { register(api) { - api.registerTool( - { - name: "optional_tool", - description: "optional tool", - parameters: { type: "object", properties: {} }, - async execute() { - return { content: [{ type: "text", text: "ok" }] }; - }, - }, - { optional: true }, - ); -} } -`; - -const optionalDemoPlugin = writePlugin({ id: "optional-demo", body: pluginBody }); -const coreNameCollisionPlugin = writePlugin({ id: "message", body: pluginBody }); -const multiToolPlugin = writePlugin({ - id: "multi", - body: ` -export default { register(api) { - api.registerTool({ - name: "message", - description: "conflict", - parameters: { type: "object", properties: {} }, - async execute() { - return { content: [{ type: "text", text: "nope" }] }; - }, - }); - api.registerTool({ - name: "other_tool", - description: "ok", +function makeTool(name: string) { + return { + name, + description: `${name} tool`, parameters: { type: "object", properties: {} }, async execute() { return { content: [{ type: "text", text: "ok" }] }; }, - }); -} } -`, -}); + }; +} -afterAll(() => { - try { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } catch { - // ignore cleanup failures - } -}); +function createContext() { + return { + config: { + plugins: { + enabled: true, + allow: ["optional-demo", "message", "multi"], + load: { paths: ["/tmp/plugin.js"] }, + }, + }, + workspaceDir: "/tmp", + }; +} + +function setRegistry(entries: MockRegistryToolEntry[]) { + const registry = { + tools: entries, + diagnostics: [] as Array<{ + level: string; + pluginId: string; + source: string; + message: string; + }>, + }; + loadOpenClawPluginsMock.mockReturnValue(registry); + return registry; +} describe("resolvePluginTools optional tools", () => { + beforeEach(() => { + loadOpenClawPluginsMock.mockReset(); + }); + it("skips optional tools without explicit allowlist", () => { - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [optionalDemoPlugin.file] }, - allow: [optionalDemoPlugin.id], - }, - }, - workspaceDir: optionalDemoPlugin.dir, + setRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + factory: () => makeTool("optional_tool"), }, + ]); + + const tools = resolvePluginTools({ + context: createContext() as never, }); + expect(tools).toHaveLength(0); }); - it("allows optional tools by name", () => { - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [optionalDemoPlugin.file] }, - allow: [optionalDemoPlugin.id], - }, - }, - workspaceDir: optionalDemoPlugin.dir, + it("allows optional tools by tool name", () => { + setRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + factory: () => makeTool("optional_tool"), }, + ]); + + const tools = resolvePluginTools({ + context: createContext() as never, toolAllowlist: ["optional_tool"], }); - expect(tools.map((tool) => tool.name)).toContain("optional_tool"); + + expect(tools.map((tool) => tool.name)).toEqual(["optional_tool"]); }); - it("allows optional tools via plugin groups", () => { - const toolsAll = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [optionalDemoPlugin.file] }, - allow: [optionalDemoPlugin.id], - }, - }, - workspaceDir: optionalDemoPlugin.dir, + it("allows optional tools via plugin-scoped allowlist entries", () => { + setRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + factory: () => makeTool("optional_tool"), }, - toolAllowlist: ["group:plugins"], - }); - expect(toolsAll.map((tool) => tool.name)).toContain("optional_tool"); + ]); - const toolsPlugin = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [optionalDemoPlugin.file] }, - allow: [optionalDemoPlugin.id], - }, - }, - workspaceDir: optionalDemoPlugin.dir, - }, + const toolsByPlugin = resolvePluginTools({ + context: createContext() as never, toolAllowlist: ["optional-demo"], }); - expect(toolsPlugin.map((tool) => tool.name)).toContain("optional_tool"); + const toolsByGroup = resolvePluginTools({ + context: createContext() as never, + toolAllowlist: ["group:plugins"], + }); + + expect(toolsByPlugin.map((tool) => tool.name)).toEqual(["optional_tool"]); + expect(toolsByGroup.map((tool) => tool.name)).toEqual(["optional_tool"]); }); it("rejects plugin id collisions with core tool names", () => { - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [coreNameCollisionPlugin.file] }, - allow: [coreNameCollisionPlugin.id], - }, - }, - workspaceDir: coreNameCollisionPlugin.dir, + const registry = setRegistry([ + { + pluginId: "message", + optional: false, + source: "/tmp/message.js", + factory: () => makeTool("optional_tool"), }, + ]); + + const tools = resolvePluginTools({ + context: createContext() as never, existingToolNames: new Set(["message"]), - toolAllowlist: ["message"], }); + expect(tools).toHaveLength(0); + expect(registry.diagnostics).toHaveLength(1); + expect(registry.diagnostics[0]?.message).toContain("plugin id conflicts with core tool name"); }); it("skips conflicting tool names but keeps other tools", () => { - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [multiToolPlugin.file] }, - allow: [multiToolPlugin.id], - }, - }, - workspaceDir: multiToolPlugin.dir, + const registry = setRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + factory: () => [makeTool("message"), makeTool("other_tool")], }, + ]); + + const tools = resolvePluginTools({ + context: createContext() as never, existingToolNames: new Set(["message"]), }); expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]); + expect(registry.diagnostics).toHaveLength(1); + expect(registry.diagnostics[0]?.message).toContain("plugin tool name conflict"); }); });