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

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

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

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

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

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

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

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
+477
View File
@@ -376,12 +376,16 @@ describe('data:clear* — 危险操作事务语义', () => {
it('clearAuditLogs:先删防篡改触发器再清表,最后重建触发器(INSERT-ONLY 契约)', async () => {
const { prepare, exec } = makeDbMock();
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
// v0.7.4 P1-5: 清空后重置链式哈希缓存(防 verifyChain 误报 TAMPERED
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(true);
expect(resetChainCache).toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls[0]).toBe('BEGIN');
expect(execCalls.some((c) => c.includes('DROP TRIGGER IF EXISTS audit_no_delete'))).toBe(true);
@@ -390,4 +394,477 @@ describe('data:clear* — 危险操作事务语义', () => {
);
expect(execCalls[execCalls.length - 1]).toBe('COMMIT');
});
it('clearMemories 返回三张表各自的删除计数', async () => {
const { prepare, exec } = makeDbMock();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as {
deletedEpisodic: number;
deletedSemantic: number;
deletedWorking: number;
};
expect(result).toEqual({
success: true,
deletedEpisodic: 2,
deletedSemantic: 2,
deletedWorking: 2,
});
});
it('clearMemories 中途失败 → ROLLBACK(三表一致性)', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM working_memories')) {
throw new Error('db error');
}
});
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as { success: boolean };
expect(result.success).toBe(false);
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearAuditLogs 失败 → ROLLBACK 且不重置链缓存', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM audit_logs')) throw new Error('locked');
});
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(false);
expect(resetChainCache).not.toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearSessions 删除计数准确(session/message 各行)', async () => {
const prepare = vi.fn((sql: string) => ({
run: vi.fn(),
get: vi.fn(() => (sql.includes('FROM messages') ? { c: 5 } : { c: 3 })),
all: vi.fn(() => []),
}));
const exec = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearSessions')(null)) as {
deletedSessions: number;
deletedMessages: number;
};
expect(result).toEqual({ success: true, deletedSessions: 3, deletedMessages: 5 });
});
});
// ===== 追加:app 域补充 =====
describe('app:openExternal — 协议白名单扩展(M-12', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it.each([
'chrome://settings',
'about:blank',
'ws://host',
'ftp://host',
'file:///etc',
'smb://x',
'javascript:void(0)',
])('%s → 协议不在白名单拒绝', async (url) => {
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, url)) as { success: boolean };
expect(result.success).toBe(false);
});
it('大小写协议(HTTPS)→ 解析为 https: 放行', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:openExternal')(null, 'HTTPS://example.com')).toMatchObject({
success: true,
});
});
it('shell.openExternal 抛错 → 返回失败', async () => {
const shellMock = (await import('electron')).shell as unknown as {
openExternal: Mock;
};
shellMock.openExternal.mockRejectedValueOnce(new Error('no browser'));
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, 'https://example.com')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('no browser');
});
it('app:showItemInFolder 校验 path 类型', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:showItemInFolder')(null, '')).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, 42)).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, '/tmp/x')).toMatchObject({
success: true,
});
});
it('app:getVersion / app:getAppDataPath 透传', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:getVersion')(null)).toBe('0.7.2');
expect(await getHandler('app:getAppDataPath')(null)).toBe('/tmp/userdata');
});
});
describe('searxng:testConnection — 高危 URL 拒绝(v0.7.4 P2-9', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it('云元数据 169.254.169.254 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://169.254.169.254/latest/meta-data',
'',
'',
)) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('metadata.google.internal 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://metadata.google.internal/',
'',
'',
)) as { success: boolean };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('本地回环 SearXNG 实例放行(自建实例合法)', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://127.0.0.1:8080',
'',
'',
)) as { success: boolean; statusCode?: number };
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
// 测试 URL 拼接 /search?q=test
expect(fetchSpy.mock.calls[0][0]).toContain('/search?q=test');
vi.unstubAllGlobals();
});
it('非 2xx 响应 → 返回 statusCode + HTTP 错误信息', async () => {
registerAppHandlers(makeCtx());
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
);
const result = (await getHandler('searxng:testConnection')(
null,
'https://sx.local',
'',
'',
)) as { success: boolean; statusCode?: number; error?: string };
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain('HTTP 500');
vi.unstubAllGlobals();
});
it('无认证信息时不携带 Authorization 头', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', '', '');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers).toEqual({});
vi.unstubAllGlobals();
});
it('Bearer 认证 → Authorization: Bearer <key>', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'tok-abc', 'bearer');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBe('Bearer tok-abc');
vi.unstubAllGlobals();
});
it('未知 authType → 不附加认证头(仅接受 bearer/basic', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'secret', 'oauth');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBeUndefined();
vi.unstubAllGlobals();
});
});
describe('error:report — 截断与审计补充', () => {
it('type 字段截断到 100 字符', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, { type: 'T'.repeat(300), error: 'e', stack: '', timestamp: Date.now() });
const entry = log.mock.calls[0][0] as { details: { type: string } };
expect(entry.details.type.length).toBe(100);
});
it('日志处理自身抛错不向外抛出(单向通道安全)', () => {
const log = vi.fn(() => {
throw new Error('audit db down');
});
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
expect(() => listener(null, { type: 'x', error: 'e' })).not.toThrow();
});
it('数组载荷通过 typeof 守卫被记录(仅 null/原始类型被忽略;数组字段为 undefined', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, [1, 2, 3]);
// 源码守卫 `typeof payload !== 'object'` 对数组放行 —— 数组被当作对象记录(字段 undefined)
expect(log).toHaveBeenCalledTimes(1);
});
});
describe('audit 域 — 补充校验', () => {
function makeCtx(): { ctx: IPCContext; query: Mock } {
const query = vi.fn(() => []);
return {
ctx: {
auditService: {
query,
log: vi.fn(),
verifyChain: vi.fn(() => ({
valid: true,
totalRecords: 0,
verifiedRecords: 0,
tamperedId: null,
})),
},
} as unknown as IPCContext,
query,
};
}
it('audit:query sessionId 过滤透传;空 sessionId 被忽略', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
const handler = getHandler('audit:query');
await handler(null, { sessionId: 's1', limit: 10 });
expect(query).toHaveBeenCalledWith({ sessionId: 's1', limit: 10 });
await handler(null, { sessionId: '' });
expect(query).toHaveBeenLastCalledWith({});
});
it('audit:query 合法 eventType 白名单透传', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
await getHandler('audit:query')(null, { eventType: 'tool_call' });
expect(query).toHaveBeenCalledWith({ eventType: 'tool_call' });
});
it('audit:export 默认 jsonlcsv 显式选择', async () => {
const exportJSONL = vi.fn(() => '{"a":1}\n{"b":2}\n');
const exportCSV = vi.fn(() => 'col\nval\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV, log: vi.fn() },
} as unknown as IPCContext);
const handler = getHandler('audit:export');
const jsonl = (await handler(null)) as { format: string; recordCount: number };
expect(jsonl.format).toBe('jsonl');
expect(jsonl.recordCount).toBe(2);
const csv = (await handler(null, 'csv')) as { format: string; recordCount: number };
expect(csv.format).toBe('csv');
expect(csv.recordCount).toBe(1); // 表头不计入
});
it('audit:export 任意 format 回退 jsonl(非 csv 一律 jsonl', async () => {
const exportJSONL = vi.fn(() => 'x\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV: vi.fn(), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null, 'xml')) as { format: string };
expect(r.format).toBe('jsonl');
});
it('audit:export 空内容 → recordCount 0', async () => {
registerAppHandlers({
auditService: { exportJSONL: vi.fn(() => ''), exportCSV: vi.fn(() => ''), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null)) as { recordCount: number };
expect(r.recordCount).toBe(0);
});
it('audit:verifyChain 失败路径返回错误', async () => {
registerAppHandlers({
auditService: {
verifyChain: vi.fn(() => {
throw new Error('chain corrupted');
}),
log: vi.fn(),
},
} as unknown as IPCContext);
const r = (await getHandler('audit:verifyChain')(null)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(r.error).toBe('chain corrupted');
});
});
// ===== 追加:data:export 脱敏 =====
describe('data:export — 补充脱敏', () => {
function makeCtx(messages: unknown[], sessions?: unknown[]) {
const sessionService = {
getMessages: vi.fn(() => messages),
list: vi.fn(() => sessions ?? []),
};
const configService = {
getAll: vi.fn(() => ({ 'llm.apiKey': 'sk-raw-1234', 'ui.theme': 'dark' })),
};
return { ctx: { sessionService, configService } as unknown as IPCContext, sessionService };
}
it('全量导出:非工具消息原样保留(无 dataUrl 不动)', async () => {
const msgs = [
{ id: 'm1', role: 'user', content: 'hello', timestamp: Date.now() },
{ id: 'm2', role: 'assistant', content: 'hi back', timestamp: Date.now() },
];
const { ctx } = makeCtx(msgs, [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ messages: unknown[]; truncated: boolean }> };
};
const session = result.data.sessions[0];
expect(session.messages).toEqual(msgs);
expect(session.truncated).toBe(false);
});
it('全量导出:条数达到上限时标记 truncated=true(防 Blob 序列化 OOM', async () => {
const { ctx, sessionService } = makeCtx([], [{ id: 's1', title: 't' }]);
// 构造恰好 2000 条消息(达到上限 → truncated=true
const many = Array.from({ length: 2000 }, (_, i) => ({
id: `m${i}`,
role: 'user',
content: 'x',
}));
sessionService.getMessages.mockImplementation(() => many);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ truncated: boolean }> };
};
expect(result.data.sessions[0].truncated).toBe(true);
});
it('全量导出配置脱敏不包含明文 API Key 子串', async () => {
const { ctx } = makeCtx([], [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { config: Record<string, unknown> };
};
expect(JSON.stringify(result.data.config)).not.toContain('sk-raw-1234');
});
it('单会话导出不返回 truncated 标记(仅全量导出带)', async () => {
const msgs = [{ id: 'm1', role: 'user', content: 'x' }];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
success: boolean;
data: unknown[];
};
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
});
it('toolResult.result 为字符串时不剥离(dataUrl 剥离仅针对对象)', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: 'plain string result' },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: string } }>;
};
expect(result.data[0].toolResult.result).toBe('plain string result');
});
it('toolResult.result 为数组(非对象)不剥离', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: [{ path: 'a.png', dataUrl: 'x' }] },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: Array<{ dataUrl: string }> } }>;
};
expect(result.data[0].toolResult.result).toHaveLength(1);
expect(result.data[0].toolResult.result[0].dataUrl).toBe('x');
});
});