feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
+615 -1
View File
@@ -31,6 +31,8 @@ vi.mock('../context', () => ({
import { registerAgentHandlers } from '../agent';
import type { IPCContext } from '../context';
import type { MetonaMessage } from '../../harness/types';
import { DeepSeekAdapter } from '../../harness/adapters/deepseek.adapter';
import { OllamaAdapter } from '../../harness/adapters/ollama.adapter';
// ===== Mock 依赖工厂 =====
@@ -71,12 +73,19 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
getEngine: Mock;
abort: Mock;
waitForAbort: Mock;
// v0.7.4 P1-3/P3-5: 同会话防重入 + 会话删除联动淘汰引擎
isRunning: Mock;
disposeEngine: Mock;
getAdapter: Mock;
};
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
.fn()
.mockResolvedValue(true);
(engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false);
(engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn();
(engineManager as unknown as { getAdapter: Mock }).getAdapter = vi.fn(() => null);
const ctx = {
agentEngineManager: engineManager,
@@ -96,6 +105,8 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
saveMessage: vi.fn(),
getMessages: vi.fn(() => []),
updateTokenUsage: vi.fn(),
// v0.7.4 P1-4: 会话存在性预检(getSession 返回非 null 视为会话存在)
getSession: vi.fn(() => ({ id: 'sess_1' })),
},
workspaceService: {
getFiles: vi.fn(() => ({ soul: '# Metona', memory: '# Memory' })),
@@ -140,7 +151,7 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
// mock 改用 EventEmitter 提供真实 on/emit 行为
// v0.5.1: abortByParent 返回 taskId[]abortSession 据此清理 SubAgent pending 确认)
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
confirmationHook: { clearPending: vi.fn() },
confirmationHook: { clearPending: vi.fn(), forgetSession: vi.fn() },
// v0.7.3 P4-1: 会话标题生成器接线(sendMessage 完成路径消费)
titleGenerator: { maybeGenerateTitle: vi.fn().mockResolvedValue(null) },
reloadAdapter: vi.fn(() => true),
@@ -379,12 +390,16 @@ describe('agent:sendMessage — 异常路径', () => {
getEngine: Mock;
abort: Mock;
waitForAbort: Mock;
isRunning: Mock;
disposeEngine: Mock;
};
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
.fn()
.mockResolvedValue(true);
(engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false);
(engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn();
const { ctx, ctxRaw } = makeCtx({
agentEngineManager: engineManager,
});
@@ -440,3 +455,602 @@ function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
};
return engine.runStream.mock.calls[0][3];
}
// ===== 追加:sendMessage 并发/会话预检 =====
describe('agent:sendMessage — 并发防重入与会话预检(v0.7.4 P1-3/P1-4', () => {
it('同会话并发:isRunning=true 时拒绝第二次调用并广播 ERROR+DONE(不启动引擎)', async () => {
const { ctx, ctxRaw } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => true),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => null),
}),
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toMatchObject({ success: false });
expect((result as { error: string }).error).toContain('正在执行任务');
const errorCall = broadcastMock.mock.calls.find(
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error',
);
expect(errorCall).toBeDefined();
const doneCall = broadcastMock.mock.calls.find(
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'done',
);
expect(doneCall).toBeDefined();
// 引擎不启动(防重入的核心)
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
// 用户消息不落库(拒绝发生在持久化前)
expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled();
});
it('会话不存在:预检拒绝 + stopRecording 收尾(ERROR+DONE 防 isStreaming 卡死)', async () => {
const { ctx, ctxRaw } = makeCtx({
sessionService: {
saveMessage: vi.fn(),
getMessages: vi.fn(() => []),
updateTokenUsage: vi.fn(),
getSession: vi.fn(() => null), // 会话不存在
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_deleted');
expect(result).toMatchObject({ success: false });
expect((result as { error: string }).error).toContain('会话不存在');
// 录制终止 + ERROR/DONE 双事件
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type);
expect(eventTypes).toContain('error');
expect(eventTypes).toContain('done');
});
it('数据准备抛错(历史加载/记忆检索失败)→ ERROR 事件 + stopRecording 收尾', async () => {
const { ctx, ctxRaw } = makeCtx({
sessionSummaryService: {
buildHistoryMessages: vi.fn(() => {
throw new Error('DB locked');
}),
maybeSummarize: vi.fn().mockResolvedValue(undefined),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toMatchObject({ success: false, error: 'DB locked' });
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
const errorCall = broadcastMock.mock.calls.find(
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error',
);
expect(errorCall).toBeDefined();
});
it('记忆检索失败 → 前置块退化为仅时间/时区,run 仍正常继续', async () => {
const { ctx, ctxRaw } = makeCtx({
memoryManager: {
search: vi.fn(() => {
throw new Error('search db down');
}),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toEqual({ success: true });
// 前置块仍生成(含日期上下文),不阻断主流程
const userMessage = engine_runStreamUserMessage(ctxRaw);
expect(userMessage.content).toContain('[Contextual information for this message');
});
it('注入防御被配置关闭(security.promptInjectionDefense=false)→ 跳过检测直接运行', async () => {
const detect = vi.fn();
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) => (key === 'security.promptInjectionDefense' ? false : '')),
},
promptInjectionDefender: { detect },
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toEqual({ success: true });
expect(detect).not.toHaveBeenCalled();
});
it('注入风险 4-6(警告级)→ 记录警告但放行', async () => {
const { ctx, ctxRaw } = makeCtx({
promptInjectionDefender: {
detect: vi.fn(() => ({
isInjection: true,
riskScore: 5,
findings: [{ pattern: 'suspicious', matched: 'x', severity: 'medium' }],
recommendation: 'CAUTION',
})),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toEqual({ success: true });
expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalled();
});
it('assistant 仅有工具调用无文本时 content 为 null 落库(C-6 契约)', async () => {
const engine = makeEngineMock({
runStream: vi.fn().mockResolvedValue({
finalAnswer: 'answer',
terminationReason: 'completed',
iterations: [
{
iteration: 1,
state: 'TOOL_CALL',
startedAt: 1,
completedAt: 2,
thought: { content: '', reasoningContent: '', timestamp: 1, iteration: 1 },
toolCalls: [
{
id: 'tc_1',
name: 'read_file',
args: { path: 'a.txt' },
iteration: 1,
timestamp: 1,
},
],
toolResults: [
{
toolCallId: 'tc_1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 5,
timestamp: 1,
},
],
},
],
totalTokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
durationMs: 10,
metadata: {},
}),
});
const engineManager = new EventEmitter() as EventEmitter & {
getEngine: Mock;
abort: Mock;
waitForAbort: Mock;
isRunning: Mock;
disposeEngine: Mock;
getAdapter: Mock;
};
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
.fn()
.mockResolvedValue(true);
(engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false);
(engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn();
(engineManager as unknown as { getAdapter: Mock }).getAdapter = vi.fn(() => null);
const { ctx, ctxRaw } = makeCtx({ agentEngineManager: engineManager });
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
await handler(null, VALID_MESSAGE, 'sess_1');
// assistant 消息 content=null(纯工具调用轮)
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({ role: 'assistant', content: null }),
);
// tool 结果消息落库
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
toolResult: expect.objectContaining({ toolCallId: 'tc_1' }),
}),
);
});
it('输出验证抛错不阻断成功路径(验证失败仅记录)', async () => {
const { ctx, ctxRaw } = makeCtx({
outputValidator: {
validate: vi.fn(() => {
throw new Error('validator crashed');
}),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toEqual({ success: true });
expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalled();
});
});
// ===== 追加:abortSession 深入 =====
describe('agent:abortSession — SubAgent 清理', () => {
function makeCtxWithOrchestrator(abortedTasks: string[]) {
return makeCtx({
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => abortedTasks) }),
confirmationHook: {
clearPending: vi.fn(),
forgetSession: vi.fn(),
},
});
}
it('abortByParent 返回的每个 taskId 都做 forgetSession + stopRecordingTRACE 收尾)', async () => {
const { ctx, ctxRaw } = makeCtxWithOrchestrator(['task_1', 'task_2']);
registerAgentHandlers(ctx);
const handler = getHandler('agent:abortSession');
const result = await handler(null, 'sess_1');
expect(result).toEqual({ success: true });
expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1');
expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledTimes(2);
expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledWith('task_1');
expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledWith('task_2');
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalledWith(
'task_1',
expect.objectContaining({ terminationReason: 'user_interrupt' }),
);
});
it('无 SubAgent 时不调用 forgetSession(空数组短路)', async () => {
const { ctx, ctxRaw } = makeCtxWithOrchestrator([]);
registerAgentHandlers(ctx);
const handler = getHandler('agent:abortSession');
await handler(null, 'sess_1');
expect(ctxRaw.confirmationHook.forgetSession).not.toHaveBeenCalled();
expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalledWith('sess_1');
expect(ctxRaw.agentEngineManager.abort).toHaveBeenCalledWith('sess_1');
expect(ctxRaw.agentEngineManager.waitForAbort).toHaveBeenCalledWith('sess_1');
});
it('waitForAbort 在返回前被 awaitMT-1:防重发卡在等待中)', async () => {
const waitForAbort = vi.fn().mockResolvedValue(true);
const { ctx } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort,
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => null),
}),
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:abortSession');
await handler(null, 'sess_1');
expect(waitForAbort).toHaveBeenCalled();
// abort 在 waitForAbort 之前调用
const abortOrder = (ctx.agentEngineManager as unknown as { abort: Mock }).abort.mock
.invocationCallOrder[0];
const waitOrder = waitForAbort.mock.invocationCallOrder[0];
expect(abortOrder).toBeLessThan(waitOrder);
});
});
// ===== 追加:llm:getBalance / llm:listModels / llm:ollamaPull =====
describe('llm:getBalance — DeepSeek 余额查询', () => {
it('adapter 为 DeepSeek 且余额存在 → 返回余额', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'sk-test',
defaultModel: 'deepseek-v4-pro',
});
vi.spyOn(adapter, 'getBalance').mockResolvedValue({
currency: 'CNY',
totalBalance: '10.00',
grantedBalance: '0',
toppedUpBalance: '10.00',
});
const { ctx } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => adapter),
}),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:getBalance')(null)) as {
success: boolean;
data?: unknown;
};
expect(result).toMatchObject({ success: true, data: { currency: 'CNY' } });
});
it('adapter 非 DeepSeek → 明确拒绝(仅 DeepSeek 支持)', async () => {
const { ctx } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => ({ providerId: 'openai' })),
}),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:getBalance')(null)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('only supported for the DeepSeek provider');
});
it('getBalance 返回 null → 失败(API Key 无效或网络错误)', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'bad',
defaultModel: 'deepseek-v4-pro',
});
vi.spyOn(adapter, 'getBalance').mockResolvedValue(null);
const { ctx } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => adapter),
}),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:getBalance')(null)) as { success: boolean };
expect(result.success).toBe(false);
});
it('getBalance 抛异常 → 返回错误信息', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'k',
defaultModel: 'deepseek-v4-pro',
});
vi.spyOn(adapter, 'getBalance').mockRejectedValue(new Error('network down'));
const { ctx } = makeCtx({
agentEngineManager: Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => adapter),
}),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:getBalance')(null)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('network down');
});
});
describe('llm:listModels — 动态模型列表', () => {
function managerWithAdapter(adapter: unknown) {
return Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => adapter),
});
}
it('Provider/Model 未配置 → 明确失败', async () => {
const { ctx } = makeCtx({
configService: { get: vi.fn(() => '') },
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:listModels')(null)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('LLM 未配置');
});
it('非 ollama Provider 且无 API Key → 失败', async () => {
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) =>
key === 'llm.provider' ? 'deepseek' : key === 'llm.model' ? 'deepseek-v4-pro' : '',
),
},
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:listModels')(null)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('API Key 未配置');
});
it('ollama Provider 无需 API Key → 放行', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
defaultModel: 'qwen3',
});
vi.spyOn(adapter, 'listModels').mockResolvedValue([{ id: 'qwen3' }]);
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) =>
key === 'llm.provider' ? 'ollama' : key === 'llm.model' ? 'qwen3' : '',
),
},
agentEngineManager: managerWithAdapter(adapter),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:listModels')(null)) as {
success: boolean;
data?: unknown[];
};
expect(result.success).toBe(true);
expect(result.data).toEqual([{ id: 'qwen3' }]);
});
it('reloadAdapter 失败 → 返回配置校验失败', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'k',
defaultModel: 'deepseek-v4-pro',
});
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) =>
key === 'llm.provider'
? 'deepseek'
: key === 'llm.model'
? 'deepseek-v4-pro'
: key === 'llm.apiKey'
? 'k'
: '',
),
},
reloadAdapter: vi.fn(() => false),
agentEngineManager: managerWithAdapter(adapter),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:listModels')(null)) as { success: boolean };
expect(result.success).toBe(false);
});
it('adapter 无 listModels 方法 → 明确失败', async () => {
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) =>
key === 'llm.provider'
? 'deepseek'
: key === 'llm.model'
? 'm'
: key === 'llm.apiKey'
? 'k'
: '',
),
},
agentEngineManager: managerWithAdapter({ providerId: 'x' }),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:listModels')(null)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('不支持模型列表查询');
});
});
describe('llm:ollamaPull / ollamaPullCancel — 模型下载', () => {
function managerWithAdapter(adapter: unknown) {
return Object.assign(new EventEmitter(), {
getEngine: vi.fn(),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
isRunning: vi.fn(() => false),
disposeEngine: vi.fn(),
getAdapter: vi.fn(() => adapter),
});
}
it('非法模型名(非字符串 / 空 / 非法字符)拒绝', async () => {
const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(null) });
registerAgentHandlers(ctx);
const handler = getHandler('llm:ollamaPull');
expect(await handler(null, '')).toMatchObject({ success: false });
expect(await handler(null, 42)).toMatchObject({ success: false });
expect(await handler(null, 'rm -rf /')).toMatchObject({ success: false });
expect(await handler(null, 'model with spaces')).toMatchObject({ success: false });
});
it('非 Ollama Provider → 拒绝', async () => {
const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter({ providerId: 'deepseek' }) });
registerAgentHandlers(ctx);
const result = (await getHandler('llm:ollamaPull')(null, 'qwen3:8b')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('仅 Ollama Provider');
});
it('成功路径:进度广播 + ollamaPullEnded 收尾', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
defaultModel: 'qwen3',
});
vi.spyOn(adapter, 'pullModel').mockImplementation(async (_m, onProgress) => {
onProgress?.({ status: 'downloading', completed: 50, total: 100 });
});
const { ctx } = makeCtx({
agentEngineManager: managerWithAdapter(adapter),
});
registerAgentHandlers(ctx);
const result = await getHandler('llm:ollamaPull')(null, 'qwen3:8b');
expect(result).toEqual({ success: true });
const progressCall = broadcastMock.mock.calls.find(([ch]) => ch === 'llm:ollamaPullProgress');
expect(progressCall).toBeDefined();
expect(progressCall![1]).toMatchObject({ model: 'qwen3:8b', status: 'downloading' });
const endedCall = broadcastMock.mock.calls.find(([ch]) => ch === 'llm:ollamaPullEnded');
expect(endedCall).toBeDefined();
expect(endedCall![1]).toEqual({ model: 'qwen3:8b' });
});
it('pullModel 抛错 → 返回错误 + aborted 标志(取消信号语义)', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
defaultModel: 'qwen3',
});
vi.spyOn(adapter, 'pullModel').mockRejectedValue(new Error('download failed'));
const { ctx } = makeCtx({
agentEngineManager: managerWithAdapter(adapter),
});
registerAgentHandlers(ctx);
const result = (await getHandler('llm:ollamaPull')(null, 'qwen3:8b')) as {
success: boolean;
error?: string;
aborted?: boolean;
};
expect(result.success).toBe(false);
expect(result.error).toBe('download failed');
expect(result.aborted).toBe(false);
});
it('无进行中的下载时 ollamaPullCancel → 失败', async () => {
const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(null) });
registerAgentHandlers(ctx);
const result = (await getHandler('llm:ollamaPullCancel')(null)) as { success: boolean };
expect(result.success).toBe(false);
});
});
+477
View File
@@ -376,12 +376,16 @@ describe('data:clear* — 危险操作事务语义', () => {
it('clearAuditLogs:先删防篡改触发器再清表,最后重建触发器(INSERT-ONLY 契约)', async () => {
const { prepare, exec } = makeDbMock();
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
// v0.7.4 P1-5: 清空后重置链式哈希缓存(防 verifyChain 误报 TAMPERED
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(true);
expect(resetChainCache).toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls[0]).toBe('BEGIN');
expect(execCalls.some((c) => c.includes('DROP TRIGGER IF EXISTS audit_no_delete'))).toBe(true);
@@ -390,4 +394,477 @@ describe('data:clear* — 危险操作事务语义', () => {
);
expect(execCalls[execCalls.length - 1]).toBe('COMMIT');
});
it('clearMemories 返回三张表各自的删除计数', async () => {
const { prepare, exec } = makeDbMock();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as {
deletedEpisodic: number;
deletedSemantic: number;
deletedWorking: number;
};
expect(result).toEqual({
success: true,
deletedEpisodic: 2,
deletedSemantic: 2,
deletedWorking: 2,
});
});
it('clearMemories 中途失败 → ROLLBACK(三表一致性)', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM working_memories')) {
throw new Error('db error');
}
});
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as { success: boolean };
expect(result.success).toBe(false);
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearAuditLogs 失败 → ROLLBACK 且不重置链缓存', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM audit_logs')) throw new Error('locked');
});
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(false);
expect(resetChainCache).not.toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearSessions 删除计数准确(session/message 各行)', async () => {
const prepare = vi.fn((sql: string) => ({
run: vi.fn(),
get: vi.fn(() => (sql.includes('FROM messages') ? { c: 5 } : { c: 3 })),
all: vi.fn(() => []),
}));
const exec = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearSessions')(null)) as {
deletedSessions: number;
deletedMessages: number;
};
expect(result).toEqual({ success: true, deletedSessions: 3, deletedMessages: 5 });
});
});
// ===== 追加:app 域补充 =====
describe('app:openExternal — 协议白名单扩展(M-12', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it.each([
'chrome://settings',
'about:blank',
'ws://host',
'ftp://host',
'file:///etc',
'smb://x',
'javascript:void(0)',
])('%s → 协议不在白名单拒绝', async (url) => {
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, url)) as { success: boolean };
expect(result.success).toBe(false);
});
it('大小写协议(HTTPS)→ 解析为 https: 放行', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:openExternal')(null, 'HTTPS://example.com')).toMatchObject({
success: true,
});
});
it('shell.openExternal 抛错 → 返回失败', async () => {
const shellMock = (await import('electron')).shell as unknown as {
openExternal: Mock;
};
shellMock.openExternal.mockRejectedValueOnce(new Error('no browser'));
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, 'https://example.com')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('no browser');
});
it('app:showItemInFolder 校验 path 类型', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:showItemInFolder')(null, '')).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, 42)).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, '/tmp/x')).toMatchObject({
success: true,
});
});
it('app:getVersion / app:getAppDataPath 透传', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:getVersion')(null)).toBe('0.7.2');
expect(await getHandler('app:getAppDataPath')(null)).toBe('/tmp/userdata');
});
});
describe('searxng:testConnection — 高危 URL 拒绝(v0.7.4 P2-9', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it('云元数据 169.254.169.254 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://169.254.169.254/latest/meta-data',
'',
'',
)) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('metadata.google.internal 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://metadata.google.internal/',
'',
'',
)) as { success: boolean };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('本地回环 SearXNG 实例放行(自建实例合法)', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://127.0.0.1:8080',
'',
'',
)) as { success: boolean; statusCode?: number };
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
// 测试 URL 拼接 /search?q=test
expect(fetchSpy.mock.calls[0][0]).toContain('/search?q=test');
vi.unstubAllGlobals();
});
it('非 2xx 响应 → 返回 statusCode + HTTP 错误信息', async () => {
registerAppHandlers(makeCtx());
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
);
const result = (await getHandler('searxng:testConnection')(
null,
'https://sx.local',
'',
'',
)) as { success: boolean; statusCode?: number; error?: string };
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain('HTTP 500');
vi.unstubAllGlobals();
});
it('无认证信息时不携带 Authorization 头', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', '', '');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers).toEqual({});
vi.unstubAllGlobals();
});
it('Bearer 认证 → Authorization: Bearer <key>', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'tok-abc', 'bearer');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBe('Bearer tok-abc');
vi.unstubAllGlobals();
});
it('未知 authType → 不附加认证头(仅接受 bearer/basic', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'secret', 'oauth');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBeUndefined();
vi.unstubAllGlobals();
});
});
describe('error:report — 截断与审计补充', () => {
it('type 字段截断到 100 字符', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, { type: 'T'.repeat(300), error: 'e', stack: '', timestamp: Date.now() });
const entry = log.mock.calls[0][0] as { details: { type: string } };
expect(entry.details.type.length).toBe(100);
});
it('日志处理自身抛错不向外抛出(单向通道安全)', () => {
const log = vi.fn(() => {
throw new Error('audit db down');
});
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
expect(() => listener(null, { type: 'x', error: 'e' })).not.toThrow();
});
it('数组载荷通过 typeof 守卫被记录(仅 null/原始类型被忽略;数组字段为 undefined', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, [1, 2, 3]);
// 源码守卫 `typeof payload !== 'object'` 对数组放行 —— 数组被当作对象记录(字段 undefined)
expect(log).toHaveBeenCalledTimes(1);
});
});
describe('audit 域 — 补充校验', () => {
function makeCtx(): { ctx: IPCContext; query: Mock } {
const query = vi.fn(() => []);
return {
ctx: {
auditService: {
query,
log: vi.fn(),
verifyChain: vi.fn(() => ({
valid: true,
totalRecords: 0,
verifiedRecords: 0,
tamperedId: null,
})),
},
} as unknown as IPCContext,
query,
};
}
it('audit:query sessionId 过滤透传;空 sessionId 被忽略', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
const handler = getHandler('audit:query');
await handler(null, { sessionId: 's1', limit: 10 });
expect(query).toHaveBeenCalledWith({ sessionId: 's1', limit: 10 });
await handler(null, { sessionId: '' });
expect(query).toHaveBeenLastCalledWith({});
});
it('audit:query 合法 eventType 白名单透传', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
await getHandler('audit:query')(null, { eventType: 'tool_call' });
expect(query).toHaveBeenCalledWith({ eventType: 'tool_call' });
});
it('audit:export 默认 jsonlcsv 显式选择', async () => {
const exportJSONL = vi.fn(() => '{"a":1}\n{"b":2}\n');
const exportCSV = vi.fn(() => 'col\nval\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV, log: vi.fn() },
} as unknown as IPCContext);
const handler = getHandler('audit:export');
const jsonl = (await handler(null)) as { format: string; recordCount: number };
expect(jsonl.format).toBe('jsonl');
expect(jsonl.recordCount).toBe(2);
const csv = (await handler(null, 'csv')) as { format: string; recordCount: number };
expect(csv.format).toBe('csv');
expect(csv.recordCount).toBe(1); // 表头不计入
});
it('audit:export 任意 format 回退 jsonl(非 csv 一律 jsonl', async () => {
const exportJSONL = vi.fn(() => 'x\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV: vi.fn(), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null, 'xml')) as { format: string };
expect(r.format).toBe('jsonl');
});
it('audit:export 空内容 → recordCount 0', async () => {
registerAppHandlers({
auditService: { exportJSONL: vi.fn(() => ''), exportCSV: vi.fn(() => ''), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null)) as { recordCount: number };
expect(r.recordCount).toBe(0);
});
it('audit:verifyChain 失败路径返回错误', async () => {
registerAppHandlers({
auditService: {
verifyChain: vi.fn(() => {
throw new Error('chain corrupted');
}),
log: vi.fn(),
},
} as unknown as IPCContext);
const r = (await getHandler('audit:verifyChain')(null)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(r.error).toBe('chain corrupted');
});
});
// ===== 追加:data:export 脱敏 =====
describe('data:export — 补充脱敏', () => {
function makeCtx(messages: unknown[], sessions?: unknown[]) {
const sessionService = {
getMessages: vi.fn(() => messages),
list: vi.fn(() => sessions ?? []),
};
const configService = {
getAll: vi.fn(() => ({ 'llm.apiKey': 'sk-raw-1234', 'ui.theme': 'dark' })),
};
return { ctx: { sessionService, configService } as unknown as IPCContext, sessionService };
}
it('全量导出:非工具消息原样保留(无 dataUrl 不动)', async () => {
const msgs = [
{ id: 'm1', role: 'user', content: 'hello', timestamp: Date.now() },
{ id: 'm2', role: 'assistant', content: 'hi back', timestamp: Date.now() },
];
const { ctx } = makeCtx(msgs, [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ messages: unknown[]; truncated: boolean }> };
};
const session = result.data.sessions[0];
expect(session.messages).toEqual(msgs);
expect(session.truncated).toBe(false);
});
it('全量导出:条数达到上限时标记 truncated=true(防 Blob 序列化 OOM', async () => {
const { ctx, sessionService } = makeCtx([], [{ id: 's1', title: 't' }]);
// 构造恰好 2000 条消息(达到上限 → truncated=true
const many = Array.from({ length: 2000 }, (_, i) => ({
id: `m${i}`,
role: 'user',
content: 'x',
}));
sessionService.getMessages.mockImplementation(() => many);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ truncated: boolean }> };
};
expect(result.data.sessions[0].truncated).toBe(true);
});
it('全量导出配置脱敏不包含明文 API Key 子串', async () => {
const { ctx } = makeCtx([], [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { config: Record<string, unknown> };
};
expect(JSON.stringify(result.data.config)).not.toContain('sk-raw-1234');
});
it('单会话导出不返回 truncated 标记(仅全量导出带)', async () => {
const msgs = [{ id: 'm1', role: 'user', content: 'x' }];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
success: boolean;
data: unknown[];
};
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
});
it('toolResult.result 为字符串时不剥离(dataUrl 剥离仅针对对象)', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: 'plain string result' },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: string } }>;
};
expect(result.data[0].toolResult.result).toBe('plain string result');
});
it('toolResult.result 为数组(非对象)不剥离', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: [{ path: 'a.png', dataUrl: 'x' }] },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: Array<{ dataUrl: string }> } }>;
};
expect(result.data[0].toolResult.result).toHaveLength(1);
expect(result.data[0].toolResult.result[0].dataUrl).toBe('x');
});
});
+329 -1
View File
@@ -40,7 +40,6 @@ describe('mcp:addServer — 校验矩阵', () => {
const addServer = vi.fn(async () => undefined);
return { ctx: { mcpManager: { addServer } } as unknown as IPCContext, addServer };
}
it.each([
[null, 'Invalid config'],
[undefined, 'Invalid config'],
@@ -183,6 +182,99 @@ describe('mcp:addServer — 校验矩阵', () => {
expect(result.success).toBe(false);
expect(result.error).toBe('connect timeout');
});
it('headers 键超长(>128 字符)拒绝', async () => {
const { ctx } = makeCtx();
registerMCPHandlers(ctx);
const result = (await getHandler('mcp:addServer')(null, {
name: 'x',
transport: 'streamable-http',
url: 'https://a.com/mcp',
headers: { ['X'.repeat(200)]: 'v' },
})) as { success: boolean };
expect(result.success).toBe(false);
});
it('headers 值为空字符串合法(允许空值);非字符串值拒绝', async () => {
const { ctx, addServer } = makeCtx();
registerMCPHandlers(ctx);
const handler = getHandler('mcp:addServer');
// 空字符串值合法
await handler(null, {
name: 'x',
transport: 'streamable-http',
url: 'https://a.com/mcp',
headers: { 'X-Empty': '' },
});
expect(addServer).toHaveBeenCalled();
});
it('transport=sse 时 url 必填且必须可解析', async () => {
const { ctx } = makeCtx();
registerMCPHandlers(ctx);
const handler = getHandler('mcp:addServer');
expect(await handler(null, { name: 'x', transport: 'sse' })).toMatchObject({ success: false });
expect(await handler(null, { name: 'x', transport: 'sse', url: '::bad' })).toMatchObject({
success: false,
});
});
it('url 指向云元数据 169.254.169.254 → 拒绝(SSRF 配置面防护)', async () => {
const { ctx } = makeCtx();
registerMCPHandlers(ctx);
const result = (await getHandler('mcp:addServer')(null, {
name: 'evil',
transport: 'streamable-http',
url: 'http://169.254.169.254/latest/meta-data',
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('cloud metadata');
});
it('url 指向 metadata.google.internal → 拒绝', async () => {
const { ctx } = makeCtx();
registerMCPHandlers(ctx);
const result = (await getHandler('mcp:addServer')(null, {
name: 'evil',
transport: 'sse',
url: 'http://metadata.google.internal/',
})) as { success: boolean };
expect(result.success).toBe(false);
});
it('url 指向本地回环 127.0.0.1 → 放行(本地 MCP 服务器合法用例)', async () => {
const { ctx, addServer } = makeCtx();
registerMCPHandlers(ctx);
const result = (await getHandler('mcp:addServer')(null, {
name: 'local',
transport: 'streamable-http',
url: 'http://127.0.0.1:8080/mcp',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(addServer).toHaveBeenCalled();
});
it('mcp:listServers 透传 manager 状态', async () => {
const getServerStates = vi.fn(() => [{ name: 'srv', status: 'running' }]);
registerMCPHandlers({ mcpManager: { getServerStates } } as unknown as IPCContext);
const result = (await getHandler('mcp:listServers')(null)) as unknown[];
expect(result).toHaveLength(1);
});
it('mcp:toggleServer manager 抛错 → 返回错误', async () => {
const toggleServer = vi.fn(async () => {
throw new Error('toggle failed');
});
registerMCPHandlers({ mcpManager: { toggleServer } } as unknown as IPCContext);
const result = (await getHandler('mcp:toggleServer')(null, 'srv', true)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('toggle failed');
});
});
describe('mcp:removeServer / toggleServer — 校验', () => {
@@ -315,6 +407,152 @@ describe('tasks 域 — 会话越权防护与校验', () => {
await getHandler('tasks:list')(null);
expect(all).toHaveBeenCalled();
});
it('tasks:list 带 sessionId 过滤 SQL', async () => {
const { ctx, prepare, all } = makeCtx();
registerTaskHandlers(ctx);
await getHandler('tasks:list')(null, 's1');
const sql = String(prepare.mock.calls[0][0]);
expect(sql).toContain('WHERE session_id = ?');
expect(all).toHaveBeenCalledWith('s1');
});
it('tasks:create 合法路径带 parentId → order_idx 按父子分组自增', async () => {
const { ctx, prepare, run } = makeCtx();
const get = vi.fn(() => ({ maxOrder: 2 }));
(prepare as unknown as Mock).mockImplementation(() => ({
run,
get,
all: vi.fn(() => []),
}));
registerTaskHandlers(ctx);
const result = (await getHandler('tasks:create')(null, {
sessionId: 's1',
title: '子任务',
parentId: 'task_p1',
priority: 'low',
})) as { success: boolean; id: string };
expect(result.success).toBe(true);
expect(result.id).toMatch(/^task_/);
// 同 session + parent 分组查询(参数经 get() 传入)
const selectSql = String(prepare.mock.calls[0][0]);
expect(selectSql).toContain('parent_id = ?');
expect(get).toHaveBeenCalledWith('s1', 'task_p1');
});
it('tasks:create 描述非字符串 → 缺省空串落库', async () => {
const { ctx, run } = makeCtx();
(ctx.sessionService as unknown as { getDB: () => unknown }).getDB = () => ({
prepare: vi.fn(() => ({
run,
get: vi.fn(() => ({ maxOrder: -1 })),
all: vi.fn(() => []),
})),
});
registerTaskHandlers(ctx);
const result = (await getHandler('tasks:create')(null, {
sessionId: 's1',
title: 't',
description: 123,
})) as { success: boolean };
expect(result.success).toBe(true);
});
it('tasks:create DB 抛错 → 返回错误信息', async () => {
const { ctx } = makeCtx();
(ctx.sessionService as unknown as { getDB: () => unknown }).getDB = () => ({
prepare: vi.fn(() => {
throw new Error('UNIQUE constraint failed');
}),
});
registerTaskHandlers(ctx);
const result = (await getHandler('tasks:create')(null, {
sessionId: 's1',
title: 't',
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toBe('UNIQUE constraint failed');
});
it('tasks:update 跨会话伪造 parent_id → WHERE 包含 session_id 保护(越权防护)', async () => {
const { ctx, prepare, run } = makeCtx();
prepare.mockImplementation(() => ({
run,
get: vi.fn(() => ({ id: 't1' })),
all: vi.fn(() => []),
}));
registerTaskHandlers(ctx);
// 伪造:用另一会话的 sessionId 修改 t1 —— WHERE 同时带 id 与 session_id
await getHandler('tasks:update')(null, 't1', { title: 'hack' }, 'sess_attacker');
const sql = String(
prepare.mock.calls.find((c) => String(c[0]).startsWith('UPDATE'))?.[0] ?? '',
);
expect(sql).toContain('WHERE id = ? AND session_id = ?');
expect(run).toHaveBeenCalled();
});
it('tasks:update 空更新(无 fields)→ 幂等 success 不触发 UPDATE', async () => {
const { ctx, prepare, run } = makeCtx();
prepare.mockClear();
registerTaskHandlers(ctx);
const result = await getHandler('tasks:update')(null, 't1', {}, 's');
expect(result).toEqual({ success: true });
expect(run).not.toHaveBeenCalled();
});
it('tasks:update 完成状态附带 completed_at 写入', async () => {
const { ctx, prepare, run } = makeCtx();
prepare.mockImplementation(() => ({
run,
get: vi.fn(() => ({ id: 't1' })),
all: vi.fn(() => []),
}));
registerTaskHandlers(ctx);
await getHandler('tasks:update')(null, 't1', { status: 'completed' }, 's');
const sql = String(
prepare.mock.calls.find((c) => String(c[0]).startsWith('UPDATE'))?.[0] ?? '',
);
expect(sql).toContain('completed_at = ?');
});
it('tasks:update 非法 id/sessionId/updates 拒绝', async () => {
const { ctx } = makeCtx();
registerTaskHandlers(ctx);
const handler = getHandler('tasks:update');
expect(await handler(null, '', { status: 'completed' }, 's')).toMatchObject({ success: false });
expect(await handler(null, 't1', { status: 'completed' }, '')).toMatchObject({
success: false,
});
expect(await handler(null, 't1', null, 's')).toMatchObject({ success: false });
expect(await handler(null, 't1', { assignedTo: 42 }, 's')).toMatchObject({ success: false });
});
it('tasks:delete 跨会话删除 → WHERE 保护(他会话任务不受影响)', async () => {
const { ctx, prepare, run } = makeCtx();
registerTaskHandlers(ctx);
await getHandler('tasks:delete')(null, 't1', 'sess_attacker');
const sql = String(prepare.mock.calls[0][0]);
expect(sql).toContain('WHERE id = ? AND session_id = ?');
expect(run).toHaveBeenCalledWith('t1', 'sess_attacker');
});
it('tasks:delete 非法 id / sessionId 拒绝', async () => {
const { ctx } = makeCtx();
registerTaskHandlers(ctx);
const handler = getHandler('tasks:delete');
expect(await handler(null, '', 's')).toMatchObject({ success: false });
expect(await handler(null, 't1', 42)).toMatchObject({ success: false });
});
});
// ===== Memory 域 =====
@@ -384,4 +622,94 @@ describe('memory 域 — 查询参数收敛与删除映射', () => {
expect(await handler(null, 'unknown', 'm3')).toMatchObject({ success: false });
});
it('memory:delete working 类型映射到 working_memories 表', async () => {
const { ctx, prepare } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('memory:delete');
await handler(null, 'working', 'w1');
expect(String(prepare.mock.calls[0][0])).toContain('working_memories');
});
it('memory:delete 非法 id(非字符串/空)拒绝', async () => {
const { ctx } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('memory:delete');
expect(await handler(null, 'episodic', '')).toMatchObject({ success: false });
expect(await handler(null, 'episodic', 42)).toMatchObject({ success: false });
expect(await handler(null, 123, 'm1')).toMatchObject({ success: false });
});
it('memory:listAll 合法 type 过滤:仅查对应表', async () => {
const { ctx, prepare } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('memory:listAll');
await handler(null, { type: 'semantic', limit: 50 });
// 只执行 semantic 表的 SELECT
const sqls = prepare.mock.calls.map((c) => String(c[0]));
expect(sqls).toHaveLength(1);
expect(sqls[0]).toContain('semantic_memories');
});
it('memory:listAll 未指定 type → 查询全部三张表', async () => {
const { ctx, prepare } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('memory:listAll');
await handler(null);
const sqls = prepare.mock.calls.map((c) => String(c[0]));
expect(sqls.some((s) => s.includes('episodic_memories'))).toBe(true);
expect(sqls.some((s) => s.includes('semantic_memories'))).toBe(true);
expect(sqls.some((s) => s.includes('working_memories'))).toBe(true);
});
it('memory:listAll limit 小数向下取整', async () => {
const { ctx, prepare } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('memory:listAll');
await handler(null, { type: 'episodic', limit: 42.7 });
const all = prepare.mock.results[0].value.all as Mock;
expect(all).toHaveBeenCalledWith(42);
});
it('db:searchMemories options 非对象(null/字符串)→ 使用默认搜索选项', async () => {
const { ctx } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('db:searchMemories');
const search = (ctx.memoryManager as unknown as { search: Mock }).search;
await handler(null, 'q', null);
expect(search).toHaveBeenLastCalledWith('q', {});
await handler(null, 'q', 'not-an-object');
expect(search).toHaveBeenLastCalledWith('q', {});
});
it('db:searchMemories minImportance 数值透传(非有限值剔除)', async () => {
const { ctx } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('db:searchMemories');
const search = (ctx.memoryManager as unknown as { search: Mock }).search;
await handler(null, 'q', { minImportance: 0.5 });
expect(search).toHaveBeenLastCalledWith('q', expect.objectContaining({ minImportance: 0.5 }));
await handler(null, 'q', { minImportance: Number.NaN });
expect(search).toHaveBeenLastCalledWith(
'q',
expect.not.objectContaining({ minImportance: expect.anything() }),
);
});
it('db:searchMemories 空 query(仅空白)→ 返回 [] 不触达 search', async () => {
const { ctx } = makeCtx();
registerMemoryHandlers(ctx);
const handler = getHandler('db:searchMemories');
const search = (ctx.memoryManager as unknown as { search: Mock }).search;
expect(await handler(null, ' ')).toEqual([]);
expect(search).not.toHaveBeenCalled();
});
});
@@ -322,6 +322,8 @@ describe('config 域 — 配置写入与审计', () => {
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set: vi.fn(),
// v0.7.4 P3-6: setBatch 接线(批量保存单事务 + 全局层一次落盘)
setBatch: vi.fn(),
},
auditService: { log: vi.fn() },
agentEngineManager: { updateConfigAll: vi.fn() },
@@ -368,10 +370,11 @@ describe('config 域 — 配置写入与审计', () => {
for (const bad of [null, [], [{ key: '', value: 1 }], [{ key: 'k', value: {} }]]) {
expect(await getHandler('config:setBatch')(null, bad)).toMatchObject({ success: false });
}
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
// v0.7.4 P3-6: 非法输入不触发任何写入(setBatch 不被调用)
expect((raw.configService as { setBatch: Mock }).setBatch).not.toHaveBeenCalled();
});
it('config:setBatch 合法路径:逐条写入 + 审计 + 广播;Provider 变更先清 apiKey', async () => {
it('config:setBatch 合法路径:批量写入 + 审计 + 广播;Provider 变更先清 apiKey', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
@@ -382,12 +385,14 @@ describe('config 域 — 配置写入与审计', () => {
]);
expect(r).toEqual({ success: true });
const set = (raw.configService as { set: Mock }).set;
// Provider 变更(deepseek → ollama)在写入前先清 apiKeyC-1 契约)
expect(set).toHaveBeenCalledWith('llm.apiKey', '');
expect(set).toHaveBeenCalledWith('ui.theme', 'light');
expect(set).toHaveBeenCalledWith('llm.provider', 'ollama');
expect(set).toHaveBeenCalledWith('llm.model', 'qwen3:8b');
// v0.7.4 P3-6: 批量写入收敛为 configService.setBatch 单次调用(单事务 + 全局层一次落盘)
const setBatch = (raw.configService as { setBatch: Mock }).setBatch;
expect(setBatch).toHaveBeenCalledTimes(1);
expect(setBatch).toHaveBeenCalledWith([
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.provider', value: 'ollama' },
{ key: 'llm.model', value: 'qwen3:8b' },
]);
// 每条写入都有审计记录
expect((raw.auditService as { log: Mock }).log).toHaveBeenCalledTimes(3);
@@ -409,3 +414,394 @@ describe('config 域 — 配置写入与审计', () => {
expect(raw.reloadAdapter as Mock).toHaveBeenCalled();
});
});
// ===== 追加:sessions 校验矩阵 =====
describe('sessions 域 — 补充校验矩阵', () => {
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
sessionService: {
list: vi.fn(() => []),
create: vi.fn(() => ({ id: 's1' })),
rename: vi.fn(() => true),
delete: vi.fn(() => true),
getMessages: vi.fn(() => []),
updateMessageContent: vi.fn(() => true),
pin: vi.fn(() => true),
archive: vi.fn(() => true),
clearMessages: vi.fn(),
truncateMessagesAfter: vi.fn(() => true),
searchMessages: vi.fn(() => []),
saveTraceData: vi.fn(),
getTraceData: vi.fn(() => null),
},
agentEngineManager: {
isRunning: vi.fn(() => false),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
disposeEngine: vi.fn(),
},
confirmationHook: { forgetSession: vi.fn() },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('updateMessageContent 校验:非法 sessionId / messageId / content 拒绝', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:updateMessageContent');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, '', 'm1', 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', '', 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', 42, 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', 'm1', 123)).toMatchObject({ success: false });
expect(svc.updateMessageContent).not.toHaveBeenCalled();
await handler(null, 's1', 'm1', '新内容');
expect(svc.updateMessageContent).toHaveBeenCalledWith('s1', 'm1', '新内容');
});
it('updateMessageContent service 抛错 → 返回错误信息', async () => {
const { ctx } = makeCtx();
(ctx.sessionService as unknown as Record<string, Mock>).updateMessageContent = vi.fn(() => {
throw new Error('message not found');
});
registerSessionHandlers(ctx);
const result = (await getHandler('sessions:updateMessageContent')(
null,
's1',
'm_missing',
'x',
)) as { success: boolean; error?: string };
expect(result).toMatchObject({ success: false, error: 'message not found' });
});
it('sessions:delete 在会话运行中先中断再删除,且清理决策记忆 + 销毁引擎', async () => {
const { ctx, raw } = makeCtx();
(raw.agentEngineManager as Record<string, Mock>).isRunning = vi.fn(() => true);
registerSessionHandlers(ctx);
const handler = getHandler('sessions:delete');
const result = await handler(null, 's1');
expect(result).toEqual({ success: true });
expect((raw.agentEngineManager as Record<string, Mock>).abort).toHaveBeenCalledWith('s1');
expect((raw.agentEngineManager as Record<string, Mock>).waitForAbort).toHaveBeenCalledWith(
's1',
);
expect((raw.confirmationHook as Record<string, Mock>).forgetSession).toHaveBeenCalledWith('s1');
expect((raw.agentEngineManager as Record<string, Mock>).disposeEngine).toHaveBeenCalledWith(
's1',
);
});
it('sessions:delete 删除失败 → 不清理决策记忆/引擎', async () => {
const { ctx, raw } = makeCtx();
(raw.sessionService as Record<string, Mock>).delete = vi.fn(() => false);
registerSessionHandlers(ctx);
const handler = getHandler('sessions:delete');
const result = await handler(null, 's1');
expect(result).toEqual({ success: false });
expect((raw.confirmationHook as Record<string, Mock>).forgetSession).not.toHaveBeenCalled();
expect((raw.agentEngineManager as Record<string, Mock>).disposeEngine).not.toHaveBeenCalled();
});
it('sessions:clearMessages 操作完成即成功(A1 语义:空会话同样 success', async () => {
const { ctx } = makeCtx();
registerSessionHandlers(ctx);
expect(await getHandler('sessions:clearMessages')(null, 's1')).toEqual({ success: true });
});
it('sessions:truncateAfter:非法 messageId 拒绝;inclusive 缺省为 true', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:truncateAfter');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, 's1', '', true)).toMatchObject({ success: false });
expect(svc.truncateMessagesAfter).not.toHaveBeenCalled();
await handler(null, 's1', 'm1');
expect(svc.truncateMessagesAfter).toHaveBeenCalledWith('s1', 'm1', true);
await handler(null, 's1', 'm1', false);
expect(svc.truncateMessagesAfter).toHaveBeenLastCalledWith('s1', 'm1', false);
});
it('sessions:searchContent 空查询拒绝;合法查询透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:searchContent');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, ' ')).toMatchObject({ success: false });
expect(await handler(null, 42)).toMatchObject({ success: false });
await handler(null, '关键词');
expect(svc.searchMessages).toHaveBeenCalledWith('关键词');
});
it('sessions:pin/archive 合法布尔透传 service', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
await getHandler('sessions:pin')(null, 's1', true);
expect(svc.pin).toHaveBeenCalledWith('s1', true);
await getHandler('sessions:archive')(null, 's1', false);
expect(svc.archive).toHaveBeenCalledWith('s1', false);
});
it('sessions:rename 合法路径透传 title', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
const result = await getHandler('sessions:rename')(null, 's1', '新标题');
expect(result).toEqual({ success: true });
expect(svc.rename).toHaveBeenCalledWith('s1', '新标题');
});
it('sessions:list / create / getTrace 透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
await getHandler('sessions:list')(null);
expect(svc.list).toHaveBeenCalled();
await getHandler('sessions:create')(null, '标题');
expect(svc.create).toHaveBeenCalledWith('标题');
await getHandler('sessions:getTrace')(null, 's1');
expect(svc.getTraceData).toHaveBeenCalledWith('s1');
});
});
// ===== 追加:config:set URL 类键校验 =====
describe('config 域 — URL 类配置键高危目标校验(v0.7.4 P2-9-C', () => {
function makeCtx(overrides: Record<string, unknown> = {}): {
ctx: IPCContext;
raw: Record<string, unknown>;
} {
const raw = {
configService: {
get: vi.fn(() => null),
set: vi.fn(),
setBatch: vi.fn(),
},
auditService: { log: vi.fn() },
agentEngineManager: { updateConfigAll: vi.fn() },
orchestrator: { updateDefaultConfig: vi.fn() },
confirmationHook: { setConfirmationTimeout: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: raw as unknown as IPCContext, raw };
}
it.each(['llm.baseURL', 'llm.fallbackBaseURL', 'searxng.url', 'app.updateFeedUrl'])(
'%s 指向云元数据 169.254.169.254 → 拒绝且不落库',
async (key) => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(
null,
key,
'http://169.254.169.254/latest/meta-data',
);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
},
);
it('llm.baseURL 指向 metadata.google.internal → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(
null,
'llm.baseURL',
'http://metadata.google.internal/computeMetadata/v1',
);
expect(r).toMatchObject({ success: false });
});
it('llm.baseURL 指向链路本地 169.254.16.0 段 → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
expect(await getHandler('config:set')(null, 'llm.baseURL', 'http://169.254.0.1')).toMatchObject(
{
success: false,
},
);
});
it('llm.baseURL 指向组播 224.0.0.1 → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
expect(await getHandler('config:set')(null, 'llm.baseURL', 'http://224.0.0.1')).toMatchObject({
success: false,
});
});
it('llm.baseURL 合法公网/本地回环 → 放行(本地 Ollama 是合法用例)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
for (const url of [
'http://localhost:11434',
'http://127.0.0.1:11434',
'http://192.168.1.5:8080',
'https://api.openai.com/v1',
]) {
const r = await getHandler('config:set')(null, 'llm.baseURL', url);
expect(r).toMatchObject({ success: true });
}
expect((raw.configService as { set: Mock }).set).toHaveBeenCalled();
});
it('非 URL 类键(ui.theme)不受 URL 校验影响', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(null, 'ui.theme', 'dark');
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith('ui.theme', 'dark');
});
it('config:setBatch 含高危 URL → 整体拒绝(批量不部分写入)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.baseURL', value: 'http://169.254.169.254' },
]);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { setBatch: Mock }).setBatch).not.toHaveBeenCalled();
});
it('Provider 切换时旧 apiKey 被清空(C-1:防止不兼容 key 复用)', async () => {
const set = vi.fn();
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set,
setBatch: vi.fn(),
},
});
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.provider', 'openai');
expect(set).toHaveBeenCalledWith('llm.apiKey', '');
});
it('同一 Provider 重设不误清 apiKey', async () => {
const set = vi.fn();
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set,
setBatch: vi.fn(),
},
});
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.provider', 'deepseek');
expect(set).not.toHaveBeenCalledWith('llm.apiKey', '');
});
});
// ===== 追加:tools 域补充 =====
describe('tools 域 — 补充契约', () => {
const TOOL_A = {
name: 'write_file',
description: 'd',
category: 'filesystem',
riskLevel: 'medium',
requiresPermission: true,
enabled: true,
};
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
toolRegistry: {
listAllTools: vi.fn(() => [TOOL_A]),
listTools: vi.fn(() => [TOOL_A]),
setToolEnabled: vi.fn(),
},
confirmationHook: {
resolveConfirmation: vi.fn(),
resolveConfirmationsBatch: vi.fn(() => ['tc_1']),
getPendingConfirmations: vi.fn(() => [{ id: 'tc_1' }]),
getRememberedDenials: vi.fn(() => []),
resetRememberedDenial: vi.fn(() => true),
setAutoExecute: vi.fn(),
getAutoExecuteList: vi.fn(() => []),
},
agentEngineManager: { setToolsAll: vi.fn() },
configService: { set: vi.fn() },
toolsReadyRef: { ready: true, toolCount: 1 },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('tools:toggle 参数类型非法(非字符串/非布尔)拒绝', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tools:toggle')(null, 42, true)).toMatchObject({ success: false });
expect(await getHandler('tools:toggle')(null, 'write_file', 'yes')).toMatchObject({
success: false,
});
expect(await getHandler('tools:toggle')(null, '', true)).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
});
it('tools:toggle 启用路径(enabled=true)同步三处', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'write_file', true);
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith(
'tools.write_file.enabled',
true,
);
});
it('tool:getPendingConfirmations / getAutoExecuteList 透传', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const pending = (await getHandler('tool:getPendingConfirmations')(null)) as {
success: boolean;
data: unknown[];
};
expect(pending.success).toBe(true);
expect(pending.data).toHaveLength(1);
await getHandler('tool:getAutoExecuteList')(null);
expect(
(raw.confirmationHook as { getAutoExecuteList: Mock }).getAutoExecuteList,
).toHaveBeenCalled();
});
it('tool:resetRememberedDenial 非法 toolName 拒绝;合法返回 reset 结果', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const handler = getHandler('tool:resetRememberedDenial');
expect(await handler(null, '')).toMatchObject({ success: false });
expect(await handler(null, 42)).toMatchObject({ success: false });
const r = await handler(null, 'write_file', 's1');
expect(r).toEqual({ success: true });
expect(
(raw.confirmationHook as { resetRememberedDenial: Mock }).resetRememberedDenial,
).toHaveBeenCalledWith('write_file', 's1');
});
it('tool:confirmationResponse 部分合法字段的默认值(remember/autoExecute 缺省 false', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponse');
const hook = (raw.confirmationHook as { resolveConfirmation: Mock }).resolveConfirmation;
listener(null, { toolCallId: 'tc_1', approved: true });
expect(hook).toHaveBeenCalledWith('tc_1', true, false, false);
});
});