feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
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:
@@ -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 + stopRecording(TRACE 收尾)', 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 在返回前被 await(MT-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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 默认 jsonl;csv 显式选择', 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)在写入前先清 apiKey(C-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);
|
||||
});
|
||||
});
|
||||
|
||||
+169
-71
@@ -14,7 +14,12 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import { broadcast } from './context';
|
||||
import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
|
||||
import type {
|
||||
MetonaMessage,
|
||||
MetonaStreamEvent,
|
||||
MetonaError,
|
||||
MetonaSystemPrompt,
|
||||
} from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
||||
@@ -80,7 +85,20 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const iterationTraces = new Map<string, IterationTrace>();
|
||||
|
||||
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
|
||||
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
|
||||
// 容量 200:超过后淘汰最旧的(固化频率窗口 10 分钟,200 个会话远超实际并发上限)。
|
||||
const lastConsolidationBySession = new Map<string, number>();
|
||||
const MAX_CONSOLIDATION_TRACKED = 200;
|
||||
const touchConsolidation = (sessionId: string): number => {
|
||||
const now = Date.now();
|
||||
lastConsolidationBySession.set(sessionId, now);
|
||||
if (lastConsolidationBySession.size > MAX_CONSOLIDATION_TRACKED) {
|
||||
// 淘汰最旧(Map 迭代顺序 = 插入顺序)
|
||||
const oldestKey = lastConsolidationBySession.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) lastConsolidationBySession.delete(oldestKey);
|
||||
}
|
||||
return now;
|
||||
};
|
||||
|
||||
const flushThrottle = (sessionId: string): void => {
|
||||
const st = throttleStates.get(sessionId);
|
||||
@@ -200,7 +218,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.DONE:
|
||||
cleanupSessionState(sessionId);
|
||||
// v0.7.4 P1-6: DONE 不再执行 cleanupSessionState —— 引擎的 finish() 顺序是
|
||||
// 先发 DONE 再发 TERMINATED stateChange,而 TERMINATED 分支(stateChange 监听)
|
||||
// 需要读取 iterationTraces 补记最终迭代的 iteration_end/llm_response。
|
||||
// 旧实现在 DONE 时删除 iterationTraces → 最终迭代的 TRACE 完整性缺失。
|
||||
// 现仅在 TERMINATED 分支统一收尾(flush throttle buffer + 删 iterationTraces)。
|
||||
// 注意:此处仍需转发 DONE 事件到前端(下方 broadcast 统一执行)。
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -385,13 +408,42 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
|
||||
// v0.7.4 P1-3: 同会话并发防重入 —— 同一 sessionId 的第二个 invoke 在第一个 run
|
||||
// 未结束时到达,会让同一引擎被并行 runStream(工具副作用并发执行 / 内部 run-lock
|
||||
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
|
||||
// 广播明确错误事件,前端 isStreaming 正常收尾。
|
||||
if (agentEngineManager.isRunning(sessionId)) {
|
||||
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送';
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
|
||||
sendErrorEvent(busyMsg, sessionId);
|
||||
return { success: false, error: busyMsg };
|
||||
}
|
||||
|
||||
// v0.7.4 P1-4: 会话存在性预检 —— sessionId 指向已删除会话时,后续 saveMessage
|
||||
// 会因 FK 约束(foreign_keys=ON)抛错,而旧实现该调用在 try 块之外,invoke 直接
|
||||
// reject:不发 ERROR/DONE 事件、不 stopRecording,前端 isStreaming 永久卡死。
|
||||
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
|
||||
const sessionExists = sessionService.getSession(sessionId) != null;
|
||||
if (!sessionExists) {
|
||||
const missingMsg = '会话不存在或已被删除,请刷新后重试';
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
|
||||
sendErrorEvent(missingMsg, sessionId);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'error',
|
||||
});
|
||||
return { success: false, error: missingMsg };
|
||||
}
|
||||
|
||||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
const errorMsg =
|
||||
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||
log.error('[AGENT]', errorMsg);
|
||||
sendErrorEvent(errorMsg, sessionId);
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
@@ -404,73 +456,108 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
sessionRecorder.startRecording(sessionId);
|
||||
auditService.logSessionStart(sessionId);
|
||||
|
||||
// 保存用户消息到数据库
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
});
|
||||
|
||||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||
|
||||
// 从工作空间文件构建 System Prompt
|
||||
const workspaceFiles = workspaceService.getFiles();
|
||||
const systemPrompt = contextBuilder.buildSystemPrompt(
|
||||
workspaceFiles,
|
||||
workspaceService.getPath(),
|
||||
);
|
||||
|
||||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message:
|
||||
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
});
|
||||
}
|
||||
|
||||
// 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。
|
||||
// v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders,
|
||||
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||||
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||||
// v0.7.4 P1-4: 以下数据准备(保存用户消息/加载历史/构建 System Prompt/记忆检索)
|
||||
// 全部移入 try 块 —— 旧实现这些调用在 try 之外,DB 或文件系统抛错时 invoke 直接
|
||||
// reject(不发 ERROR/DONE 事件、不 stopRecording,前端 isStreaming 永久卡死)。
|
||||
// 统一由下方 catch 收尾:ERROR + DONE 双事件 + 审计 + 录制终止。
|
||||
let history: MetonaMessage[] = [];
|
||||
let systemPrompt: MetonaSystemPrompt | null = null;
|
||||
let userContextPrefix = '';
|
||||
let engineUserMessage: MetonaMessage;
|
||||
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||
const attachments = (
|
||||
userMessage as MetonaMessage & {
|
||||
attachments?: Array<{ name: string; type: string; truncated?: boolean }>;
|
||||
}
|
||||
).attachments;
|
||||
userContextPrefix = buildUserContextPrefix({
|
||||
now: Date.now(),
|
||||
memories,
|
||||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
timezoneLabel: buildTimezoneLabel(),
|
||||
// 保存用户消息到数据库
|
||||
// v0.7.4 回归修复: 透传前端消息 id(ChatMessage.id 由 genMsgId 生成)——
|
||||
// 否则 DB 用 msg_<nanoid> 生成不同 id,用户对刚发送消息"仅保存"
|
||||
// (updateMessageContent 按 id 匹配)会 0 行更新失败。
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
id: (userMessage as MetonaMessage & { id?: string }).id,
|
||||
});
|
||||
if (memories.length > 0) {
|
||||
log.debug(`[AGENT] Injected ${memories.length} memories into user context prefix`);
|
||||
|
||||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||
history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||
|
||||
// 从工作空间文件构建 System Prompt
|
||||
const workspaceFiles = workspaceService.getFiles();
|
||||
systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||||
|
||||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message:
|
||||
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
});
|
||||
}
|
||||
|
||||
// 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。
|
||||
// v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders,
|
||||
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||||
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, {
|
||||
topK: 5,
|
||||
minImportance: 0.3,
|
||||
});
|
||||
const attachments = (
|
||||
userMessage as MetonaMessage & {
|
||||
attachments?: Array<{ name: string; type: string; truncated?: boolean }>;
|
||||
}
|
||||
).attachments;
|
||||
userContextPrefix = buildUserContextPrefix({
|
||||
now: Date.now(),
|
||||
memories,
|
||||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
timezoneLabel: buildTimezoneLabel(),
|
||||
});
|
||||
if (memories.length > 0) {
|
||||
log.debug(`[AGENT] Injected ${memories.length} memories into user context prefix`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||
// 记忆检索失败时前置块退化为仅含日期时间(附件提示随之丢失可接受——
|
||||
// 主进程附件提示是辅助语义,附件内容本体仍在消息中)
|
||||
userContextPrefix = buildUserContextPrefix({
|
||||
now: Date.now(),
|
||||
memories: [],
|
||||
attachments: [],
|
||||
timezoneLabel: buildTimezoneLabel(),
|
||||
});
|
||||
}
|
||||
|
||||
// 构建发送给引擎的用户消息副本 —— 前置块只存在于该副本:
|
||||
// DB 持久化(上方 saveMessage 已用原始内容)、前端展示、记忆固化、
|
||||
// 注入检测均使用原始干净内容,互不污染。
|
||||
engineUserMessage = {
|
||||
...userMessage,
|
||||
content: withUserContextPrefix(userContextPrefix, userMessage.content),
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||
// 记忆检索失败时前置块退化为仅含日期时间(附件提示随之丢失可接受——
|
||||
// 主进程附件提示是辅助语义,附件内容本体仍在消息中)
|
||||
userContextPrefix = buildUserContextPrefix({
|
||||
now: Date.now(),
|
||||
memories: [],
|
||||
attachments: [],
|
||||
timezoneLabel: buildTimezoneLabel(),
|
||||
log.error('[AGENT] Failed to prepare message context:', err);
|
||||
const prepErr = (err as Error).message;
|
||||
broadcast('agent:streamEvent', {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '',
|
||||
sessionId,
|
||||
iteration: 0,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
error: { code: MetonaErrorCode.UNKNOWN, message: prepErr, retryable: false },
|
||||
} satisfies MetonaStreamEvent);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'error',
|
||||
});
|
||||
return { success: false, error: prepErr };
|
||||
}
|
||||
|
||||
// 构建发送给引擎的用户消息副本 —— 前置块只存在于该副本:
|
||||
// DB 持久化(上方 saveMessage 已用原始内容)、前端展示、记忆固化、
|
||||
// 注入检测均使用原始干净内容,互不污染。
|
||||
const engineUserMessage: MetonaMessage = {
|
||||
...userMessage,
|
||||
content: withUserContextPrefix(userContextPrefix, userMessage.content),
|
||||
};
|
||||
// 提示注入检测在 try 内执行(需 systemPrompt 已构建,与引擎运行同域)
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
@@ -487,7 +574,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
|
||||
sessionId,
|
||||
);
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
@@ -653,7 +740,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||||
.then((result) => {
|
||||
if (result.appended > 0) {
|
||||
lastConsolidationBySession.set(sessionId, Date.now());
|
||||
touchConsolidation(sessionId);
|
||||
log.info(
|
||||
`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`,
|
||||
);
|
||||
@@ -693,7 +780,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
durationMs: output.durationMs,
|
||||
terminationReason: output.terminationReason,
|
||||
});
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: output.iterations.length,
|
||||
totalTokens: output.totalTokenUsage.totalTokens,
|
||||
durationMs: output.durationMs,
|
||||
@@ -724,7 +811,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
// TRACE 层:停止录制
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
@@ -876,8 +963,19 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
confirmationHook.clearPending(sessionId);
|
||||
// v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务)
|
||||
// v0.7.3 P2-3: 被中止的 SubAgent 是会话终态 —— 用 forgetSession 连决策记忆一并清理
|
||||
// v0.7.4 P3-5: 被中止 SubAgent 的 TRACE 状态(subTraces/subMeta)一并清理 ——
|
||||
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
|
||||
for (const taskId of abortedTaskIds) {
|
||||
confirmationHook.forgetSession(taskId);
|
||||
subTraces.delete(taskId);
|
||||
subMeta.delete(taskId);
|
||||
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
|
||||
await sessionRecorder.stopRecording(taskId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'user_interrupt',
|
||||
});
|
||||
}
|
||||
|
||||
// TOOL 层:记录中断
|
||||
@@ -910,13 +1008,13 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const subTraces = new Map<string, SubTraceState>();
|
||||
|
||||
const finishSubTrace = (
|
||||
const finishSubTrace = async (
|
||||
taskId: string,
|
||||
reason: string,
|
||||
durationMs: number,
|
||||
iterations: number,
|
||||
): void => {
|
||||
sessionRecorder.stopRecording(taskId, {
|
||||
): Promise<void> => {
|
||||
await sessionRecorder.stopRecording(taskId, {
|
||||
totalIterations: iterations,
|
||||
totalTokens: 0,
|
||||
durationMs,
|
||||
@@ -975,7 +1073,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
durationMs: r.durationMs,
|
||||
iterations: r.iterations,
|
||||
});
|
||||
finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
|
||||
void finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -991,7 +1089,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
depth: meta?.depth ?? 1,
|
||||
error: d.error,
|
||||
});
|
||||
finishSubTrace(d.taskId, 'error', 0, 0);
|
||||
void finishSubTrace(d.taskId, 'error', 0, 0);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ipcMain, shell, app, dialog, BrowserWindow } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import type { AuditEventType } from '../services/audit.service';
|
||||
import { UpdateService } from '../services/update.service';
|
||||
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerAppHandlers(ctx: IPCContext): void {
|
||||
@@ -160,6 +161,9 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
||||
if (!url || !/^https?:\/\//.test(url)) {
|
||||
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
|
||||
}
|
||||
// v0.7.4 P2-9: 高危目标校验(云元数据/链路本地/组播/保留段拦截;
|
||||
// 本地/私网 SearXNG 实例放行——自建实例常见于局域网)
|
||||
assertSafeConfigTarget(url);
|
||||
|
||||
const startTime = Date.now();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
+43
-7
@@ -7,7 +7,13 @@
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import { LLM_CONFIG_KEYS, applyConfigSideEffects, clearApiKeyOnProviderChange, maskSensitive } from './shared';
|
||||
import {
|
||||
LLM_CONFIG_KEYS,
|
||||
applyConfigSideEffects,
|
||||
clearApiKeyOnProviderChange,
|
||||
maskSensitive,
|
||||
assertSafeConfigUrls,
|
||||
} from './shared';
|
||||
import log from 'electron-log';
|
||||
|
||||
export function registerConfigHandlers(ctx: IPCContext): void {
|
||||
@@ -23,8 +29,24 @@ export function registerConfigHandlers(ctx: IPCContext): void {
|
||||
return { success: false, error: 'Invalid config key' };
|
||||
}
|
||||
// value 必须是可序列化的基本类型(string|number|boolean|null)
|
||||
if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||
return { success: false, error: 'Invalid config value: must be string, number, boolean, or null' };
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid config value: must be string, number, boolean, or null',
|
||||
};
|
||||
}
|
||||
|
||||
// v0.7.4 P2-9-C: URL 类配置键高危目标校验(llm.baseURL/searxng.url/updateFeedUrl)
|
||||
try {
|
||||
assertSafeConfigUrls([{ key, value }]);
|
||||
} catch (err) {
|
||||
log.warn(`[CONFIG] config:set rejected URL for ${key}: ${(err as Error).message}`);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
|
||||
// C-1 修复: Provider 切换时清空 API key(P2-9: 统一走共享函数)
|
||||
@@ -74,17 +96,27 @@ export function registerConfigHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const v = e.value;
|
||||
if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
|
||||
return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` };
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// v0.7.4 P2-9-C: 批量写入同样校验 URL 类配置键
|
||||
try {
|
||||
assertSafeConfigUrls(entries as Array<{ key: string; value: unknown }>);
|
||||
} catch (err) {
|
||||
log.warn(`[CONFIG] config:setBatch rejected URL entry: ${(err as Error).message}`);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
|
||||
// v0.3.10 防御性修复: Provider 切换清空 apiKey 必须在循环前执行(共享函数内含说明)
|
||||
clearApiKeyOnProviderChange(ctx, entries as Array<{ key: string; value: unknown }>);
|
||||
|
||||
// 第一步:逐条写入 configService + 审计日志(脱敏)
|
||||
// 第一步:批量写入 configService(单事务 + 全局层一次落盘,v0.7.4 P3-6)+ 审计日志(脱敏)
|
||||
for (const entry of entries) {
|
||||
const { key, value } = entry as { key: string; value: unknown };
|
||||
configService.set(key, value);
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'config_change',
|
||||
@@ -94,9 +126,13 @@ export function registerConfigHandlers(ctx: IPCContext): void {
|
||||
outcome: 'success',
|
||||
});
|
||||
}
|
||||
configService.setBatch(entries as Array<{ key: string; value: unknown }>);
|
||||
|
||||
// 第二步:统一副作用(reloadAdapter + Engine 同步 + 日志级别 + 广播 + 工作空间路径)
|
||||
const sideEffectError = await applyConfigSideEffects(ctx, entries as Array<{ key: string; value: unknown }>);
|
||||
const sideEffectError = await applyConfigSideEffects(
|
||||
ctx,
|
||||
entries as Array<{ key: string; value: unknown }>,
|
||||
);
|
||||
if (sideEffectError) {
|
||||
log.warn('[CONFIG] Config batch save side effects failed');
|
||||
return { success: false, error: sideEffectError };
|
||||
|
||||
@@ -58,7 +58,7 @@ const sanitizeExportMessage = (msg: Record<string, unknown>): Record<string, unk
|
||||
};
|
||||
|
||||
export function registerDataHandlers(ctx: IPCContext): void {
|
||||
const { sessionService, configService } = ctx;
|
||||
const { sessionService, configService, auditService } = ctx;
|
||||
|
||||
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
||||
try {
|
||||
@@ -178,6 +178,9 @@ export function registerDataHandlers(ctx: IPCContext): void {
|
||||
END
|
||||
`);
|
||||
db.exec('COMMIT');
|
||||
// v0.7.4 P1-5: 清空后重置链式哈希缓存 —— 否则下一条 log() 的 prev_hash
|
||||
// 引用已删除的末行,audit:verifyChain 会误报 TAMPERED
|
||||
auditService.resetChainCache();
|
||||
log.info('[DATA] Audit logs cleared');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import log from 'electron-log';
|
||||
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
|
||||
|
||||
export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
const { mcpManager } = ctx;
|
||||
@@ -62,6 +63,13 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid url format' };
|
||||
}
|
||||
// v0.7.4 P2-9: 配置类 URL 高危目标校验(云元数据/链路本地/组播/保留段拦截;
|
||||
// 本地回环与私网实例放行——本地 MCP server 是合法用例)
|
||||
try {
|
||||
assertSafeConfigTarget(config.url);
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
// v0.7.2 P2-8: headers 校验 —— 可选,必须是扁平的 string→string 对象。
|
||||
// 上限约束(≤20 项、键 ≤128 字符、值 ≤4096 字符)防止把 headers
|
||||
|
||||
@@ -13,7 +13,7 @@ const isValidSessionId = (id: unknown): id is string =>
|
||||
typeof id === 'string' && id.length > 0 && id.length <= 200;
|
||||
|
||||
export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
const { sessionService, confirmationHook } = ctx;
|
||||
const { sessionService, confirmationHook, agentEngineManager } = ctx;
|
||||
|
||||
ipcMain.handle('sessions:list', async () => {
|
||||
return sessionService.list();
|
||||
@@ -34,11 +34,20 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
|
||||
// M-34 修复: 校验 sessionId
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
// v0.7.4 P3-5: 删除会话前先中断其进行中的 run(若在运行)
|
||||
// —— 防止删除后引擎仍在后台执行工具副作用
|
||||
if (agentEngineManager.isRunning(sessionId)) {
|
||||
agentEngineManager.abort(sessionId);
|
||||
await agentEngineManager.waitForAbort(sessionId);
|
||||
}
|
||||
const result = { success: sessionService.delete(sessionId) };
|
||||
// v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理
|
||||
// (防 rememberedDecisions 随会话数累积泄漏)
|
||||
if (result.success) {
|
||||
confirmationHook.forgetSession(sessionId);
|
||||
// v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口)
|
||||
// —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留
|
||||
agentEngineManager.disposeEngine(sessionId);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -49,6 +58,24 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
return sessionService.getMessages(sessionId);
|
||||
});
|
||||
|
||||
// v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。
|
||||
// 此前"仅保存"只改前端 store,会话重载即丢失(与"保存并重发"的截断语义割裂)。
|
||||
ipcMain.handle(
|
||||
'sessions:updateMessageContent',
|
||||
async (_event, sessionId: unknown, messageId: unknown, content: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof messageId !== 'string' || !messageId)
|
||||
return { success: false, error: 'Invalid messageId' };
|
||||
if (typeof content !== 'string') return { success: false, error: 'Invalid content' };
|
||||
try {
|
||||
const updated = sessionService.updateMessageContent(sessionId, messageId, content);
|
||||
return { success: updated };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' };
|
||||
|
||||
@@ -10,6 +10,37 @@ import log from 'electron-log';
|
||||
import type { IPCContext } from './context';
|
||||
import { broadcast } from './context';
|
||||
import { isSensitiveConfigKey } from '../utils/secure-config';
|
||||
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
|
||||
|
||||
/**
|
||||
* v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。
|
||||
*
|
||||
* 背景:llm.baseURL(adapter 携带 apiKey 请求头 POST)、searxng.url(搜索时携带
|
||||
* Authorization 认证头)、app.updateFeedUrl(update.service 直接 fetch)均为渲染层
|
||||
* 可控 URL。旧实现 config:set/setBatch 对 URL 类键零校验 —— XSS 或配置写入可将
|
||||
* API Key 泄给云元数据(169.254.169.254)/攻击者主机。
|
||||
*
|
||||
* 注:searxng 本地实例(127.0.0.1/192.168.x)与 Ollama 本地 baseURL 均在
|
||||
* assertSafeConfigTarget 放行范围,接入无冲突。
|
||||
*/
|
||||
const URL_CONFIG_KEYS = new Set([
|
||||
'llm.baseURL',
|
||||
'llm.fallbackBaseURL',
|
||||
'searxng.url',
|
||||
'app.updateFeedUrl',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 校验一批配置写入中的 URL 类键值。任一非法即抛错(调用方整体拒绝)。
|
||||
* 非 URL 类键 / 空值 / 非字符串值跳过(空 baseURL 表示"未配置",允许)。
|
||||
*/
|
||||
export function assertSafeConfigUrls(entries: Array<{ key: string; value: unknown }>): void {
|
||||
for (const { key, value } of entries) {
|
||||
if (!URL_CONFIG_KEYS.has(key)) continue;
|
||||
if (typeof value !== 'string' || value.trim() === '') continue;
|
||||
assertSafeConfigTarget(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM 相关配置 key(变更时触发热重载 Adapter) */
|
||||
export const LLM_CONFIG_KEYS = [
|
||||
@@ -106,6 +137,17 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
|
||||
agentEngineManager.updateConfigAll({ enableReflection: value === true });
|
||||
orchestrator.updateDefaultConfig({ enableReflection: value === true });
|
||||
break;
|
||||
// v0.7.4 P3-2: temperature/maxTokens 热生效 —— 旧实现这两个 key 不在
|
||||
// LLM_CONFIG_KEYS(不触发 reloadAdapter)也不在 applyEngineConfigKey(不更新
|
||||
// 引擎 baseConfig),设置保存后要重启才生效("读时接入、写时死配置"不对称)。
|
||||
case 'llm.temperature':
|
||||
agentEngineManager.updateConfigAll({ temperature: Number(value) });
|
||||
orchestrator.updateDefaultConfig({ temperature: Number(value) });
|
||||
break;
|
||||
case 'llm.maxTokens':
|
||||
agentEngineManager.updateConfigAll({ maxTokens: Number(value) });
|
||||
orchestrator.updateDefaultConfig({ maxTokens: Number(value) });
|
||||
break;
|
||||
case 'agent.toolExecutionTimeoutMs':
|
||||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user