P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
171 lines
7.4 KiB
TypeScript
171 lines
7.4 KiB
TypeScript
/**
|
||
* HTTP 请求工具(1 个)
|
||
*
|
||
* http_request — 发送 HTTP/REST API 请求
|
||
*
|
||
* 使用 Node.js 18+ 内置 fetch API。
|
||
* 响应体截断到 50KB 防止结果过大。
|
||
*
|
||
* #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。
|
||
*/
|
||
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
|
||
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
|
||
// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
|
||
// 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding
|
||
// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。
|
||
import { validateSSRF } from './ssrf-guard';
|
||
import { ssrfPinnedFetch } from './ssrf-dispatcher';
|
||
|
||
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
||
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
||
|
||
/**
|
||
* #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址
|
||
*
|
||
* v0.6.4: 实现迁移到共享模块 ssrf-guard.ts(isPrivateIP / validateSSRF),
|
||
* 本文件仅保留使用方。实现细节与覆盖范围见 ssrf-guard.ts 注释:
|
||
* - IPv4: 127/8、10/8、192.168/16、172.16-31、169.254/16(云元数据)、0/8、224+/4
|
||
* - IPv6: ::1、fe80::/10、fc00::/7、::ffff: 映射 v4(递归检测)
|
||
*/
|
||
|
||
/**
|
||
* 审查修复 (M7) — 已知限制:DNS rebinding 窗口
|
||
* ---------------------------------------------------------------
|
||
* validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析,
|
||
* 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换
|
||
* DNS 记录到内网 IP)。
|
||
*
|
||
* 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname),
|
||
* 但在 Node.js fetch 实现下不可行:
|
||
* 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN,
|
||
* 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。
|
||
* 2. Node fetch 将 Host 列为 forbidden header,无法通过设置
|
||
* Host header 保留原始域名。
|
||
* 3. fetch API 不暴露 SNI 自定义入口。
|
||
*
|
||
* 当前实现的缓解措施:
|
||
* - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过)
|
||
* - redirect: 'manual' 禁用自动重定向(防重定向到内网)
|
||
* - web_fetch 场景下对重定向终态 URL 复检(v0.6.4)
|
||
*/
|
||
|
||
export class HttpRequestTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'http_request',
|
||
description:
|
||
'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
url: { type: 'string', description: 'Request URL (must start with http:// or https://)' },
|
||
method: {
|
||
type: 'string',
|
||
description: 'HTTP method (default GET)',
|
||
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'],
|
||
},
|
||
headers: { type: 'object', description: 'Request headers as key-value pairs' },
|
||
body: { type: 'string', description: 'Request body (string)' },
|
||
timeout: {
|
||
type: 'number',
|
||
description: 'Timeout in milliseconds (default 30000, max 60000)',
|
||
},
|
||
},
|
||
required: ['url'],
|
||
},
|
||
category: MetonaToolCategory.NETWORK,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: 30_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const url = args.url as string;
|
||
const method = ((args.method as string) ?? 'GET').toUpperCase();
|
||
const headers = (args.headers as Record<string, string> | undefined) ?? undefined;
|
||
const body = args.body as string | undefined;
|
||
const timeout = Math.min(60_000, Math.max(1, (args.timeout as number) ?? 30_000));
|
||
|
||
// 校验 URL
|
||
if (!url || !/^https?:\/\//i.test(url)) {
|
||
return { error: 'Invalid URL', success: false };
|
||
}
|
||
|
||
// #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址
|
||
try {
|
||
await validateSSRF(url);
|
||
} catch (ssrfErr) {
|
||
return { error: (ssrfErr as Error).message, success: false };
|
||
}
|
||
|
||
// 校验 method
|
||
if (!(ALLOWED_METHODS as readonly string[]).includes(method)) {
|
||
return {
|
||
error: `Invalid method: ${method}. Must be one of: ${ALLOWED_METHODS.join(', ')}`,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT;
|
||
// 工具执行层的 abort signal 经 context 传入 registry 兜底)
|
||
{
|
||
const fetchOptions: RequestInit = {
|
||
method,
|
||
headers,
|
||
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
||
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
||
redirect: 'manual',
|
||
};
|
||
// GET/HEAD 不应携带 body
|
||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||
fetchOptions.body = body;
|
||
}
|
||
|
||
// v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
|
||
// 关闭校验-连接之间的 DNS rebinding 窗口
|
||
// v0.8.0 P1-2: 第 4 参透传引擎级 abort signal —— 用户中断会话时底层
|
||
// fetch 立即取消(此前最长 60s 资源悬挂)
|
||
const response = await ssrfPinnedFetch(url, fetchOptions, timeout, context.signal);
|
||
const text = await response.text();
|
||
|
||
// 截断到 50KB
|
||
const truncated = text.length > MAX_BODY_BYTES;
|
||
const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text;
|
||
|
||
// 只返回 content-type 和 content-length
|
||
// 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标
|
||
const filteredHeaders: Record<string, string> = {};
|
||
const contentType = response.headers.get('content-type');
|
||
if (contentType) filteredHeaders['content-type'] = contentType;
|
||
const contentLength = response.headers.get('content-length');
|
||
if (contentLength) filteredHeaders['content-length'] = contentLength;
|
||
const location = response.headers.get('location');
|
||
if (location) filteredHeaders['location'] = location;
|
||
|
||
return {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
headers: filteredHeaders,
|
||
body: safeBody,
|
||
truncated,
|
||
ok: response.ok,
|
||
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
|
||
};
|
||
}
|
||
} catch (error) {
|
||
// 区分超时与其他网络错误:AbortError(外部中断)与
|
||
// ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义
|
||
const err = error as Error & { code?: string };
|
||
if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') {
|
||
return { error: 'Request timeout', success: false };
|
||
}
|
||
const errMsg = error instanceof Error ? error.message : String(error);
|
||
return { error: errMsg, success: false };
|
||
}
|
||
}
|
||
}
|