From e3cba91ef0597a870ed7e64ec4b90dafbd98cd03 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 20:41:51 -0700 Subject: [PATCH] fix(plugins): respect manifest optional tool siblings --- CHANGELOG.md | 1 + src/plugins/tools.optional.test.ts | 56 ++++++++++++++++++++++++++++++ src/plugins/tools.ts | 37 +++++++++++++++++--- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f019c0aa5a..18e8b215009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: honor the effective tool denylist before constructing optional PDF/media tool factories, so `tools.deny: ["pdf"]` skips PDF setup before later policy filtering. Fixes #76997. - MCP/plugin tools: apply global `tools.profile`, `tools.alsoAllow`, and `tools.deny` policy while exposing plugin tools over the standalone MCP bridge, so ACP clients do not see policy-hidden plugin tools or miss opt-in optional tools. Thanks @vincentkoc. - Plugin tools: honor explicit tool denylists while selecting plugin tool runtimes, so denied plugin tools are not materialized for direct command or gateway surfaces before later policy filtering. Thanks @vincentkoc. +- Plugin tools: filter factory-returned tools by manifest per-tool optional policy, so optional sibling tools from a shared runtime factory stay hidden unless explicitly allowed. Thanks @vincentkoc. - Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946. - Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc. - Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374. diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 2864ccf0922..07c29ace81f 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1214,6 +1214,62 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); + it("does not materialize manifest-optional sibling tools from non-optional factories by default", async () => { + const config = createContext().config; + installToolManifestSnapshot({ + config, + plugin: { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["other_tool", "optional_tool"], + }, + toolMetadata: { + optional_tool: { + optional: true, + }, + }, + }, + }); + const factory = vi.fn(() => [makeTool("other_tool"), makeTool("optional_tool")]); + setActivePluginRegistry( + createToolRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool", "optional_tool"], + declaredNames: ["other_tool", "optional_tool"], + factory, + }, + ]) as never, + "test-tool-registry", + "gateway-bindable", + "/tmp", + ); + const { loadManifestContractSnapshot } = await import("./manifest-contract-eligibility.js"); + const snapshot = loadManifestContractSnapshot({ config, workspaceDir: "/tmp" }); + expect( + snapshot.plugins.find((plugin) => plugin.id === "multi")?.toolMetadata?.optional_tool, + ).toMatchObject({ optional: true }); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + config, + }, + }), + ); + + expectResolvedToolNames(tools, ["other_tool"]); + expect(factory).toHaveBeenCalledTimes(1); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + it("rejects plugin id collisions with core tool names", () => { const registry = setRegistry([ { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 8aaf7511136..08f5226464d 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1053,16 +1053,43 @@ export function resolvePluginTools(params: { continue; } const listRaw: unknown[] = Array.isArray(resolved) ? resolved : [resolved]; + const selectedManifestToolNames = + manifestPlugin && availabilityNames.length > 0 + ? new Set(allowlistNames.map((name) => normalizeToolName(name))) + : undefined; + const manifestContractToolNames = + manifestPlugin && availabilityNames.length > 0 + ? new Set(availabilityNames.map((name) => normalizeToolName(name))) + : undefined; const availableList = manifestPlugin - ? listRaw.filter((tool) => - isManifestToolNameAvailable({ + ? listRaw.filter((tool) => { + const toolName = readPluginToolName(tool); + const normalizedToolName = normalizeToolName(toolName); + if ( + isManifestToolOptional(manifestPlugin, toolName) && + !isOptionalToolAllowed({ + toolName, + pluginId: entry.pluginId, + allowlist, + }) + ) { + return false; + } + if ( + selectedManifestToolNames && + manifestContractToolNames?.has(normalizedToolName) && + !selectedManifestToolNames.has(normalizedToolName) + ) { + return false; + } + return isManifestToolNameAvailable({ plugin: manifestPlugin, - toolName: readPluginToolName(tool), + toolName, config: params.context.runtimeConfig ?? context.config, env, hasAuthForProvider: params.hasAuthForProvider, - }), - ) + }); + }) : listRaw; const policyAvailableList = availableList.filter( (tool) =>