Files
metona-ai-desktop/electron/ipc/__tests__/sessions-tools-config.test.ts
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

813 lines
31 KiB
TypeScript
Raw Permalink 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.
/**
* IPC 域 Handler 测试(v0.7.2 P4-14 覆盖补齐)
*
* sessions / tools / config 三个域此前零测试。本文件锁定三类契约:
* 1. 参数校验矩阵(M-34/M-35/M-42 系列修复的回归防线)—— 非法输入被拒绝
* 且不触达 service 层
* 2. v0.7.2 A1 语义修正 —— sessions:clearMessages 的成功判定是"操作完成"
* 而非"有行被删除"(空会话清空同样 success:true
* 3. tools:toggle / setAutoExecute 的未知工具拒绝(防配置 key 污染)与
* config:set/setBatch 的类型校验、敏感值脱敏审计
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// ===== Mock electronipcMain =====
const ipcMainHandleMock = vi.fn();
const ipcMainOnMock = vi.fn();
vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
on: (...args: unknown[]) => ipcMainOnMock(...args),
},
}));
// ===== Mock broadcastipc/contextshared.ts 的 config:changed 广播) =====
const broadcastMock = vi.fn();
vi.mock('../context', () => ({
broadcast: (...args: unknown[]) => broadcastMock(...args),
}));
import { registerSessionHandlers } from '../sessions';
import { registerToolHandlers } from '../tools';
import { registerConfigHandlers } from '../config';
import type { IPCContext } from '../context';
function getHandler(channel: string): (...args: unknown[]) => Promise<unknown> {
const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel);
if (!call) throw new Error(`IPC handler not registered: ${channel}`);
return call[1] as (...args: unknown[]) => Promise<unknown>;
}
function getListener(channel: string): (...args: unknown[]) => void {
const call = ipcMainOnMock.mock.calls.find(([ch]) => ch === channel);
if (!call) throw new Error(`IPC listener not registered: ${channel}`);
// ipcMain.on 处理器签名 (event, ...args) —— 返回宽松签名以便按双参调用
return call[1] as (...args: unknown[]) => void;
}
beforeEach(() => {
ipcMainHandleMock.mockClear();
ipcMainOnMock.mockClear();
broadcastMock.mockClear();
});
// ===== sessions 域 =====
describe('sessions 域 — 参数校验矩阵', () => {
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
sessionService: {
list: vi.fn(() => []),
create: vi.fn(),
rename: vi.fn(() => true),
delete: vi.fn(() => true),
getMessages: vi.fn(() => []),
pin: vi.fn(() => true),
archive: vi.fn(() => true),
// v0.7.3 P1-3: sessions:deleteMessage 死通道已删除,service 方法一并移除
clearMessages: vi.fn(),
truncateMessagesAfter: vi.fn(() => true),
searchMessages: vi.fn(() => []),
saveTraceData: vi.fn(),
getTraceData: vi.fn(() => null),
},
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('rename / delete / pin / archive 对非法 sessionId 拒绝且不触达 service', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
for (const bad of [null, undefined, 123, '', 'x'.repeat(201)]) {
expect(await getHandler('sessions:rename')(null, bad, 't')).toMatchObject({ success: false });
expect(await getHandler('sessions:delete')(null, bad)).toMatchObject({ success: false });
expect(await getHandler('sessions:pin')(null, bad, true)).toMatchObject({ success: false });
expect(await getHandler('sessions:archive')(null, bad, true)).toMatchObject({
success: false,
});
}
expect(svc.rename).not.toHaveBeenCalled();
expect(svc.delete).not.toHaveBeenCalled();
expect(svc.pin).not.toHaveBeenCalled();
expect(svc.archive).not.toHaveBeenCalled();
});
it('rename 拒绝空 titlepin/archive 拒绝非布尔', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:rename')(null, 's1', ' ')).toMatchObject({ success: false });
expect(await getHandler('sessions:pin')(null, 's1', 'yes')).toMatchObject({ success: false });
expect(await getHandler('sessions:archive')(null, 's1', 1)).toMatchObject({ success: false });
expect(svc.rename).not.toHaveBeenCalled();
expect(svc.pin).not.toHaveBeenCalled();
expect(svc.archive).not.toHaveBeenCalled();
});
it('getMessages 对非法 sessionId 返回空数组;合法调用透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]);
await getHandler('sessions:getMessages')(null, 's1');
// v0.8.1 P2-3: 未传选项时以 undefined 透传(全量语义向后兼容)
expect(svc.getMessages).toHaveBeenCalledWith('s1', undefined);
});
it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:saveTrace')(null, 's1', null)).toMatchObject({
success: false,
});
expect(await getHandler('sessions:saveTrace')(null, 's1', { tokenUsage: {} })).toMatchObject({
success: false,
});
expect(await getHandler('sessions:saveTrace')(null, 's1', { traceSteps: 'x' })).toMatchObject({
success: false,
});
expect(svc.saveTraceData).not.toHaveBeenCalled();
await getHandler('sessions:saveTrace')(null, 's1', {
traceSteps: [],
tokenUsage: { totalTokens: 0 },
});
expect(svc.saveTraceData).toHaveBeenCalledWith('s1', {
traceSteps: [],
tokenUsage: { totalTokens: 0 },
});
});
});
describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => {
function makeCtx(): IPCContext {
return {
sessionService: { clearMessages: vi.fn() },
// v0.8.1 P0-2: clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: vi.fn() },
} as unknown as IPCContext;
}
it('操作完成即 success:true(空会话 / 0 行删除同样是成功)', async () => {
registerSessionHandlers(makeCtx());
const result = await getHandler('sessions:clearMessages')(null, 's1');
expect(result).toEqual({ success: true });
});
it('service 抛错 → success:false + 错误信息', async () => {
const ctx = {
sessionService: {
clearMessages: vi.fn(() => {
throw new Error('db locked');
}),
},
} as unknown as IPCContext;
registerSessionHandlers(ctx);
const result = await getHandler('sessions:clearMessages')(null, 's1');
expect(result).toEqual({ success: false, error: 'db locked' });
});
it('非法 sessionId 拒绝且不触达 service', async () => {
const ctx = makeCtx();
registerSessionHandlers(ctx);
const result = await getHandler('sessions:clearMessages')(null, '');
expect(result).toMatchObject({ success: false });
expect(
(ctx.sessionService as unknown as { clearMessages: Mock }).clearMessages,
).not.toHaveBeenCalled();
});
});
// ===== tools 域 =====
describe('tools 域 — 工具开关与确认链路', () => {
const TOOL_A = {
name: 'write_file',
description: 'd',
category: 'filesystem',
riskLevel: 'medium',
requiresPermission: true,
enabled: true,
};
const TOOL_B = { ...TOOL_A, name: 'run_command', riskLevel: 'high' };
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
toolRegistry: {
listAllTools: vi.fn(() => [TOOL_A, TOOL_B]),
listTools: vi.fn(() => [TOOL_A, TOOL_B]),
setToolEnabled: vi.fn(),
},
confirmationHook: {
resolveConfirmation: vi.fn(),
resolveConfirmationsBatch: vi.fn(() => ['tc_1']),
getPendingConfirmations: vi.fn(() => []),
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: 2 },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('tools:list 透传字段', async () => {
const { ctx } = makeCtx();
registerToolHandlers(ctx);
const list = (await getHandler('tools:list')(null)) as Array<Record<string, unknown>>;
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({ name: 'write_file', riskLevel: 'medium', enabled: true });
});
it('tools:toggle 未知工具拒绝(防配置 key 污染)且不触达 registry/config', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'not_a_tool', true);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
expect((raw.toolRegistry as { setToolEnabled: Mock }).setToolEnabled).not.toHaveBeenCalled();
});
it('tools:toggle 合法路径:持久化配置 + registry + 引擎工具列表三处同步', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'write_file', false);
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith(
'tools.write_file.enabled',
false,
);
expect((raw.toolRegistry as { setToolEnabled: Mock }).setToolEnabled).toHaveBeenCalledWith(
'write_file',
false,
);
expect((raw.agentEngineManager as { setToolsAll: Mock }).setToolsAll).toHaveBeenCalled();
});
it('tools:isReady 读取共享就绪引用', async () => {
const { ctx } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tools:isReady')(null)).toEqual({ ready: true, toolCount: 2 });
});
it('tool:confirmationResponse 校验结构(无效载荷被忽略)', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponse');
// 处理器签名 (event, data) —— 首参为 event,载荷在第二参
listener(null, null);
listener(null, { approved: true });
listener(null, { toolCallId: 'tc_1', approved: 'yes' });
const hook = (raw.confirmationHook as { resolveConfirmation: Mock }).resolveConfirmation;
expect(hook).not.toHaveBeenCalled();
listener(null, { toolCallId: 'tc_1', approved: true, remember: true, autoExecute: false });
expect(hook).toHaveBeenCalledWith('tc_1', true, true, false);
});
it('tool:confirmationResponseBatch 校验 toolCallIds 数组', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponseBatch');
const hook = (raw.confirmationHook as { resolveConfirmationsBatch: Mock })
.resolveConfirmationsBatch;
listener(null, { toolCallIds: 'tc_1', approved: true });
listener(null, { toolCallIds: [], approved: true });
listener(null, { toolCallIds: ['tc_1', 42], approved: true });
expect(hook).not.toHaveBeenCalled();
listener(null, { toolCallIds: ['tc_1', 'tc_2'], approved: false, remember: true });
expect(hook).toHaveBeenCalledWith(['tc_1', 'tc_2'], false, true, false);
});
it('tool:setAutoExecute 未知工具拒绝;拒绝记忆接口透传 sessionId', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tool:setAutoExecute')(null, 'nope', true)).toMatchObject({
success: false,
});
expect(await getHandler('tool:setAutoExecute')(null, 'write_file', 'yes')).toMatchObject({
success: false,
});
expect(await getHandler('tool:setAutoExecute')(null, 'write_file', true)).toEqual({
success: true,
});
await getHandler('tool:getRememberedDenials')(null, 'sess-9');
expect(
(raw.confirmationHook as { getRememberedDenials: Mock }).getRememberedDenials,
).toHaveBeenCalledWith('sess-9');
});
});
// ===== config 域 =====
describe('config 域 — 配置写入与审计', () => {
function makeCtx(overrides: Record<string, unknown> = {}): {
ctx: IPCContext;
raw: Record<string, unknown>;
} {
const raw = {
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() },
orchestrator: { updateDefaultConfig: vi.fn() },
confirmationHook: { setConfirmationTimeout: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('config:get / config:set 类型校验矩阵', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
await getHandler('config:get')(null, 'ui.theme');
expect((raw.configService as { get: Mock }).get).toHaveBeenCalledWith('ui.theme');
expect(await getHandler('config:set')(null, '', 'x')).toMatchObject({ success: false });
expect(await getHandler('config:set')(null, 'k', { nested: true })).toMatchObject({
success: false,
});
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
});
it('config:set 敏感值脱敏入审计(明文 API Key 不落审计日志)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.apiKey', 'sk-test-1234567890');
const auditCall = (raw.auditService as { log: Mock }).log.mock.calls[0][0] as {
target: string;
details: { value: unknown };
};
expect(auditCall.target).toBe('llm.apiKey');
expect(auditCall.details.value).toBe('***7890');
});
it('config:setBatch 非法载荷整体拒绝(非数组 / 空 / 坏 key / 坏值类型)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
for (const bad of [null, [], [{ key: '', value: 1 }], [{ key: 'k', value: {} }]]) {
expect(await getHandler('config:setBatch')(null, bad)).toMatchObject({ success: false });
}
// v0.7.4 P3-6: 非法输入不触发任何写入(setBatch 不被调用)
expect((raw.configService as { setBatch: Mock }).setBatch).not.toHaveBeenCalled();
});
it('config:setBatch 合法路径:批量写入 + 审计 + 广播;Provider 变更先清 apiKey', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.provider', value: 'ollama' },
{ key: 'llm.model', value: 'qwen3:8b' },
]);
expect(r).toEqual({ success: true });
// 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);
// 配置变更广播到渲染进程(前端 store 实时更新)
const broadcastKeys = broadcastMock.mock.calls
.filter(([ch]) => ch === 'config:changed')
.map(([, payload]) => (payload as { key: string }).key);
expect(broadcastKeys).toEqual(
expect.arrayContaining(['ui.theme', 'llm.provider', 'llm.model']),
);
});
it('config:setBatch 中 LLM key 变更触发 reloadAdapter;失败时整体报错', async () => {
const { ctx, raw } = makeCtx({ reloadAdapter: vi.fn(() => false) });
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [{ key: 'llm.model', value: 'm' }]);
expect(r).toMatchObject({ success: false });
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() },
// v0.8.1 P0-2: delete/purge/clearMessages 联动清理工作记忆
memoryManager: { clearWorkingMemory: 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);
});
});