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
@@ -0,0 +1,282 @@
/**
* http_request 工具测试(v0.7.5 新建覆盖)
*
* 通过 mock ssrf-guardvalidateSSRF)与 ssrf-dispatcherssrfPinnedFetch)锁定:
* - 6 方法白名单 / 非法方法拒绝
* - GET/HEAD 不携带 bodyPOST/PUT/PATCH/DELETE 携带
* - SSRF 拦截 / 非法 URL
* - 响应截断 50KB / 头部过滤(仅 content-type/content-length/location
* - 超时转译(AbortError/ETIMEDOUT → Request timeout
* - redirect manual 语义
*/
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() },
}));
const ssrfMock = vi.hoisted(() => ({
validateSSRF: vi.fn(async () => undefined),
}));
vi.mock('../ssrf-guard', () => ({
validateSSRF: ssrfMock.validateSSRF,
}));
const dispatcherMock = vi.hoisted(() => ({
ssrfPinnedFetch: vi.fn(),
}));
vi.mock('../ssrf-dispatcher', () => ({
ssrfPinnedFetch: dispatcherMock.ssrfPinnedFetch,
}));
import { HttpRequestTool } from '../http-request';
import type { ToolExecutionContext } from '../../../types/metona-tool';
const context: ToolExecutionContext = {
sessionId: 't',
workspacePath: process.cwd(),
iteration: 1,
requestId: 'r',
};
interface HttpResult {
success: boolean;
error?: string;
status?: number;
statusText?: string;
headers?: Record<string, string>;
body?: string;
truncated?: boolean;
ok?: boolean;
}
function makeResponse(
body: string,
init?: { status?: number; statusText?: string; headers?: Record<string, string> },
): Response {
return new Response(body, init);
}
describe('http_request — 入口校验', () => {
let tool: HttpRequestTool;
beforeEach(() => {
tool = new HttpRequestTool();
vi.clearAllMocks();
ssrfMock.validateSSRF.mockReset().mockImplementation(async () => undefined);
dispatcherMock.ssrfPinnedFetch.mockReset();
});
afterEach(() => vi.clearAllMocks());
it('非法 URL(非 http/https)→ Invalid URL', async () => {
const r = (await tool.execute({ url: 'file:///etc/passwd' }, context)) as HttpResult;
expect(r.success).toBe(false);
expect(r.error).toBe('Invalid URL');
});
it('缺 url → Invalid URL', async () => {
const r = (await tool.execute({}, context)) as HttpResult;
expect(r.success).toBe(false);
expect(r.error).toBe('Invalid URL');
});
it('SSRF 校验失败 → 拒绝', async () => {
ssrfMock.validateSSRF.mockRejectedValueOnce(
new Error('Blocked SSRF: private/loopback address'),
);
const r = (await tool.execute({ url: 'http://127.0.0.1:8080/x' }, context)) as HttpResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Blocked SSRF');
expect(dispatcherMock.ssrfPinnedFetch).not.toHaveBeenCalled();
});
it('非法 method(大写归一后仍不在白名单)→ 拒绝', async () => {
const r = (await tool.execute(
{ url: 'https://a.test/', method: 'OPTIONS' },
context,
)) as HttpResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Invalid method');
});
it('method 小写自动归一为大写(post → POST 合法)', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok', { status: 200 }));
const r = (await tool.execute(
{ url: 'https://a.test/', method: 'post' },
context,
)) as HttpResult;
expect(r.success).toBe(true);
expect(dispatcherMock.ssrfPinnedFetch).toHaveBeenCalledTimes(1);
});
it('6 个白名单方法全部放行', async () => {
// 每次调用生成全新 Response(避免 body 消费后复用报错)
dispatcherMock.ssrfPinnedFetch.mockImplementation(async () =>
makeResponse('ok', { status: 200 }),
);
for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']) {
const r = (await tool.execute({ url: 'https://a.test/', method }, context)) as HttpResult;
expect(r.success, `method ${method}`).toBe(true);
}
});
it('缺省 method 为 GET', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('get-default', { status: 200 }));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(true);
expect(r.body).toBe('get-default');
});
});
describe('http_request — 请求构造与响应处理', () => {
let tool: HttpRequestTool;
beforeEach(() => {
tool = new HttpRequestTool();
vi.clearAllMocks();
ssrfMock.validateSSRF.mockReset().mockImplementation(async () => undefined);
dispatcherMock.ssrfPinnedFetch.mockReset();
});
afterEach(() => vi.clearAllMocks());
it('POST 携带 bodyGET/HEAD 不携带 body', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
await tool.execute({ url: 'https://a.test/', method: 'POST', body: 'payload' }, context);
const postInit = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
expect(postInit.body).toBe('payload');
await tool.execute({ url: 'https://a.test/', method: 'GET', body: 'should-drop' }, context);
const getInit = dispatcherMock.ssrfPinnedFetch.mock.calls[1][1] as RequestInit;
expect(getInit.body).toBeUndefined();
await tool.execute({ url: 'https://a.test/', method: 'HEAD', body: 'should-drop' }, context);
const headInit = dispatcherMock.ssrfPinnedFetch.mock.calls[2][1] as RequestInit;
expect(headInit.body).toBeUndefined();
});
it('redirect 固定为 manual(防重定向绕过 SSRF', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
await tool.execute({ url: 'https://a.test/' }, context);
const init = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
expect(init.redirect).toBe('manual');
});
it('自定义 headers 透传', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
await tool.execute(
{ url: 'https://a.test/', headers: { 'X-Custom': 'v1', Authorization: 'Bearer t' } },
context,
);
const init = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
expect(init.headers).toEqual({ 'X-Custom': 'v1', Authorization: 'Bearer t' });
});
it('响应截断到 50KB 并标记 truncated', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('y'.repeat(100_000)));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(true);
expect(r.truncated).toBe(true);
expect(r.body?.length).toBe(50 * 1024);
});
it('小响应不截断', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('small'));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.truncated).toBe(false);
expect(r.body).toBe('small');
});
it('头部过滤:仅保留 content-type/content-length/location', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(
makeResponse('body', {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Length': '4',
Location: 'https://next.test/',
'X-Secret': 'leak',
'Set-Cookie': 'sess=1',
},
}),
);
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.headers).toEqual({
'content-type': 'application/json',
'content-length': '4',
location: 'https://next.test/',
});
});
it('无额外头时只回传自动生成的 content-type(无 X-* 泄露)', async () => {
dispatcherMock.ssrfPinnedFetch.mockImplementation(async () =>
makeResponse('x', { status: 200 }),
);
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
// Response 构造器自动生成 text/plain content-type;其余自定义头一律不泄露
expect(r.headers?.['content-type']).toBeDefined();
expect(Object.keys(r.headers ?? {}).every((k) => !k.toLowerCase().startsWith('x-'))).toBe(true);
});
it('3xx 重定向状态透传 + location 头部', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(
makeResponse('', {
status: 302,
statusText: 'Found',
headers: { Location: 'https://n.test/' },
}),
);
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(true);
expect(r.status).toBe(302);
expect(r.statusText).toBe('Found');
expect(r.ok).toBe(false);
expect(r.headers?.location).toBe('https://n.test/');
});
it('超时(AbortError)→ Request timeout', async () => {
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(
Object.assign(new Error('aborted'), { name: 'AbortError' }),
);
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(false);
expect(r.error).toBe('Request timeout');
});
it('超时(ETIMEDOUT)→ Request timeout', async () => {
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(
Object.assign(new Error('Request timed out after 30000ms'), { code: 'ETIMEDOUT' }),
);
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(false);
expect(r.error).toBe('Request timeout');
});
it('其他网络错误原样回传', async () => {
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(false);
expect(r.error).toBe('ECONNREFUSED');
});
it('timeout 参数钳制(>60s 压到 60s<1ms 抬到 1ms', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
await tool.execute({ url: 'https://a.test/', timeout: 999_999 }, context);
expect(dispatcherMock.ssrfPinnedFetch.mock.calls[0][2]).toBe(60_000);
await tool.execute({ url: 'https://a.test/', timeout: 0 }, context);
expect(dispatcherMock.ssrfPinnedFetch.mock.calls[1][2]).toBe(1);
});
it('非 2xx 状态仍返回 success=true(透传状态码,语义:请求本身成功)', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('err-body', { status: 500 }));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(true);
expect(r.status).toBe(500);
expect(r.ok).toBe(false);
});
it('空 body 响应正常返回空串', async () => {
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(new Response(null, { status: 204 }));
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
expect(r.success).toBe(true);
expect(r.body).toBe('');
});
});