mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 12:21:25 +00:00
244 lines
6.5 KiB
TypeScript
244 lines
6.5 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { createHookRunner } from "./hooks.js";
|
|
import { addStaticTestHooks } from "./hooks.test-helpers.js";
|
|
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
|
|
import type { PluginHookToolContext } from "./types.js";
|
|
import type { PluginHookBeforeToolCallResult } from "./types.js";
|
|
|
|
const stubCtx: PluginHookToolContext = {
|
|
toolName: "bash",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:main",
|
|
};
|
|
|
|
async function runBeforeToolCallWithHooks(
|
|
registry: PluginRegistry,
|
|
hooks: ReadonlyArray<{
|
|
pluginId: string;
|
|
result: PluginHookBeforeToolCallResult;
|
|
priority?: number;
|
|
}>,
|
|
) {
|
|
addStaticTestHooks(registry, {
|
|
hookName: "before_tool_call",
|
|
hooks,
|
|
});
|
|
const runner = createHookRunner(registry);
|
|
return await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
|
}
|
|
|
|
function expectRequireApprovalResult(
|
|
result: PluginHookBeforeToolCallResult | undefined,
|
|
expected: {
|
|
block?: boolean;
|
|
blockReason?: string;
|
|
params?: Record<string, unknown>;
|
|
requireApproval?: Record<string, unknown>;
|
|
},
|
|
) {
|
|
expect(result?.block).toBe(expected.block);
|
|
expect(result?.blockReason).toBe(expected.blockReason);
|
|
expect(result?.params).toEqual(expected.params);
|
|
expect(result?.requireApproval).toEqual(
|
|
expected.requireApproval ? expect.objectContaining(expected.requireApproval) : undefined,
|
|
);
|
|
}
|
|
|
|
describe("before_tool_call hook merger — requireApproval", () => {
|
|
let registry: PluginRegistry;
|
|
|
|
beforeEach(() => {
|
|
registry = createEmptyPluginRegistry();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "propagates requireApproval from a single plugin",
|
|
hooks: [
|
|
{
|
|
pluginId: "sage",
|
|
result: {
|
|
requireApproval: {
|
|
id: "approval-1",
|
|
title: "Sensitive tool",
|
|
description: "This tool does something sensitive",
|
|
severity: "warning",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
expectedApproval: {
|
|
id: "approval-1",
|
|
title: "Sensitive tool",
|
|
description: "This tool does something sensitive",
|
|
severity: "warning",
|
|
pluginId: "sage",
|
|
},
|
|
},
|
|
{
|
|
name: "stamps pluginId from the registration",
|
|
hooks: [
|
|
{
|
|
pluginId: "my-plugin",
|
|
result: {
|
|
requireApproval: {
|
|
id: "a1",
|
|
title: "T",
|
|
description: "D",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
expectedApproval: {
|
|
pluginId: "my-plugin",
|
|
},
|
|
},
|
|
{
|
|
name: "first hook with requireApproval wins when multiple plugins set it",
|
|
hooks: [
|
|
{
|
|
pluginId: "plugin-a",
|
|
result: {
|
|
requireApproval: {
|
|
title: "First",
|
|
description: "First plugin",
|
|
},
|
|
},
|
|
priority: 100,
|
|
},
|
|
{
|
|
pluginId: "plugin-b",
|
|
result: {
|
|
requireApproval: {
|
|
title: "Second",
|
|
description: "Second plugin",
|
|
},
|
|
},
|
|
priority: 50,
|
|
},
|
|
],
|
|
expectedApproval: {
|
|
title: "First",
|
|
pluginId: "plugin-a",
|
|
},
|
|
},
|
|
{
|
|
name: "does not overwrite pluginId if plugin sets it (stamped by merger)",
|
|
hooks: [
|
|
{
|
|
pluginId: "actual-plugin",
|
|
result: {
|
|
requireApproval: {
|
|
title: "T",
|
|
description: "D",
|
|
pluginId: "should-be-overwritten",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
expectedApproval: {
|
|
pluginId: "actual-plugin",
|
|
},
|
|
},
|
|
] as const)("$name", async ({ hooks, expectedApproval }) => {
|
|
const result = await runBeforeToolCallWithHooks(registry, hooks);
|
|
expectRequireApprovalResult(result, { requireApproval: expectedApproval });
|
|
});
|
|
|
|
it("merges block and requireApproval from different plugins", async () => {
|
|
const result = await runBeforeToolCallWithHooks(registry, [
|
|
{
|
|
pluginId: "approver",
|
|
result: {
|
|
requireApproval: {
|
|
title: "Needs approval",
|
|
description: "Approval needed",
|
|
},
|
|
},
|
|
priority: 100,
|
|
},
|
|
{
|
|
pluginId: "blocker",
|
|
result: {
|
|
block: true,
|
|
blockReason: "blocked",
|
|
},
|
|
priority: 50,
|
|
},
|
|
]);
|
|
expect(result?.block).toBe(true);
|
|
expect(result?.requireApproval?.title).toBe("Needs approval");
|
|
});
|
|
|
|
it("returns undefined requireApproval when no plugin sets it", async () => {
|
|
const result = await runBeforeToolCallWithHooks(registry, [
|
|
{ pluginId: "plain", result: { params: { extra: true } } },
|
|
]);
|
|
expect(result?.requireApproval).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "freezes params after requireApproval when a lower-priority plugin tries to override them",
|
|
hooks: [
|
|
{
|
|
pluginId: "approver",
|
|
result: {
|
|
params: { source: "approver", safe: true },
|
|
requireApproval: {
|
|
title: "Needs approval",
|
|
description: "Approval needed",
|
|
},
|
|
},
|
|
priority: 100,
|
|
},
|
|
{
|
|
pluginId: "mutator",
|
|
result: {
|
|
params: { source: "mutator", safe: false },
|
|
},
|
|
priority: 50,
|
|
},
|
|
],
|
|
expected: {
|
|
requireApproval: { pluginId: "approver" },
|
|
params: { source: "approver", safe: true },
|
|
},
|
|
},
|
|
{
|
|
name: "still allows block=true from a lower-priority plugin after requireApproval",
|
|
hooks: [
|
|
{
|
|
pluginId: "approver",
|
|
result: {
|
|
params: { source: "approver", safe: true },
|
|
requireApproval: {
|
|
title: "Needs approval",
|
|
description: "Approval needed",
|
|
},
|
|
},
|
|
priority: 100,
|
|
},
|
|
{
|
|
pluginId: "blocker",
|
|
result: {
|
|
block: true,
|
|
blockReason: "blocked",
|
|
params: { source: "blocker", safe: false },
|
|
},
|
|
priority: 50,
|
|
},
|
|
],
|
|
expected: {
|
|
block: true,
|
|
blockReason: "blocked",
|
|
requireApproval: { pluginId: "approver" },
|
|
params: { source: "approver", safe: true },
|
|
},
|
|
},
|
|
] as const)("$name", async ({ hooks, expected }) => {
|
|
const result = await runBeforeToolCallWithHooks(registry, hooks);
|
|
expectRequireApprovalResult(result, expected);
|
|
});
|
|
});
|