新增: AI 工具(download_file + http output_file + grep 窗口 + fetch_search 搜索 + get_app_config + generate_image)
download_file 跨平台 URL 到文件流式下载;http_request output_file 落盘 + 截断可配; grep context_chars 大单行窗口;fetch_search DuckDuckGo 免 key 搜索;get_app_config 只读返当前配置(治 AI 查配置绕 PowerShell);generate_image SenseNova 图像生成。
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
//! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10,
|
||||
//! 改用手动 redirect(Policy::none) + 自管循环。
|
||||
//! 5. **超时**:默认 30s,clamp ≤60s(对齐 run_command 思路,但封顶更紧——HTTP 调用应快速返回)。
|
||||
//! 6. **响应截断**:body ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。
|
||||
//! 6. **响应截断**:body 默认 ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。
|
||||
//! 截断阈值可经 `max_response_chars` 参数覆盖(clamp [1KB, 10MB]);或经 `output_file`
|
||||
//! 将响应全量落盘(不截断),body 字段置 null + path/bytes_written 返回。
|
||||
//!
|
||||
//! ## 风险分级
|
||||
//!
|
||||
@@ -26,12 +28,22 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 响应 body 截断上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。
|
||||
const MAX_BODY_BYTES: usize = 50 * 1024;
|
||||
use crate::state::AllowedDirs;
|
||||
|
||||
/// 响应 body 截断默认上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。
|
||||
///
|
||||
/// 2026-08-01:由硬编码改为默认值,LLM 可经 `max_response_chars` 参数覆盖(clamp [1024, 10MB])。
|
||||
/// 无 `max_response_chars` 时回退此默认(保旧行为零变更)。
|
||||
const DEFAULT_MAX_BODY_BYTES: usize = 50 * 1024;
|
||||
/// `max_response_chars` 参数 clamp 下限(字节)。防 LLM 传过小值截到无意义残片。
|
||||
const MIN_MAX_RESPONSE_CHARS: usize = 1024;
|
||||
/// `max_response_chars` 参数 clamp 上限(字节,10MB)。防 LLM 传超大值撑爆 LLM context。
|
||||
const MAX_MAX_RESPONSE_CHARS: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// 默认请求超时(秒)。LLM 可经 args timeout_secs 覆盖。
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
@@ -246,9 +258,18 @@ fn build_request(
|
||||
/// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传)
|
||||
/// - timeout_secs: 默认 30,clamp ≤60
|
||||
/// - parse: "json"|"text"|"auto"(默认 auto),控制 body 解析展示
|
||||
/// - output_file: string 可选,响应 body 写入指定文件路径(而非返回 JSON body),
|
||||
/// 适合大响应。路径走工具文件路径校验(validate_path + resolve_workspace_path_with_allowed
|
||||
/// + parent 授权目录校验,与 write_file 同源),全量写不截断。
|
||||
/// - max_response_chars: integer 可选,默认 50KB(51200),clamp [1024, 10MB]。
|
||||
/// body 截断阈值,替代硬编码上限。无 output_file 时控制返回 body 的截断;有 output_file 时不截断。
|
||||
///
|
||||
/// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)}
|
||||
pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> {
|
||||
/// `allowed`:授权目录快照(由 handler 闭包 read lock clone 传入),仅 output_file 落盘路径校验用。
|
||||
/// 无 output_file 时路径校验不触发,allowed 可为空快照(默认)。
|
||||
///
|
||||
/// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)}。
|
||||
/// 有 output_file 时:body 字段置 null(已落盘),增 path/bytes_written,且 truncated=false(全量写)。
|
||||
pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) -> anyhow::Result<Value> {
|
||||
// ── 参数解析 ──
|
||||
let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_uppercase();
|
||||
let method = match method_str.as_str() {
|
||||
@@ -286,6 +307,25 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> {
|
||||
.min(MAX_TIMEOUT_SECS)
|
||||
.max(1); // clamp [1, 60]
|
||||
let parse_mode = args.get("parse").and_then(|v| v.as_str()).unwrap_or("auto").to_lowercase();
|
||||
// output_file:响应 body 落盘路径(可选)。存在时走文件路径校验 + 全量写,不返回 body。
|
||||
let output_file = args.get("output_file").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
// max_response_chars:body 截断阈值(字节)。默认 DEFAULT_MAX_BODY_BYTES,clamp [1024, 10MB]。
|
||||
let max_response_chars = args.get("max_response_chars").and_then(|v| v.as_u64())
|
||||
.map(|n| (n as usize).clamp(MIN_MAX_RESPONSE_CHARS, MAX_MAX_RESPONSE_CHARS))
|
||||
.unwrap_or(DEFAULT_MAX_BODY_BYTES);
|
||||
// output_file 路径校验(若提供):validate_path + workspace 授权 + symlink 逃逸 +
|
||||
// parent 授权目录(落盘写需要,对齐 write_file FR-S8)。落盘时再次 resolve(复用同一校验)。
|
||||
if let Some(of) = &output_file {
|
||||
if of.trim().is_empty() {
|
||||
anyhow::bail!("output_file 不能为空");
|
||||
}
|
||||
let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?;
|
||||
if let Some(parent) = Path::new(&resolved).parent() {
|
||||
if !allowed.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外写入文件");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ──
|
||||
let (scheme, host, port) = validate_url(&url_raw)?;
|
||||
@@ -314,11 +354,55 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> {
|
||||
.or_insert(val);
|
||||
}
|
||||
|
||||
// body 读取 + 截断
|
||||
// body 读取
|
||||
let full_body = resp.bytes().await
|
||||
.map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?;
|
||||
let total_bytes = full_body.len();
|
||||
let (body_text, truncated) = truncate_body(&full_body, MAX_BODY_BYTES);
|
||||
|
||||
// ── output_file 分支:全量写盘,不截断,不返回 body ──
|
||||
if let Some(of) = &output_file {
|
||||
let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?;
|
||||
let path = resolved.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("output_file 路径含非法字符"))?
|
||||
.to_string();
|
||||
let target = Path::new(&path);
|
||||
// 父目录授权(对齐 write_file FR-S8)+ 自动创建(create_dir_all)
|
||||
if let Some(parent) = target.parent() {
|
||||
if !allowed.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外写入文件");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.map_err(|e| anyhow::anyhow!("创建输出目录失败: {}", e))?;
|
||||
}
|
||||
// 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路)
|
||||
let tmp = format!("{}.tmp-http", path);
|
||||
tokio::fs::write(&tmp, &full_body).await
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
anyhow::anyhow!("写入 output_file 失败: {}", e)
|
||||
})?;
|
||||
tokio::fs::rename(&tmp, &path).await
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
anyhow::anyhow!("output_file 原子重命名失败: {}", e)
|
||||
})?;
|
||||
return Ok(json!({
|
||||
"method": method_str,
|
||||
"url": final_url,
|
||||
"status": status,
|
||||
"status_text": status_text,
|
||||
"scheme": scheme,
|
||||
"headers": resp_headers,
|
||||
"body": null,
|
||||
"path": path,
|
||||
"bytes_written": total_bytes,
|
||||
"truncated": false,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 默认分支:截断 + parse 格式化 + 返回 body ──
|
||||
let (body_text, truncated) = truncate_body(&full_body, max_response_chars);
|
||||
|
||||
// parse 处理:json → pretty;auto → 尝试 json 失败回退 text;二进制(含 \0)跳过
|
||||
let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers);
|
||||
@@ -594,14 +678,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_handler_missing_url_errors() {
|
||||
let args = json!({ "method": "GET" });
|
||||
let err = execute_http_request(args).await.unwrap_err();
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("缺少 url"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_invalid_method_errors() {
|
||||
let args = json!({ "method": "TRACE", "url": "https://example.com/" });
|
||||
let err = execute_http_request(args).await.unwrap_err();
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("不支持的 method"));
|
||||
}
|
||||
|
||||
@@ -609,21 +693,21 @@ mod tests {
|
||||
async fn test_handler_rejects_localhost_url() {
|
||||
// SSRF 防护:handler 入口 validate_url 拒 localhost,不发请求
|
||||
let args = json!({ "method": "GET", "url": "http://localhost:8080/admin" });
|
||||
let err = execute_http_request(args).await.unwrap_err();
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_rejects_private_ip_url() {
|
||||
let args = json!({ "method": "GET", "url": "http://169.254.169.254/latest/meta-data/" });
|
||||
let err = execute_http_request(args).await.unwrap_err();
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_rejects_non_http_scheme() {
|
||||
let args = json!({ "url": "file:///etc/passwd" });
|
||||
let err = execute_http_request(args).await.unwrap_err();
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("协议"));
|
||||
}
|
||||
|
||||
@@ -632,7 +716,44 @@ mod tests {
|
||||
// timeout_secs=9999 应 clamp 到 60;此处只验证不因超大值 panic(handler 内会走网络,
|
||||
// 故本测试不期望成功完成——用 localhost 触发 SSRF 早期拒绝,验证 clamp 在拒绝前不 panic)
|
||||
let args = json!({ "url": "http://localhost/", "timeout_secs": 9999 });
|
||||
let _ = execute_http_request(args).await; // 不 panic 即通过
|
||||
let _ = execute_http_request(args, &AllowedDirs::default()).await; // 不 panic 即通过
|
||||
}
|
||||
|
||||
// ── output_file:路径校验拒绝(提前在 SSRF/请求前触发,不发网络) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_output_file_rejects_path_traversal() {
|
||||
// output_file 含 .. → validate_path 拒绝(在发请求前,故无需真实网络)
|
||||
let args = json!({
|
||||
"url": "https://example.com/",
|
||||
"output_file": "../escape.txt"
|
||||
});
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("路径遍历"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_output_file_rejects_empty() {
|
||||
let args = json!({
|
||||
"url": "https://example.com/",
|
||||
"output_file": " "
|
||||
});
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
assert!(format!("{}", err).contains("output_file 不能为空"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_output_file_rejects_relative_without_allowed() {
|
||||
// 相对路径 + 空白名单 → resolve_workspace_path_with_allowed 引导报错(在发请求前)
|
||||
let args = json!({
|
||||
"url": "https://example.com/",
|
||||
"output_file": "out.txt"
|
||||
});
|
||||
let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err();
|
||||
// 无授权目录时 resolve 报"请先绑定项目目录"
|
||||
let msg = format!("{}", err);
|
||||
assert!(msg.contains("绑定项目目录") || msg.contains("授权目录"),
|
||||
"期望授权引导错误,实际: {}", msg);
|
||||
}
|
||||
|
||||
// ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ──
|
||||
@@ -641,7 +762,7 @@ mod tests {
|
||||
#[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"]
|
||||
async fn integration_get_example_com_200() {
|
||||
let args = json!({ "method": "GET", "url": "https://example.com/" });
|
||||
let result = execute_http_request(args).await.expect("GET example.com 应成功");
|
||||
let result = execute_http_request(args, &AllowedDirs::default()).await.expect("GET example.com 应成功");
|
||||
let status = result["status"].as_u64().unwrap();
|
||||
assert!(status == 200, "期望 200,实际 {}", status);
|
||||
assert!(result["body"].as_str().unwrap_or("").contains("Example Domain"));
|
||||
@@ -649,13 +770,58 @@ mod tests {
|
||||
assert!(result["elapsed_ms"].as_u64().unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需真实网络,本地手跑"]
|
||||
async fn integration_max_response_chars_truncates() {
|
||||
// 默认 50KB 不截断 example.com(<1KB);max_response_chars=100 强制截断验证参数生效
|
||||
let args = json!({
|
||||
"url": "https://example.com/",
|
||||
"max_response_chars": 100
|
||||
});
|
||||
let result = execute_http_request(args, &AllowedDirs::default()).await
|
||||
.expect("GET example.com 应成功");
|
||||
assert_eq!(result["truncated"], true, "应被 max_response_chars 截断");
|
||||
// 默认(无 max_response_chars)不截断
|
||||
let args2 = json!({ "url": "https://example.com/" });
|
||||
let result2 = execute_http_request(args2, &AllowedDirs::default()).await.unwrap();
|
||||
assert_eq!(result2["truncated"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需真实网络 + 临时目录,本地手跑"]
|
||||
async fn integration_output_file_writes_body() {
|
||||
// output_file 落盘:body 应为 null,path/bytes_written 存在,truncated=false。
|
||||
// 路径校验需授权目录,故构建含 temp_dir 的 AllowedDirs(对齐 write_file 测试思路)。
|
||||
let dir = std::env::temp_dir();
|
||||
let out_path = dir.join("devflow_http_test_output.html");
|
||||
let out_str = out_path.to_string_lossy().to_string();
|
||||
let mut allowed = AllowedDirs::default();
|
||||
allowed.persistent.insert(dir.clone());
|
||||
let args = json!({
|
||||
"url": "https://example.com/",
|
||||
"output_file": out_str
|
||||
});
|
||||
let result = execute_http_request(args, &allowed).await
|
||||
.expect("GET example.com 应成功");
|
||||
assert_eq!(result["status"], 200);
|
||||
assert_eq!(result["body"], serde_json::Value::Null, "body 应置 null(已落盘)");
|
||||
assert_eq!(result["truncated"], false, "落盘不截断");
|
||||
let bytes_written = result["bytes_written"].as_u64().unwrap();
|
||||
assert!(bytes_written > 0, "应写入非零字节");
|
||||
assert_eq!(result["path"], out_str);
|
||||
// 校验文件内容确实是 example.com 响应
|
||||
let written = std::fs::read_to_string(&out_path).unwrap();
|
||||
assert!(written.contains("Example Domain"));
|
||||
let _ = std::fs::remove_file(&out_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需真实网络,本地手跑"]
|
||||
async fn integration_get_follows_redirect() {
|
||||
// httpbin.org/redirect-to 重定向到 example.com,验证重定向链 + SSRF 每跳校验通过
|
||||
let target = urlencoding("https://example.com/");
|
||||
let args = json!({ "url": format!("https://httpbin.org/redirect-to?url={}", target) });
|
||||
let result = execute_http_request(args).await.expect("重定向链应成功");
|
||||
let result = execute_http_request(args, &AllowedDirs::default()).await.expect("重定向链应成功");
|
||||
let status = result["status"].as_u64().unwrap_or(0);
|
||||
// 最终落地 200(经重定向)
|
||||
assert!(status >= 200 && status < 400, "重定向后状态: {}", status);
|
||||
|
||||
Reference in New Issue
Block a user