Files
metona-ai-desktop/electron/harness/hooks/__tests__/confirmation-hook.test.ts
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

527 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ConfirmationHook 单元测试(v0.4.1 测试补齐)
* 覆盖:自动执行放行、会话内记忆(批准/拒绝)、拒绝记忆 TTL 过期、
* 超时拒绝、用户批准、批量审批、pending 管理、恢复询问接口
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { BrowserWindow } from 'electron';
import { ConfirmationHook } from '../confirmation-hook';
import type { MetonaToolCall, MetonaToolDef } from '../../types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../types';
// v0.7.2 P2-7: ConfirmationHook 的请求分发升级为 BrowserWindow.getAllWindows()
// 全窗口广播 —— 测试环境(node vitest)下 electron 的 BrowserWindow 为 undefined
// 统一 mock 为可控的窗口数组(默认空数组 → 广播回退到注入的 mainWindow,与旧行为一致)。
const getAllWindowsMock = vi.fn((): BrowserWindow[] => []);
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => getAllWindowsMock(),
},
}));
/** 需要确认的高风险工具定义 */
const HIGH_RISK_DEF: MetonaToolDef = {
name: 'run_command',
description: 'Execute shell command (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
};
/** 低风险工具定义(无需确认) */
const SAFE_DEF: MetonaToolDef = {
name: 'read_file',
description: 'Read file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 1_000,
};
/** 第二个需确认的高风险工具(用于记忆互不干扰的测试) */
const HIGH_RISK_DEF_2: MetonaToolDef = {
name: 'delete_file',
description: 'Delete file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
};
let idCounter = 0;
function makeToolCall(name = 'run_command'): MetonaToolCall {
return {
id: `tc_${++idCounter}`,
name,
args: { command: 'ls' },
iteration: 1,
timestamp: Date.now(),
};
}
function makeMockWindow(): BrowserWindow {
return {
isDestroyed: () => false,
webContents: { send: vi.fn() },
} as unknown as BrowserWindow;
}
beforeEach(() => {
// v0.7.2 P2-7: 每个用例前重置窗口数组 mock(默认无窗口 → 广播回退 mainWindow
getAllWindowsMock.mockReturnValue([]);
});
describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7', () => {
/** 带send 追踪的窗口(新测试用;makeMockWindow 保持原签名兼容既有用例) */
function makeTrackedWindow(destroyed = false): { win: BrowserWindow; send: Mock } {
const send = vi.fn();
const win = {
isDestroyed: () => destroyed,
webContents: { send },
} as unknown as BrowserWindow;
return { win, send };
}
it('确认请求广播到所有存活窗口(而非仅 mainWindow', async () => {
const a = makeTrackedWindow();
const b = makeTrackedWindow();
const destroyed = makeTrackedWindow(true);
getAllWindowsMock.mockReturnValue([a.win, destroyed.win, b.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约)
expect(a.send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command', expiresAt: expect.any(Number) }),
);
expect(b.send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command' }),
);
// 已销毁窗口不接收(防 send on destroyed webContents 抛错)
expect(destroyed.send).not.toHaveBeenCalled();
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false);
});
it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => {
const mainWin = makeMockWindow();
getAllWindowsMock.mockReturnValue([]);
const hook = new ConfirmationHook(mainWin, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command' }),
);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false);
});
it('全部窗口不可达时 fail-closed 阻断(no main window available', async () => {
const destroyed = makeTrackedWindow(true);
getAllWindowsMock.mockReturnValue([destroyed.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
});
it('超时提示同样广播到所有窗口(与确认请求同通道语义)', async () => {
vi.useFakeTimers();
try {
const a = makeTrackedWindow();
getAllWindowsMock.mockReturnValue([a.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000);
const p = hook.beforeExecute(makeToolCall(), 'sess');
vi.advanceTimersByTime(31_000);
await p;
expect(a.send).toHaveBeenCalledWith(
'toast:show',
expect.objectContaining({
type: 'warning',
message: expect.stringContaining('工具确认超时'),
}),
);
} finally {
vi.useRealTimers();
}
});
});
describe('ConfirmationHook — 免确认路径', () => {
it('未注册工具定义时放行(由 ToolRegistry 处理未知工具错误)', async () => {
const hook = new ConfirmationHook(null, null);
const result = await hook.beforeExecute(makeToolCall('unknown_tool'), 'sess');
expect(result.blocked).toBe(false);
});
it('无需确认的工具直接放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([SAFE_DEF]);
const result = await hook.beforeExecute(makeToolCall('read_file'), 'sess');
expect(result.blocked).toBe(false);
});
it('持久化自动执行(autoExecute)的工具放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setAutoExecute('run_command', true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
it('记住批准(remember approved)后同会话放行', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 第一次调用 → 等待确认 → 用户批准并记住
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(1);
hook.resolveConfirmation(pending[0].toolCallId, true, true, false);
expect((await p1).blocked).toBe(false);
// 第二次调用 — 记住的批准直接放行
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
});
});
describe('ConfirmationHook — 拒绝与阻断', () => {
it('记住拒绝后同会话阻断(reason 含 previously denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, true, false);
expect((await p1).blocked).toBe(true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('previously denied');
});
it('拒绝记忆 TTL 过期后恢复询问(v0.4.1', async () => {
vi.useFakeTimers();
try {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 记住拒绝
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending1 = hook.getPendingConfirmations();
hook.resolveConfirmation(pending1[0].toolCallId, false, true, false);
await p1;
// 拒绝记忆立即生效
const blockedNow = await hook.beforeExecute(makeToolCall(), 'sess');
expect(blockedNow.blocked).toBe(true);
expect(blockedNow.reason).toContain('previously denied');
// 快进 11 分钟(TTL 10 分钟)→ 拒绝记忆过期,恢复询问流程
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
// 撤掉主窗口,使询问流程以 'no main window' 阻断(证明走到了询问分支而非记忆分支)
hook.setMainWindow(null as unknown as BrowserWindow);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
// 过期记忆已被清理
expect(hook.getRememberedDenials()).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it('无主窗口时安全阻断(fail-closed', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
});
it('用户拒绝单次调用 → blocked 且 reason 含 User denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, false, false);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
});
});
describe('ConfirmationHook — 超时行为', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('确认超时视为拒绝(blocked', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000); // 最小值 30s
const p = hook.beforeExecute(makeToolCall(), 'sess');
// 快进超过超时时间
vi.advanceTimersByTime(31_000);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
// pending 已被超时清理
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
it('确认超时与用户点击的竞态:先到者赢(settled 标志)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// timer 回调已入队但未执行时,用户点击批准
vi.advanceTimersByTime(30_000);
// 超时已 resolve(false) — 后续 resolveConfirmation 无效果
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(0);
const result = await p;
expect(result.blocked).toBe(true);
});
});
describe('ConfirmationHook — 批量审批(v0.3.2', () => {
it('批量批准并行工具调用', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(2);
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
const resolved = hook.resolveConfirmationsBatch(ids, true, false, false);
expect(resolved).toHaveLength(2);
expect((await p1).blocked).toBe(false);
expect((await p2).blocked).toBe(false);
});
it('批量拒绝 + 记住 → 同工具后续调用被记忆阻断', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, false, true, false);
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
const after = await hook.beforeExecute(makeToolCall(), 'sess');
expect(after.blocked).toBe(true);
expect(after.reason).toContain('previously denied');
});
it('批量批准 + autoExecute → 写入持久化自动执行列表', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, true, false, true);
expect((await p).blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
});
describe('ConfirmationHook — 拒绝记忆管理接口(v0.4.1', () => {
it('getRememberedDenials 只返回拒绝记忆(含剩余时间)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF, HIGH_RISK_DEF_2]);
// 记住一个批准(run_command)、一个拒绝(delete_file
const pApprove = hook.beforeExecute(makeToolCall('run_command'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
await pApprove;
const pDeny = hook.beforeExecute(makeToolCall('delete_file'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pDeny;
const denials = hook.getRememberedDenials();
expect(denials).toHaveLength(1);
expect(denials[0].toolName).toBe('delete_file');
expect(denials[0].expiresInSeconds).toBeGreaterThan(0);
expect(denials[0].expiresInSeconds).toBeLessThanOrEqual(600);
});
it('resetRememberedDenial 重置后恢复询问', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
expect(hook.getRememberedDenials()).toHaveLength(1);
// 重置 → 拒绝记忆清空
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 后续调用恢复询问(有窗口 → 产生新 pending)
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p2).blocked).toBe(false);
});
it('resetRememberedDenial 对无拒绝记忆的工具返回 false', () => {
const hook = new ConfirmationHook(null, null);
expect(hook.resetRememberedDenial('run_command')).toBe(false);
});
});
describe('ConfirmationHook — clearPending', () => {
it('清空所有等待中的确认(全部视为拒绝)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
hook.clearPending();
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
});
describe('ConfirmationHook — 跨会话隔离(v0.5.0', () => {
it('A 会话记住拒绝,B 会话同工具仍正常询问(产生新 pending)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
expect((await pA).blocked).toBe(true);
// B 会话同工具 — 不受 A 会话拒绝记忆影响,进入询问流程(产生新 pending)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
const pendingB = hook.getPendingConfirmations();
expect(pendingB).toHaveLength(1);
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
// A 会话的拒绝记忆仍在(getRememberedDenials 限定 A 会话可见)
expect(hook.getRememberedDenials('sess-a')).toHaveLength(1);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(0);
});
it('A 会话记住批准,B 会话同工具仍需确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住批准
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
expect((await pA).blocked).toBe(false);
// A 会话后续调用直接放行(无新 pending)
const again = await hook.beforeExecute(makeToolCall(), 'sess-a');
expect(again.blocked).toBe(false);
expect(hook.getPendingConfirmations()).toHaveLength(0);
// B 会话同工具 — 进入询问流程(批准记忆不跨会话共享)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('中断 A 会话(clearPending 按会话)不影响 B 会话等待中的确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话各有一个等待中的确认
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(2);
// 中断 A 会话 — 只拒绝 A 的 pending
hook.clearPending('sess-a');
expect((await pA).blocked).toBe(true);
// B 会话的 pending 仍在等待,用户批准后正常放行
const remaining = hook.getPendingConfirmations();
expect(remaining).toHaveLength(1);
expect(remaining[0].toolCallId).toBeDefined();
hook.resolveConfirmation(remaining[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('resetRememberedDenial 指定会话时只重置该会话的拒绝记忆', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A、B 两个会话都记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pA;
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pB;
// 重置 A 会话的拒绝记忆 — B 会话的记忆保留
expect(hook.resetRememberedDenial('run_command', 'sess-a')).toBe(true);
expect(hook.getRememberedDenials('sess-a')).toHaveLength(0);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(1);
// 缺省 sessionId — 重置所有会话
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
});
it('setAutoExecute 启用时清除所有会话的拒绝记忆(全局设置优先)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话都记住拒绝
for (const sid of ['sess-a', 'sess-b']) {
const p = hook.beforeExecute(makeToolCall(), sid);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
}
expect(hook.getRememberedDenials()).toHaveLength(1); // 聚合去重后同一工具 1 条
// 启用自动执行 — 所有会话的拒绝记忆被清除
hook.setAutoExecute('run_command', true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 自动执行放行(任意会话)
expect((await hook.beforeExecute(makeToolCall(), 'sess-a')).blocked).toBe(false);
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
});
});