diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c07bb45a3a..91bdd7f505c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev. - Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc. - Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop. - CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries. diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 79347fa4e57..7dafd079701 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -39,6 +39,7 @@ export function resolveGatewayScopedTools(params: { excludeToolNames?: Iterable; disablePluginTools?: boolean; senderIsOwner?: boolean; + gatewayRequestedTools?: string[]; }) { const { agentId, @@ -53,11 +54,15 @@ export function resolveGatewayScopedTools(params: { } = resolveEffectiveToolPolicy({ config: params.cfg, sessionKey: params.sessionKey }); const profilePolicy = resolveToolProfilePolicy(profile); const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); - const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow); - const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy( - providerProfilePolicy, - providerProfileAlsoAllow, - ); + const gatewayRequestedTools = params.gatewayRequestedTools ?? []; + const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, [ + ...(profileAlsoAllow ?? []), + ...gatewayRequestedTools, + ]); + const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(providerProfilePolicy, [ + ...(providerProfileAlsoAllow ?? []), + ...gatewayRequestedTools, + ]); const groupPolicy = resolveGroupToolPolicy({ config: params.cfg, sessionKey: params.sessionKey, @@ -101,6 +106,7 @@ export function resolveGatewayScopedTools(params: { agentProviderPolicy, groupPolicy, subagentPolicy, + gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined, ]), }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index e96e16134ee..6764b5b2f3f 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -7,6 +7,10 @@ type RunBeforeToolCallHook = typeof runBeforeToolCallHookType; type RunBeforeToolCallHookArgs = Parameters[0]; type RunBeforeToolCallHookResult = Awaited>; +const pluginToolMetaState = vi.hoisted( + () => new Map(), +); + const hookMocks = vi.hoisted(() => ({ resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })), runBeforeToolCallHook: vi.fn( @@ -63,7 +67,8 @@ vi.mock("../plugins/config-state.js", async (importOriginal) => { }); vi.mock("../plugins/tools.js", () => ({ - getPluginToolMeta: () => undefined, + getPluginToolMeta: (tool: { name?: string }) => + typeof tool?.name === "string" ? pluginToolMetaState.get(tool.name) : undefined, })); // Perf: the real tool factory instantiates many tools per request; for these HTTP @@ -136,6 +141,11 @@ vi.mock("../agents/openclaw-tools.js", () => { parameters: { type: "object", properties: {} }, execute: async () => ({ ok: true, result: "browser" }), }, + { + name: "plugin_doctor", + parameters: { type: "object", properties: {} }, + execute: async () => ({ ok: true, permissionFlow: true }), + }, { name: "owner_only_test", ownerOnly: true, @@ -259,6 +269,8 @@ beforeEach(() => { pluginHttpHandlers = []; cfg = {}; lastCreateOpenClawToolsContext = undefined; + pluginToolMetaState.clear(); + pluginToolMetaState.set("plugin_doctor", { pluginId: "test-plugin", optional: true }); hookMocks.resolveToolLoopDetectionConfig.mockClear(); hookMocks.resolveToolLoopDetectionConfig.mockImplementation(() => ({ warnAt: 3 })); hookMocks.runBeforeToolCallHook.mockClear(); @@ -463,6 +475,25 @@ describe("POST /tools/invoke", () => { expect(lastCreateOpenClawToolsContext?.disablePluginTools).toBe(false); }); + it("allows the requested plugin tool through Gateway profile filtering", async () => { + cfg = { + ...cfg, + agents: { list: [{ id: "main", default: true }] }, + tools: { profile: "minimal" }, + }; + + const res = await invokeToolAuthed({ + tool: "plugin_doctor", + sessionKey: "main", + }); + + const body = await expectOkInvokeResponse(res); + expect(body.result).toMatchObject({ ok: true, permissionFlow: true }); + expect(lastCreateOpenClawToolsContext?.pluginToolAllowlist).toEqual( + expect.arrayContaining(["plugin_doctor"]), + ); + }); + it("blocks tool execution when before_tool_call rejects the invoke", async () => { setMainAllowedTools({ allow: ["tools_invoke_test"] }); hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({ diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index 37581399fba..cb815fbd291 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -183,6 +183,9 @@ export async function invokeGatewayTool(params: { } } + const knownCoreTool = isKnownCoreToolId(toolName); + const gatewayRequestedTools = knownCoreTool ? [] : [toolName]; + const action = normalizeOptionalString(params.input.action); const argsRaw = params.input.args; const args = @@ -203,9 +206,9 @@ export async function invokeGatewayTool(params: { surface: "http", disablePluginTools, senderIsOwner: params.senderIsOwner, + gatewayRequestedTools, }); - const knownCoreTool = isKnownCoreToolId(toolName); let { agentId, tools } = resolveTools(knownCoreTool); if (knownCoreTool && !tools.some((candidate) => candidate.name === toolName)) { ({ agentId, tools } = resolveTools(false));