Files
metona-ai-desktop/electron/harness/tools/built-in/http-request.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
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 跳过
2026-09-05 20:06:26 +08:00

171 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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),校验与连接共用同一批 IPDNS 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.tsisPrivateIP / 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(外部中断)与
// ETIMEDOUTssrfPinnedFetch 超时转译,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 };
}
}
}