Files
metona-ai-desktop/electron/harness/hooks/__tests__/confirmation-hook.test.ts
T
thzxx 49c9b25538
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
2026-08-21 13:58:48 +08:00

318 lines
12 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 } from 'vitest';
import type { BrowserWindow } from 'electron';
import { ConfirmationHook } from '../confirmation-hook';
import type { MetonaToolCall, MetonaToolDef } from '../../types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../types';
/** 需要确认的高风险工具定义 */
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.FILE_SYSTEM,
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.FILE_SYSTEM,
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;
}
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);
});
});