diff --git a/src/extension-host/tool-runtime.test.ts b/src/extension-host/tool-runtime.test.ts new file mode 100644 index 00000000000..b74d15d7be7 --- /dev/null +++ b/src/extension-host/tool-runtime.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { getExtensionHostPluginToolMeta, resolveExtensionHostPluginTools } from "./tool-runtime.js"; + +function makeTool(name: string): AnyAgentTool { + return { + name, + description: `${name} tool`, + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +function createContext() { + return { + config: { + plugins: { + enabled: true, + }, + }, + workspaceDir: "/tmp", + }; +} + +describe("resolveExtensionHostPluginTools", () => { + it("allows optional tools through tool, plugin, and plugin-group allowlists", () => { + const registry = createEmptyPluginRegistry(); + registry.tools.push({ + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + factory: () => makeTool("optional_tool"), + names: ["optional_tool"], + }); + + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + }), + ).toEqual([]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["optional_tool"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["optional-demo"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["group:plugins"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + }); + + it("records conflict diagnostics and preserves tool metadata", () => { + const registry = createEmptyPluginRegistry(); + const extraTool = makeTool("other_tool"); + registry.tools.push({ + pluginId: "message", + optional: false, + source: "/tmp/message.js", + factory: () => makeTool("optional_tool"), + names: ["optional_tool"], + }); + registry.tools.push({ + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + factory: () => [makeTool("message"), extraTool], + names: ["message", "other_tool"], + }); + + const tools = resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + existingToolNames: new Set(["message"]), + }); + + expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]); + expect(registry.diagnostics).toHaveLength(2); + expect(registry.diagnostics[0]?.message).toContain("plugin id conflicts with core tool name"); + expect(registry.diagnostics[1]?.message).toContain("plugin tool name conflict"); + expect(getExtensionHostPluginToolMeta(extraTool)).toEqual({ + pluginId: "multi", + optional: false, + }); + }); + + it("skips tool factories that throw", () => { + const registry = createEmptyPluginRegistry(); + const factory = vi.fn(() => { + throw new Error("boom"); + }); + registry.tools.push({ + pluginId: "broken", + optional: false, + source: "/tmp/broken.js", + factory, + names: ["broken_tool"], + }); + + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + }), + ).toEqual([]); + expect(factory).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/extension-host/tool-runtime.ts b/src/extension-host/tool-runtime.ts new file mode 100644 index 00000000000..66bfb871597 --- /dev/null +++ b/src/extension-host/tool-runtime.ts @@ -0,0 +1,126 @@ +import { normalizeToolName } from "../agents/tool-policy.js"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import type { OpenClawPluginToolContext } from "../plugins/types.js"; + +const log = createSubsystemLogger("plugins"); + +export type ExtensionHostPluginToolMeta = { + pluginId: string; + optional: boolean; +}; + +const extensionHostPluginToolMeta = new WeakMap(); + +export function getExtensionHostPluginToolMeta( + tool: AnyAgentTool, +): ExtensionHostPluginToolMeta | undefined { + return extensionHostPluginToolMeta.get(tool); +} + +function normalizeAllowlist(list?: string[]) { + return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); +} + +function isOptionalToolAllowed(params: { + toolName: string; + pluginId: string; + allowlist: Set; +}): boolean { + if (params.allowlist.size === 0) { + return false; + } + const toolName = normalizeToolName(params.toolName); + if (params.allowlist.has(toolName)) { + return true; + } + const pluginKey = normalizeToolName(params.pluginId); + if (params.allowlist.has(pluginKey)) { + return true; + } + return params.allowlist.has("group:plugins"); +} + +export function resolveExtensionHostPluginTools(params: { + registry: Pick; + context: OpenClawPluginToolContext; + existingToolNames?: Set; + toolAllowlist?: string[]; + suppressNameConflicts?: boolean; +}): AnyAgentTool[] { + const tools: AnyAgentTool[] = []; + const existing = params.existingToolNames ?? new Set(); + const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); + const allowlist = normalizeAllowlist(params.toolAllowlist); + const blockedPlugins = new Set(); + + for (const entry of params.registry.tools) { + if (blockedPlugins.has(entry.pluginId)) { + continue; + } + const pluginIdKey = normalizeToolName(entry.pluginId); + if (existingNormalized.has(pluginIdKey)) { + const message = `plugin id conflicts with core tool name (${entry.pluginId})`; + if (!params.suppressNameConflicts) { + log.error(message); + params.registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + } + blockedPlugins.add(entry.pluginId); + continue; + } + let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null; + try { + resolved = entry.factory(params.context); + } catch (err) { + log.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`); + continue; + } + if (!resolved) { + continue; + } + const listRaw = Array.isArray(resolved) ? resolved : [resolved]; + const list = entry.optional + ? listRaw.filter((tool) => + isOptionalToolAllowed({ + toolName: tool.name, + pluginId: entry.pluginId, + allowlist, + }), + ) + : listRaw; + if (list.length === 0) { + continue; + } + const nameSet = new Set(); + for (const tool of list) { + if (nameSet.has(tool.name) || existing.has(tool.name)) { + const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; + if (!params.suppressNameConflicts) { + log.error(message); + params.registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + } + continue; + } + nameSet.add(tool.name); + existing.add(tool.name); + extensionHostPluginToolMeta.set(tool, { + pluginId: entry.pluginId, + optional: entry.optional, + }); + tools.push(tool); + } + } + + return tools; +} diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ebf96ec6a4c..89841058017 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1,5 +1,9 @@ -import { normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import { + getExtensionHostPluginToolMeta, + resolveExtensionHostPluginTools, + type ExtensionHostPluginToolMeta, +} from "../extension-host/tool-runtime.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; @@ -8,38 +12,10 @@ import type { OpenClawPluginToolContext } from "./types.js"; const log = createSubsystemLogger("plugins"); -type PluginToolMeta = { - pluginId: string; - optional: boolean; -}; - -const pluginToolMeta = new WeakMap(); +type PluginToolMeta = ExtensionHostPluginToolMeta; export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { - return pluginToolMeta.get(tool); -} - -function normalizeAllowlist(list?: string[]) { - return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); -} - -function isOptionalToolAllowed(params: { - toolName: string; - pluginId: string; - allowlist: Set; -}): boolean { - if (params.allowlist.size === 0) { - return false; - } - const toolName = normalizeToolName(params.toolName); - if (params.allowlist.has(toolName)) { - return true; - } - const pluginKey = normalizeToolName(params.pluginId); - if (params.allowlist.has(pluginKey)) { - return true; - } - return params.allowlist.has("group:plugins"); + return getExtensionHostPluginToolMeta(tool); } export function resolvePluginTools(params: { @@ -65,78 +41,11 @@ export function resolvePluginTools(params: { logger: createPluginLoaderLogger(log), }); - const tools: AnyAgentTool[] = []; - const existing = params.existingToolNames ?? new Set(); - const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); - const allowlist = normalizeAllowlist(params.toolAllowlist); - const blockedPlugins = new Set(); - - for (const entry of registry.tools) { - if (blockedPlugins.has(entry.pluginId)) { - continue; - } - const pluginIdKey = normalizeToolName(entry.pluginId); - if (existingNormalized.has(pluginIdKey)) { - const message = `plugin id conflicts with core tool name (${entry.pluginId})`; - if (!params.suppressNameConflicts) { - log.error(message); - registry.diagnostics.push({ - level: "error", - pluginId: entry.pluginId, - source: entry.source, - message, - }); - } - blockedPlugins.add(entry.pluginId); - continue; - } - let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null; - try { - resolved = entry.factory(params.context); - } catch (err) { - log.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`); - continue; - } - if (!resolved) { - continue; - } - const listRaw = Array.isArray(resolved) ? resolved : [resolved]; - const list = entry.optional - ? listRaw.filter((tool) => - isOptionalToolAllowed({ - toolName: tool.name, - pluginId: entry.pluginId, - allowlist, - }), - ) - : listRaw; - if (list.length === 0) { - continue; - } - const nameSet = new Set(); - for (const tool of list) { - if (nameSet.has(tool.name) || existing.has(tool.name)) { - const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; - if (!params.suppressNameConflicts) { - log.error(message); - registry.diagnostics.push({ - level: "error", - pluginId: entry.pluginId, - source: entry.source, - message, - }); - } - continue; - } - nameSet.add(tool.name); - existing.add(tool.name); - pluginToolMeta.set(tool, { - pluginId: entry.pluginId, - optional: entry.optional, - }); - tools.push(tool); - } - } - - return tools; + return resolveExtensionHostPluginTools({ + registry, + context: params.context, + existingToolNames: params.existingToolNames, + toolAllowlist: params.toolAllowlist, + suppressNameConflicts: params.suppressNameConflicts, + }); }