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
@@ -1,26 +1,82 @@
/**
* SSRF DNS Pinning 测试(v0.7.3 P2-1
* SSRF DNS Pinning 测试(v0.7.3 P2-1 → v0.7.5 扩充
*
* 锁定三个单元:
* 锁定单元:
* D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family),
* 空集合返回 ENOTFOUND(防御)。
* D2 resolveRedirectTarget —— 重定向状态识别 + 相对 Location 解析 +
* D2 resolveRedirectTarget —— 重定向状态识别 + 相对/绝对/协议相对 Location 解析 +
* 非法/缺失 Location 返回 null。
* D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源
* 域名解析路径由 ssrf-guard 表测覆盖,此处不重复触网)。
* D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源)。
* D4 ssrfPinnedFetch —— 代理激活退化 / 外部信号中止 / 超时转译 ETIMEDOUT /
* 正常路径走 pinned undici Agent。
*/
import { describe, it, expect } from 'vitest';
import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps } from '../ssrf-dispatcher';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ===== DNS 表驱动(resolvePublicAddresses 依赖)=====
const dnsTable: Record<string, Array<{ address: string; family: number }>> = {
'public.example.com': [{ address: '93.184.216.34', family: 4 }],
'nx.example.com': [],
};
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(async (hostname: string) => {
if (!(hostname in dnsTable)) {
throw Object.assign(new Error(`ENOTFOUND ${hostname}`), { code: 'ENOTFOUND' });
}
return dnsTable[hostname];
}),
}));
// ===== 网络代理状态可控 mock(注意相对路径从 __tests__ 到 electron/utils=====
const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) }));
vi.mock('../../../../utils/network-proxy', () => ({
isProxyActive: proxyMock.isProxyActive,
}));
// ===== undici 可控 mockAgent + fetch=====
const undiciMock = vi.hoisted(() => {
const agentInstances: Array<{ closed: boolean; options: unknown }> = [];
class FakeAgent {
closed = false;
constructor(public options: unknown) {
agentInstances.push(this);
}
async close(): Promise<void> {
this.closed = true;
}
}
const fetch = vi.fn();
return { agentInstances, FakeAgent, fetch };
});
vi.mock('undici', () => ({
Agent: undiciMock.FakeAgent,
fetch: undiciMock.fetch,
}));
import {
createPinnedLookup,
resolveRedirectTarget,
resolvePinnedIps,
ssrfPinnedFetch,
} from '../ssrf-dispatcher';
import type { LookupCallback } from '../ssrf-dispatcher';
describe('createPinnedLookup', () => {
function runLookup(lookup: (h: string, o: unknown, cb: LookupCallback) => void, host = 'h') {
return new Promise<{ address: string; family: number }[]>((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
lookup(host, {}, cb);
});
}
it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => {
const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']);
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
lookup('attacker.example', {}, cb);
});
const result = await runLookup(lookup, 'attacker.example');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 });
expect(result[1].family).toBe(6);
@@ -28,22 +84,27 @@ describe('createPinnedLookup', () => {
it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => {
const lookup = createPinnedLookup(['not-an-ip']);
await expect(
new Promise((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
lookup('h', {}, cb as never);
}),
).rejects.toMatchObject({ code: 'ENOTFOUND' });
await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' });
});
it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher', async () => {
const lookup = createPinnedLookup([]);
await expect(
new Promise((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
lookup('h', {}, cb as never);
}),
).rejects.toMatchObject({ code: 'ENOTFOUND' });
await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' });
});
it('D1: 混合合法 IP 与非法字符串 → 仅返回合法 IP', async () => {
const lookup = createPinnedLookup(['8.8.8.8', 'garbage', '::1']);
const result = await runLookup(lookup);
expect(result).toEqual([
{ address: '8.8.8.8', family: 4 },
{ address: '::1', family: 6 },
]);
});
it('D1: hostname 参数完全被忽略(无论传什么都返回 pin 集合)', async () => {
const lookup = createPinnedLookup(['1.1.1.1']);
const result = await runLookup(lookup, 'evil-hostname.example');
expect(result).toEqual([{ address: '1.1.1.1', family: 4 }]);
});
});
@@ -69,9 +130,29 @@ describe('resolveRedirectTarget', () => {
);
});
it('D2: 相对 Location 不带前导斜杠 → 基于目录解析', () => {
expect(resolveRedirectTarget(makeResponse(302, 'next'), 'https://a.test/dir/page')).toBe(
'https://a.test/dir/next',
);
});
it('D2: 协议相对 Location//host/path)→ 沿用当前协议', () => {
expect(resolveRedirectTarget(makeResponse(302, '//cdn.example.com/x'), 'https://a.test/')).toBe(
'https://cdn.example.com/x',
);
});
it('D2: 带 fragment 的 Location 解析', () => {
expect(resolveRedirectTarget(makeResponse(301, '/new#section'), 'https://a.test/old')).toBe(
'https://a.test/new#section',
);
});
it('D2: 非 3xx 状态 → null(终态)', () => {
expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(300), 'https://a.test/')).toBeNull(); // 300 不在集合
expect(resolveRedirectTarget(makeResponse(304), 'https://a.test/')).toBeNull(); // 304 不在集合
});
it('D2: 缺失/非法 Location → null', () => {
@@ -79,6 +160,12 @@ describe('resolveRedirectTarget', () => {
expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull();
});
it('D2: Location 为纯路径但当前 URL 含 query → 解析后不丢 query', () => {
expect(resolveRedirectTarget(makeResponse(303, '/landing'), 'https://a.test/p?x=1')).toBe(
'https://a.test/landing',
);
});
});
describe('resolvePinnedIps', () => {
@@ -100,4 +187,100 @@ describe('resolvePinnedIps', () => {
it('D3: 非法 URL 被拒', async () => {
await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/);
});
it('D3: 公网域名解析返回 pin 集合', async () => {
const ips = await resolvePinnedIps('http://public.example.com/page');
expect(ips).toEqual(['93.184.216.34']);
});
it('D3: 无 DNS 记录 → 拒绝', async () => {
await expect(resolvePinnedIps('http://nx.example.com/')).rejects.toThrow(/no DNS records/);
});
});
describe('ssrfPinnedFetch — 代理退化 / 超时转译 / 用后即毁', () => {
beforeEach(() => {
proxyMock.isProxyActive.mockReturnValue(false);
undiciMock.fetch.mockReset();
undiciMock.agentInstances.length = 0; // Agent 实例列表按测试清零
});
afterEach(() => {
vi.unstubAllGlobals();
proxyMock.isProxyActive.mockReset();
});
it('D4: 代理激活 → 退化为普通 fetch(走全局 fetchWithTimeout,不构造 pinned Agent', async () => {
proxyMock.isProxyActive.mockReturnValue(true);
const glob = vi.fn(async () => new Response('via-proxy', { status: 200 }));
vi.stubGlobal('fetch', glob);
const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000);
expect(resp.status).toBe(200);
expect(glob).toHaveBeenCalledTimes(1);
expect(undiciMock.fetch).not.toHaveBeenCalled();
// 代理路径无 Agent 实例(无 pin 集合泄漏)
expect(undiciMock.agentInstances.length).toBe(0);
});
it('D4: 外部信号已中止 → 直接抛 AbortError(不发起请求)', async () => {
const controller = new AbortController();
controller.abort();
await expect(
ssrfPinnedFetch('http://public.example.com/', {}, 1000, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
expect(undiciMock.fetch).not.toHaveBeenCalled();
});
it('D4: 超时 → 转译为 ETIMEDOUT 错误', async () => {
undiciMock.fetch.mockImplementation(
(_url: string, init: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new Error('aborted by timeout'));
});
}),
);
await expect(ssrfPinnedFetch('http://public.example.com/', {}, 30)).rejects.toMatchObject({
code: 'ETIMEDOUT',
message: expect.stringContaining('timed out after 30ms'),
});
});
it('D4: 正常路径使用 pinned undici Agent(构造一次)且响应透传', async () => {
undiciMock.fetch.mockResolvedValue(new Response('pinned-ok', { status: 200 }));
const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000);
expect(resp.status).toBe(200);
expect(undiciMock.fetch).toHaveBeenCalledTimes(1);
expect(undiciMock.agentInstances.length).toBe(1);
});
it('D4: pinned Agent 的 connect.lookup 返回校验 IP 集合(pinning 契约)', async () => {
undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 }));
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
const agent = undiciMock.agentInstances[undiciMock.agentInstances.length - 1];
const connect = (
agent.options as { connect: { lookup: (h: string, o: unknown, cb: LookupCallback) => void } }
).connect;
expect(typeof connect.lookup).toBe('function');
const addresses = await new Promise<unknown>((resolve, reject) => {
connect.lookup('anything.example', {}, (err, addrs) => (err ? reject(err) : resolve(addrs)));
});
expect(addresses).toEqual([{ address: '93.184.216.34', family: 4 }]);
});
it('D4: 每次请求构造一次性 Agent,请求结束后关闭(用后即毁)', async () => {
undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 }));
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
const agents = undiciMock.agentInstances.slice(-2);
expect(agents.every((a) => a.closed)).toBe(true);
expect(agents.length).toBe(2); // 两个请求各自独立 Agent,不跨请求复用
});
it('D4: 私有 IP 目标在校验阶段被拒(不构造 Agent、不发请求)', async () => {
await expect(ssrfPinnedFetch('http://127.0.0.1:9999/x', {}, 1000)).rejects.toThrow(
/Blocked SSRF/,
);
expect(undiciMock.fetch).not.toHaveBeenCalled();
});
});