fix(gateway): keep requested plugin tools invokable (#76285) thanks @BunsDev

Keep directly requested plugin tools invokable under restrictive profiles, with the changelog update included on the verified branch.
This commit is contained in:
Val Alexander
2026-05-02 17:48:11 -05:00
committed by GitHub
parent 9404a4ddcd
commit 57d6e63f30
4 changed files with 48 additions and 7 deletions

View File

@@ -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.

View File

@@ -39,6 +39,7 @@ export function resolveGatewayScopedTools(params: {
excludeToolNames?: Iterable<string>;
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,
]),
});

View File

@@ -7,6 +7,10 @@ type RunBeforeToolCallHook = typeof runBeforeToolCallHookType;
type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
const pluginToolMetaState = vi.hoisted(
() => new Map<string, { pluginId: string; optional: boolean }>(),
);
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({

View File

@@ -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));