新增: http_request AI工具(结构化HTTP·SSRF防御·High审批)

- 后端: Cargo.toml reqwest0.12(json/gzip/brotli native-tls对齐df-ai) + http.rs(handler+SSRF三层防御 validate_url/resolve_and_check/重定向≤3跳每跳校验) + tool_registry register_http_tools + 基线28→29
- 前端: useToolCard formatHttpResult + ToolCard渲染(method/url/status/body折叠/截断) + HIGH_RISK_TOOLS + confirmHighHttp + i18n
- SSRF: localhost/私有IP(127/10/172.16/192.168/169.254云元数据/100.64CGNAT/≥224/IPv6保留)拒绝 + DNS resolve校验防重绑定 + 重定向每跳重校验
- 审批: High(统一保守 GET也审批 防漏写操作)
- 截断: 响应50KB(T-05)+ 请求1MB + 超时1-60s默认30
This commit is contained in:
2026-06-19 12:48:28 +08:00
parent b42cc66e7a
commit f1a06732fd
8 changed files with 904 additions and 5 deletions

View File

@@ -42,3 +42,7 @@ percent-encoding = "2"
# src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。 # src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md # 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
keyring = { workspace = true } keyring = { workspace = true }
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }

View File

@@ -0,0 +1,673 @@
//! http_request AI 工具 — 结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE)
//!
//! 设计目标:让 LLM 能查询外部 API(天气/汇率/文档/第三方服务),代替裸 run_command curl。
//! 相比 run_command:① 结构化输入(method/url/headers/body)避免 shell 注入;② 无 shell 进程
//! 开销;③ 显式 SSRF 防护(run_command 靠人工审批兜底,本工具因可被 LLM 自动触发 GET 需机器防护)。
//!
//! ## 安全(SSRF 防护是核心)
//!
//! 1. **协议白名单**:仅 http/https,拒 file/ftp/gopher/data 等(reqwest 默认接 file://,故显式挡)。
//! 2. **私网 IP 黑名单**:拒 localhost/127.0.0.1/10.x/172.16-31.x/192.168.x/169.254.x/::1/fc00::/7。
//! 3. **DNS resolve 后校验 IP**(防 DNS rebinding):词法层校验主机名后,再解析其真实 IP 校验,
//! 防 `evil.com` 解析到 127.0.0.1。
//! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10,
//! 改用手动 redirect(Policy::none) + 自管循环。
//! 5. **超时**:默认 30s,clamp ≤60s(对齐 run_command 思路,但封顶更紧——HTTP 调用应快速返回)。
//! 6. **响应截断**:body ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。
//!
//! ## 风险分级
//!
//! - GET → Medium:只读,但触发外发网络请求(数据可经 URL query 泄露),需可见。
//! - POST/PUT/PATCH/DELETE + body → High:写操作/副作用,须人工批准。
//!
//! 注意:HTTP 无 cookie/认证复用(每次新 Client);headers 由 LLM 显式传(含 Authorization)。
//! 本工具不读环境变量 proxy(避免意外的内网 proxy SSRF 中转)— reqwest 默认尊重 HTTP_PROXY/HTTPS_PROXY
//! 环境变量,桌面应用场景用户期望尊重系统代理,保留默认行为(用户主动配置的代理非 SSRF 攻击向量)。
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
/// 响应 body 截断上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。
const MAX_BODY_BYTES: usize = 50 * 1024;
/// 默认请求超时(秒)。LLM 可经 args timeout_secs 覆盖。
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 超时硬上限(秒)。防 LLM 传超大值冻结会话(对齐 run_command MAX 思路,封顶更紧)。
const MAX_TIMEOUT_SECS: u64 = 60;
/// 重定向最大跳转数。防开放重定向被利用做 SSRF 中转(302 链绕过到内网)。
const MAX_REDIRECTS: usize = 3;
/// body/headers 各自大小上限(防 LLM 传超大请求体)。
const MAX_REQUEST_BODY_BYTES: usize = 1 * 1024 * 1024; // 1MB
/// 私网/保留 IP 段判定 — SSRF 防护核心。
///
/// 拒绝所有 RFC1918 私网 + 链路本地 + 环回 + 未指定 + IPv6 私网。词法层与 DNS 解析后都走此函数,
/// 双重校验防 DNS rebinding(词法层过但解析后命中私网 IP 仍挡)。
///
/// 注:`is_global()` (std nightly-gated)在 stable 不可用,故手工列举保留段(覆盖 OWASP SSRF 常见面)。
pub(crate) fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let oct = v4.octets();
// 环回 127.0.0.0/8(整段,非仅 127.0.0.1)
oct[0] == 127
// 0.0.0.0/8(本机/未指定,常被 SSRF 用于绕过)
|| oct[0] == 0
// 10.0.0.0/8(RFC1918 A 类私网)
|| oct[0] == 10
// 172.16.0.0/12(RFC1918 B 类私网:172.16.x - 172.31.x)
|| (oct[0] == 172 && (16..=31).contains(&oct[1]))
// 192.168.0.0/16(RFC1918 C 类私网)
|| (oct[0] == 192 && oct[1] == 168)
// 169.254.0.0/16(链路本地,云元数据服务 169.254.169.254 高危 SSRF 目标)
|| (oct[0] == 169 && oct[1] == 254)
// 100.64.0.0/10(CGNAT 共享地址,部分云内网用)
|| (oct[0] == 100 && (64..=127).contains(&oct[1]))
// 224.0.0.0/4(组播)/ 240.0.0.0/4(保留),非单播不应作请求目标
|| oct[0] >= 224
}
IpAddr::V6(v6) => {
// ::1 环回 / :: 未指定 / fc00::/7 唯一本地(私网) / fe80::/10 链路本地
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.octets()[0] & 0xFE) == 0xFC // fc00::/7(首字节高 7 位 1111110)
|| (v6.octets()[0] == 0xFE && (v6.octets()[1] & 0xC0) == 0x80) // fe80::/10
}
}
}
/// 校验 URL:协议白名单 + 词法层 host 私网拦截。
///
/// 返回解析后的 (scheme, host, port)。DNS 解析后的 IP 校验在 `resolve_and_check_host` 做
/// (词法层只挡字面量 IP/localhost,DNS rebinding 靠 resolve 后校验补防)。
///
/// 安全顺序:① scheme 白名单 ② host 非空 ③ 字面量 IP 私网拦截 ④ localhost/.*local 等域名拦截。
fn validate_url(raw: &str) -> anyhow::Result<(String, String, u16)> {
let parsed = reqwest::Url::parse(raw)
.map_err(|e| anyhow::anyhow!("URL 解析失败: {} ({})", raw, e))?;
let scheme = parsed.scheme().to_lowercase();
if scheme != "http" && scheme != "https" {
anyhow::bail!(
"不支持的协议 '{}':仅允许 http/https(拒 file/ftp/gopher/data 等)",
scheme
);
}
let host = parsed.host_str()
.ok_or_else(|| anyhow::anyhow!("URL 缺少 host: {}", raw))?
.to_lowercase();
if host.is_empty() {
anyhow::bail!("URL host 为空: {}", raw);
}
// 词法层:拒 localhost / *.localhost / IP 字面量私网(快路径挡常见 SSRF)
if host == "localhost" || host.ends_with(".localhost") {
anyhow::bail!("拒绝访问 localhost(SSRF 防护)");
}
// 字面量 IP 校验(host 形如 "127.0.0.1" / "[::1]")
let bare_host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = bare_host.parse::<IpAddr>() {
if is_private_ip(&ip) {
anyhow::bail!("拒绝访问私网/保留 IP: {} (SSRF 防护)", ip);
}
}
let port = parsed.port_or_known_default().unwrap_or(if scheme == "https" { 443 } else { 80 });
Ok((scheme, host, port))
}
/// DNS 解析 host 后校验所有解析 IP 非私网(防 DNS rebinding)。
///
/// 词法层只挡字面量 IP;攻击者注册 `evil.com` 解析到 127.0.0.1 时词法层过,必须 resolve 后再校验。
/// 多 A 记录全部校验(任一私网即拒,防混合记录绕过)。
///
/// 注:用 blocking resolve(tokio::net 已在 runtime,但单次 resolve 短且同步 DNS 系统调用,
/// spawn_blocking 会增加调度开销)。直接用 tokio::net::lookup_host 异步解析。
async fn resolve_and_check_host(host: &str, port: u16) -> anyhow::Result<Vec<std::net::SocketAddr>> {
// lookup_host 需 host:port 形式;host 可能含 IPv6 字面量,SocketAddr::to_string 会自动加 []
let target = format!("{}:{}", host, port);
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
.await
.map_err(|e| anyhow::anyhow!("DNS 解析失败 {}: {}", host, e))?
.collect();
if addrs.is_empty() {
anyhow::bail!("DNS 解析无结果: {}", host);
}
for addr in &addrs {
let ip = addr.ip();
if is_private_ip(&ip) {
anyhow::bail!(
"拒绝访问: {} 解析到私网/保留 IP {} (SSRF 防护,DNS rebinding 拦截)",
host, ip
);
}
}
Ok(addrs)
}
/// 构建限制重定向的 reqwest Client(手动接管重定向以每跳校验 SSRF)。
///
/// reqwest 默认 follow ≤10 重定向且不暴露每跳 URL 校验钩子,故 Policy::none 关闭自动跟随,
/// 在 execute_with_redirects 循环里逐跳校验 Location。
fn build_client(timeout: Duration) -> anyhow::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none()) // 手动接管重定向(每跳 SSRF 校验)
.build()
.map_err(|e| anyhow::anyhow!("HTTP client 构建失败: {}", e))
}
/// 执行请求 + 手动重定向循环(每跳 SSRF 校验 Location)。
///
/// 返回最终响应(reqwest::Response)。重定向链每跳重新 validate_url + resolve_and_check_host,
/// 防 302 Location: http://127.0.0.1/ 绕过初始 URL 校验。
async fn execute_with_redirects(
client: &reqwest::Client,
method: reqwest::Method,
initial_url: String,
headers: &HashMap<String, String>,
body: &Option<String>,
max_redirects: usize,
) -> anyhow::Result<reqwest::Response> {
let mut current_url = initial_url;
let mut hops = 0usize;
loop {
let resp = build_request(client, method.clone(), &current_url, headers, body)?
.send()
.await
.map_err(|e| anyhow::anyhow!("请求失败 {}: {}", current_url, e))?;
if resp.status().is_redirection() {
hops += 1;
if hops > max_redirects {
anyhow::bail!(
"重定向超过 {} 跳上限(SSRF 防护),最后状态: {}",
max_redirects, resp.status()
);
}
let location = resp.headers().get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow::anyhow!("重定向 {} 缺 Location 头", resp.status()))?;
// 相对 Location 拼 base(current_url);绝对 Location 直接用
let next = if location.starts_with("http://") || location.starts_with("https://") {
location.to_string()
} else {
let base = reqwest::Url::parse(&current_url)
.map_err(|e| anyhow::anyhow!("当前 URL 解析失败({}): {}", current_url, e))?;
base.join(location)
.map_err(|e| anyhow::anyhow!("重定向 Location 拼接失败({}): {}", location, e))?
.to_string()
};
// 每跳重新走完整 SSRF 校验(协议 + 词法 + DNS 解析 IP)
let (_scheme, host, port) = validate_url(&next)?;
resolve_and_check_host(&host, port).await?;
current_url = next;
continue; // 重试同 method?标准 301/302/303 改 GET,307/308 保 method。
// 为简化 + 安全(POST→GET 跟随会丢 body 改语义,易致 SSRF 探测),
// 本工具统一保 method 跟随(对 303 严格不符 RFC,但安全更稳;正常 API 重定向少用 303)。
}
return Ok(resp);
}
}
/// 构建单个 reqwest::RequestBuilder(共享 method/url/headers/body 构建逻辑,重定向循环复用)。
fn build_request(
client: &reqwest::Client,
method: reqwest::Method,
url: &str,
headers: &HashMap<String, String>,
body: &Option<String>,
) -> anyhow::Result<reqwest::RequestBuilder> {
let mut req = client.request(method, url);
for (k, v) in headers {
// header 名/值用 HeaderName/HeaderValue 校验,非法字符直接报错(不静默丢)
let name = reqwest::header::HeaderName::from_bytes(k.as_bytes())
.map_err(|e| anyhow::anyhow!("非法 header 名 '{}': {}", k, e))?;
let value = reqwest::header::HeaderValue::from_str(v)
.map_err(|e| anyhow::anyhow!("header '{}' 值含非法字符: {}", k, e))?;
req = req.header(name, value);
}
if let Some(b) = body {
// body 大小校验(防 LLM 传超大请求体)
if b.len() > MAX_REQUEST_BODY_BYTES {
anyhow::bail!("请求体超过 {} 字节上限", MAX_REQUEST_BODY_BYTES);
}
req = req.body(b.clone());
}
Ok(req)
}
/// http_request 工具 handler 入口(供 tool_registry.rs register 调用)。
///
/// 参数:
/// - method: GET/POST/PUT/PATCH/DELETE(默认 GET,非法值报错)
/// - url: 必填,http/https
/// - headers: map<string,string>,可选
/// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传)
/// - timeout_secs: 默认 30,clamp ≤60
/// - parse: "json"|"text"|"auto"(默认 auto),控制 body 解析展示
///
/// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)}
pub(crate) async fn execute_http_request(args: Value) -> 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() {
"GET" => reqwest::Method::GET,
"POST" => reqwest::Method::POST,
"PUT" => reqwest::Method::PUT,
"PATCH" => reqwest::Method::PATCH,
"DELETE" => reqwest::Method::DELETE,
other => anyhow::bail!("不支持的 method '{}'(仅 GET/POST/PUT/PATCH/DELETE)", other),
};
let url_raw = args.get("url").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))?
.trim()
.to_string();
if url_raw.is_empty() {
anyhow::bail!("url 不能为空");
}
// headers: map<string,string>。LLM 可能传非 string 值(数字/bool),尽力转 string 容错。
let headers: HashMap<String, String> = match args.get("headers") {
Some(Value::Object(m)) => m.iter()
.filter_map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
Some((k.clone(), s))
})
.collect(),
_ => HashMap::new(),
};
// body: 仅 string(LLM 传 JSON 字符串原样发,不替它序列化 — 透明转发,headers Content-Type 由 LLM 定)
let body = args.get("body").and_then(|v| v.as_str()).map(|s| s.to_string());
let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.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();
// ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ──
let (scheme, host, port) = validate_url(&url_raw)?;
resolve_and_check_host(&host, port).await?;
// ── 构建限制 client + 执行(手动重定向循环) ──
let client = build_client(Duration::from_secs(timeout_secs))?;
let started = Instant::now();
let resp = execute_with_redirects(
&client, method, url_raw.clone(), &headers, &body, MAX_REDIRECTS,
).await?;
let elapsed_ms = started.elapsed().as_millis() as u64;
// ── 响应组装 ──
let status = resp.status().as_u16();
let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
let final_url = resp.url().to_string();
// headers 提取(多值合并逗号分隔)
let mut resp_headers: HashMap<String, String> = HashMap::new();
for (k, v) in resp.headers().iter() {
let key = k.as_str().to_string();
let val = v.to_str().unwrap_or("").to_string();
resp_headers.entry(key)
.and_modify(|existing| { existing.push_str(", "); existing.push_str(&val); })
.or_insert(val);
}
// 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);
// parse 处理:json → pretty;auto → 尝试 json 失败回退 text;二进制(含 \0)跳过
let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers);
Ok(json!({
"method": method_str,
"url": final_url,
"status": status,
"status_text": status_text,
"scheme": scheme,
"headers": resp_headers,
"body": parsed_body,
"body_bytes": total_bytes,
"truncated": truncated,
"elapsed_ms": elapsed_ms,
}))
}
/// 截断 body 到 max 字节(尾部保留报错堆栈 + 截断标记)。
/// 按 char 边界切(防切多字节 UTF-8 中间)。返回 (截断后 String, 是否截断)。
///
/// 二进制检测(含 \0 字节)优先于长度判定:无论是否超长,二进制响应一律用占位串替代,
/// 避免 String::from_utf8_lossy 把非法字节全转 U+FFFD 撑爆 LLM context。
fn truncate_body(bytes: &[u8], max: usize) -> (String, bool) {
if bytes.contains(&0u8) {
return (
format!("[二进制响应 {} 字节,跳过文本展示]", bytes.len()),
true,
);
}
if bytes.len() <= max {
// 全量;非 UTF-8 容错降级(String::from_utf8_lossy 会替换非法字节为 U+FFFD)
let s = String::from_utf8_lossy(bytes).into_owned();
return (s, false);
}
let head = &bytes[..max];
let s = String::from_utf8_lossy(head).into_owned();
// lossy 可能在截断处插入 U+FFFD(切到多字节中间),接受此降级 + 截断标记
(
format!("{}... [body 已截断,原始 {} 字节,仅保留前 {} 字节]", s, bytes.len(), head.len()),
true,
)
}
/// 按 parse 模式格式化 body 文本。
/// - json: 尝试 serde_json::to_string_pretty,失败回退原文
/// - text: 原样
/// - auto: 看 Content-Type 含 json → 走 json;否则 text
/// 二进制已被 truncate_body 处理为占位串,此处原样返回。
fn format_body(
body_text: &str,
total_bytes: usize,
parse_mode: &str,
headers: &HashMap<String, String>,
) -> String {
// 二进制占位串特征(由 truncate_body 注入):直接返回不解析
if body_text.starts_with("[二进制响应") {
return body_text.to_string();
}
let want_json = match parse_mode {
"json" => true,
"text" => false,
_ => {
// auto:Content-Type 含 json → 走 json 解析
let ct = headers.get("content-type")
.or_else(|| headers.get("Content-Type"))
.map(|s| s.to_lowercase())
.unwrap_or_default();
ct.contains("json")
}
};
if want_json {
if let Ok(v) = serde_json::from_str::<Value>(body_text) {
return serde_json::to_string_pretty(&v).unwrap_or_else(|_| body_text.to_string());
}
// 解析失败:零字节文本但非合法 JSON(如 HTML 含 <json> 标签),回退原文 + 提示
if total_bytes > 0 {
return format!("{}\n[注:期望 JSON 但解析失败,原样展示]", body_text);
}
}
body_text.to_string()
}
// ============================================================
// 单元测试
//
// 覆盖 SSRF 防护纯函数(is_private_ip / validate_url)+ handler 集成路径。
// 无 mock 库(mockito 未引入 workspace),采用两类测试:
// ① 纯函数(is_private_ip/validate_url):零网络依赖,确定性。
// ② handler 集成:对 example.com(GET 200 稳定公共端点)做真实请求,验证 happy path;
// SSRF 拒绝路径(localhost/127.0.0.1)走 validate_url 纯函数断言(handler 集成层
// 已由纯函数覆盖,无需真发请求触发拒绝)。超时/截断靠纯函数逻辑断言 + 参数边界。
// 真实网络测试 #[ignore](CI 无网时跳过,本地 `cargo test -- --ignored` 手动跑)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ── is_private_ip:IPv4 各保留段 ──
#[test]
fn test_is_private_ip_v4_loopback() {
assert!(is_private_ip(&"127.0.0.1".parse().unwrap()));
assert!(is_private_ip(&"127.255.255.254".parse().unwrap())); // 整段 127/8
}
#[test]
fn test_is_private_ip_v4_rfc1918() {
assert!(is_private_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_private_ip(&"10.255.255.255".parse().unwrap()));
assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_private_ip(&"172.31.255.254".parse().unwrap()));
assert!(is_private_ip(&"192.168.1.1".parse().unwrap()));
assert!(is_private_ip(&"192.168.0.0".parse().unwrap()));
}
#[test]
fn test_is_private_ip_v4_link_local_and_metadata() {
// 169.254.169.254:云元数据服务,SSRF 头号目标
assert!(is_private_ip(&"169.254.169.254".parse().unwrap()));
assert!(is_private_ip(&"169.254.0.1".parse().unwrap()));
}
#[test]
fn test_is_private_ip_v4_cgnat_and_zero_and_multicast() {
assert!(is_private_ip(&"100.64.0.1".parse().unwrap())); // CGNAT 100.64/10
assert!(is_private_ip(&"0.0.0.0".parse().unwrap())); // 未指定
assert!(is_private_ip(&"224.0.0.1".parse().unwrap())); // 组播
assert!(is_private_ip(&"240.0.0.1".parse().unwrap())); // 保留
}
#[test]
fn test_is_private_ip_v4_public_not_matched() {
// 172.15 / 172.32 边界外不算私网
assert!(!is_private_ip(&"172.15.0.1".parse().unwrap()));
assert!(!is_private_ip(&"172.32.0.1".parse().unwrap()));
// 公网示例(8.8.8.8 / 1.1.1.1)
assert!(!is_private_ip(&"8.8.8.8".parse().unwrap()));
assert!(!is_private_ip(&"1.1.1.1".parse().unwrap()));
}
#[test]
fn test_is_private_ip_v6_special() {
assert!(is_private_ip(&"::1".parse().unwrap())); // 环回
assert!(is_private_ip(&"::".parse().unwrap())); // 未指定
assert!(is_private_ip(&"fe80::1".parse().unwrap())); // 链路本地
assert!(is_private_ip(&"fc00::1".parse().unwrap())); // 唯一本地
assert!(is_private_ip(&"fd00::1".parse().unwrap())); // 唯一本地
}
// ── validate_url:协议白名单 + 词法层拦截 ──
#[test]
fn test_validate_url_https_public_ok() {
let (scheme, host, port) = validate_url("https://example.com/path").unwrap();
assert_eq!(scheme, "https");
assert_eq!(host, "example.com");
assert_eq!(port, 443);
}
#[test]
fn test_validate_url_http_default_port() {
let (scheme, _, port) = validate_url("http://example.com").unwrap();
assert_eq!(scheme, "http");
assert_eq!(port, 80);
}
#[test]
fn test_validate_url_rejects_non_http_schemes() {
assert!(validate_url("file:///etc/passwd").is_err());
assert!(validate_url("ftp://example.com").is_err());
assert!(validate_url("gopher://example.com").is_err());
assert!(validate_url("data:text/plain,hello").is_err());
}
#[test]
fn test_validate_url_rejects_localhost() {
assert!(validate_url("http://localhost/admin").is_err());
assert!(validate_url("http://sub.localhost/x").is_err());
}
#[test]
fn test_validate_url_rejects_literal_private_ip() {
assert!(validate_url("http://127.0.0.1/").is_err());
assert!(validate_url("http://10.0.0.1/").is_err());
assert!(validate_url("http://192.168.1.1/").is_err());
assert!(validate_url("http://172.16.0.1/").is_err());
assert!(validate_url("http://169.254.169.254/").is_err());
// IPv6 环回字面量
assert!(validate_url("http://[::1]/").is_err());
}
#[test]
fn test_validate_url_case_insensitive_scheme() {
let (scheme, _, _) = validate_url("HTTPS://Example.COM").unwrap();
assert_eq!(scheme, "https");
}
// ── truncate_body:截断 + 二进制跳过 ──
#[test]
fn test_truncate_body_under_limit() {
let s = b"hello world".to_vec();
let (out, trunc) = truncate_body(&s, 1024);
assert!(!trunc);
assert_eq!(out, "hello world");
}
#[test]
fn test_truncate_body_over_limit_text() {
let big = "a".repeat(60_000);
let (out, trunc) = truncate_body(big.as_bytes(), 1024);
assert!(trunc);
assert!(out.contains("[body 已截断"));
assert!(out.contains("原始 60000 字节"));
}
#[test]
fn test_truncate_body_binary_nul_detected() {
// 含 \0 字节:视为二进制,不 lossy 展开大段 U+FFFD
let mut bin = vec![0x41u8; 100]; // 'A' x100
bin.extend_from_slice(&[0u8; 50]);
let (out, trunc) = truncate_body(&bin, 1024);
assert!(trunc);
assert!(out.starts_with("[二进制响应"));
}
// ── format_body:json/text/auto 解析 ──
#[test]
fn test_format_body_json_pretty() {
let body = r#"{"a":1,"b":[2,3]}"#;
let out = format_body(body, body.len(), "json", &HashMap::new());
assert!(out.contains("\"a\": 1"));
assert!(out.contains("\n")); // pretty 多行
}
#[test]
fn test_format_body_text_passthrough() {
let body = "<html>raw</html>";
let out = format_body(body, body.len(), "text", &HashMap::new());
assert_eq!(out, body);
}
#[test]
fn test_format_body_auto_detects_json_via_content_type() {
let body = r#"{"x":5}"#;
let mut h = HashMap::new();
h.insert("content-type".to_string(), "application/json; charset=utf-8".to_string());
let out = format_body(body, body.len(), "auto", &h);
assert!(out.contains("\"x\": 5"));
}
#[test]
fn test_format_body_auto_text_when_not_json_ct() {
let body = "plain text";
let mut h = HashMap::new();
h.insert("content-type".to_string(), "text/html".to_string());
let out = format_body(body, body.len(), "auto", &h);
assert_eq!(out, "plain text");
}
#[test]
fn test_format_body_json_parse_fail_fallback() {
let body = "{not valid json";
let out = format_body(body, body.len(), "json", &HashMap::new());
assert!(out.contains("[注:期望 JSON 但解析失败"));
}
// ── handler 参数边界(method/timeout/missing url) ──
#[tokio::test]
async fn test_handler_missing_url_errors() {
let args = json!({ "method": "GET" });
let err = execute_http_request(args).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();
assert!(format!("{}", err).contains("不支持的 method"));
}
#[tokio::test]
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();
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();
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();
assert!(format!("{}", err).contains("协议"));
}
#[tokio::test]
async fn test_handler_timeout_clamped() {
// 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 即通过
}
// ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ──
#[tokio::test]
#[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 status = result["status"].as_u64().unwrap();
assert!(status == 200, "期望 200,实际 {}", status);
assert!(result["body"].as_str().unwrap_or("").contains("Example Domain"));
assert_eq!(result["truncated"], false);
assert!(result["elapsed_ms"].as_u64().unwrap_or(0) > 0);
}
#[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 status = result["status"].as_u64().unwrap_or(0);
// 最终落地 200(经重定向)
assert!(status >= 200 && status < 400, "重定向后状态: {}", status);
assert!(result["url"].as_str().unwrap_or("").contains("example.com"));
}
/// 简单 URL 编码(测试用,避免引入 urlencoding crate)。仅编码必要字符。
fn urlencoding(s: &str) -> String {
s.chars().map(|c| match c {
':' => "%3A".to_string(),
'/' => "%2F".to_string(),
_ => c.to_string(),
}).collect()
}
}

View File

@@ -27,6 +27,7 @@ pub mod audit;
pub mod commands; pub mod commands;
pub mod compress; pub mod compress;
pub mod conversation; pub mod conversation;
pub mod http;
pub mod knowledge_inject; pub mod knowledge_inject;
pub mod prompt; pub mod prompt;
pub mod provider_pool; pub mod provider_pool;

View File

@@ -368,9 +368,43 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db); register_data_tools(&mut registry, db);
register_file_tools(&mut registry); register_file_tools(&mut registry);
register_http_tools(&mut registry);
registry registry
} }
/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
///
/// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。
// (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。)
// 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。
fn register_http_tools(registry: &mut AiToolRegistry) {
registry.register(
"http_request", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼
{
let mut props = serde_json::Map::new();
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
},
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
super::http::execute_http_request(args).await
})),
);
}
/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。 /// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
/// ///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。 /// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
@@ -1584,6 +1618,8 @@ pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'stati
// F-03 收口:推进链工具审批文案(advance_task/run_workflow) // F-03 收口:推进链工具审批文案(advance_task/run_workflow)
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]), "advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]), "run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
// http_request 审批文案:method + url(看是什么请求打到哪)
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
_ => return None, _ => return None,
}; };
Some((template, keys)) Some((template, keys))
@@ -1618,6 +1654,7 @@ pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
"append_file" => Some("追加写入"), "append_file" => Some("追加写入"),
"delete_file" => Some("删除文件"), "delete_file" => Some("删除文件"),
"rename_file" => Some("重命名/移动"), "rename_file" => Some("重命名/移动"),
"http_request" => Some("发起 HTTP 请求"),
_ => None, _ => None,
} }
} }
@@ -1691,7 +1728,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================ // ============================================================
/// build_ai_tool_registry 应注册恰好 28 个工具(18 data + 10 file),且工具名集合稳定。 /// build_ai_tool_registry 应注册恰好 29 个工具(18 data + 10 file + 1 http),且工具名集合稳定。
/// ///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1701,16 +1738,17 @@ mod tests {
let db = Arc::new(db); let db = Arc::new(db);
let registry = build_ai_tool_registry(&db); let registry = build_ai_tool_registry(&db);
// 总量基线:28(18 data + 10 file)。拆分前后必须一致。 // 总量基线:29(18 data + 10 file + 1 http)。拆分前后必须一致。
assert_eq!( assert_eq!(
registry.len(), registry.len(),
28, 29,
"工具总数应为 28(18 data + 10 file),实际 {}", registry.len() "工具总数应为 29(18 data + 10 file + 1 http),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 18 个(持 db):CRUD/状态机/工作流 // data 层 18 个(持 db):CRUD/状态机/工作流
// file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜 // file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![ let mut expected: Vec<&str> = vec![
// ── data 层 (18) ── // ── data 层 (18) ──
"list_projects", "list_tasks", "list_ideas", "list_projects", "list_tasks", "list_ideas",
@@ -1723,6 +1761,8 @@ mod tests {
"run_command", "read_file", "list_directory", "run_command", "read_file", "list_directory",
"write_file", "patch_file", "file_info", "write_file", "patch_file", "file_info",
"append_file", "delete_file", "rename_file", "search_files", "append_file", "delete_file", "rename_file", "search_files",
// ── http 层 (1) ──
"http_request",
]; ];
expected.sort_unstable(); expected.sort_unstable();

View File

@@ -165,6 +165,28 @@
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre> <pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div> </div>
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 UX-260618-08) -->
<div
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
class="ai-tool-http-result"
>
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
<button
v-if="parsed?.body"
class="ai-tool-cmd-toggle"
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="httpBodyExpanded = !httpBodyExpanded"
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
{{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
</div>
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
</div>
<!-- 通用 CRUD 结果 --> <!-- 通用 CRUD 结果 -->
<div <div
v-else-if="tc.result && tc.status === 'completed'" v-else-if="tc.result && tc.status === 'completed'"
@@ -248,6 +270,8 @@ const emit = defineEmits<{
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠 // run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
// 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。 // 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。
const cmdOutputExpanded = ref(false) const cmdOutputExpanded = ref(false)
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
const httpBodyExpanded = ref(false)
// SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch // SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch
// (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。 // (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。
@@ -286,11 +310,13 @@ function showApproveTimeoutToast(): void {
*/ */
const HIGH_RISK_TOOLS = new Set<string>([ const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command', 'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
]) ])
/** High 风险工具的二次确认文案(按操作类型) */ /** High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string { function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec') if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') { if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete') return t('aiTool.confirmHighDelete')
} }
@@ -320,7 +346,10 @@ async function onApprove(approved: boolean) {
// immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用); // immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用);
// approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。 // approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。
watch(() => props.tc.status, (s) => { watch(() => props.tc.status, (s) => {
if (s === 'completed') cmdOutputExpanded.value = isToolFailure(props.tc) if (s === 'completed') {
cmdOutputExpanded.value = isToolFailure(props.tc)
httpBodyExpanded.value = isToolFailure(props.tc)
}
if (s !== 'pending_approval') { if (s !== 'pending_approval') {
approving.value = false approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null } if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
@@ -499,9 +528,33 @@ function toolDisplayName(tc: AiToolCallInfo): string {
const cmd = argString(tc.args, 'command') const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name) return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
} }
case 'http_request': {
// 折叠态头部显 method + host(从 url 提取),无 url 回退通用名
const method = argString(tc.args, 'method') || 'GET'
const url = argString(tc.args, 'url')
const host = httpHost(url)
return host ? `${method} ${host}` : t('aiTool.httpRequestFallback')
}
default: return formatToolName(tc.name) default: return formatToolName(tc.name)
} }
} }
/**
* http_request url → host 显示用(折叠态头部精简,不全显 url)。
* 取 scheme://host[:port],剥 path/query;非 http(s) url 返回空串回退通用名。
* 注:用 URL 构造器容错,非法 url 抛错时回退原 url 截断。
*/
function httpHost(raw: string): string {
if (!raw) return ''
try {
const u = new URL(raw)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''
const portPart = u.port ? `:${u.port}` : ''
return `${u.hostname}${portPart}`
} catch {
return raw.length > 40 ? raw.slice(0, 40) + '…' : raw
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1041,6 +1094,54 @@ function toolDisplayName(tc: AiToolCallInfo): string {
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
} }
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
.ai-tool-http-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-http-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
}
.ai-tool-http-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-http-method {
flex-shrink: 0;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.08);
color: var(--df-text);
font-size: 10px;
}
.ai-tool-http-url {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text-dim);
}
.ai-tool-http-status {
flex-shrink: 0;
font-weight: 600;
color: var(--df-danger);
}
.ai-tool-http-status--ok {
color: var(--df-success);
}
.ai-tool-http-trunc-warn {
padding: 4px 10px;
font-size: 10.5px;
color: var(--df-warning, #f0c75e);
border-top: 0.5px solid var(--df-border);
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */ /* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast { .ai-tool-toast {
position: absolute; position: absolute;

View File

@@ -79,6 +79,12 @@ export interface ToolResult {
old_size?: number | null old_size?: number | null
encoding?: string encoding?: string
cross_volume?: boolean cross_volume?: boolean
// http_request result fields
method?: string
url?: string
body?: string
body_bytes?: number
elapsed_ms?: number
} }
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */ /** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
@@ -414,11 +420,47 @@ export function formatToolResult(tc: AiToolCallInfo): string {
if (!execId) return formatJson(r) if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId }) return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
} }
case 'http_request':
return formatHttpResult(r)
default: default:
return formatJson(r) return formatJson(r)
} }
} }
/**
* http_request 结果摘要:状态码标题(method + 最终 URL) + 截断的 body(前 N 行) + 耗时/字节数。
* 后端已截断 body 到 50KB + parse 格式化(json pretty/auto),此处仅做行数限制(前 20 行)
* 防大 JSON 撑爆折叠态卡片。展开态由 ToolCard.vue http_request 分支全量展示。
*/
export function formatHttpResult(r: ToolResult): string {
const status = typeof r.status === 'number' ? r.status : 0
const method = typeof r.method === 'string' ? r.method : 'HTTP'
const url = typeof r.url === 'string' ? r.url : ''
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
const ok = status >= 200 && status < 300
const head = ok
? t('aiTool.httpOk', { method, status, ms })
: t('aiTool.httpFailed', { method, status })
const parts: string[] = [head, url]
const body = typeof r.body === 'string' ? r.body : ''
if (body) {
const MAX_LINES = 20
const lines = body.split('\n')
if (lines.length > MAX_LINES) {
parts.push(lines.slice(0, MAX_LINES).join('\n'))
parts.push(t('aiTool.commandOutputLines', { n: lines.length }))
} else {
parts.push(body)
}
}
const trunc = r.truncated === true
const bytes = typeof r.body_bytes === 'number' ? r.body_bytes : 0
if (trunc) {
parts.push(t('aiTool.httpTruncated', { bytes: formatBytes(bytes) }))
}
return parts.join('\n')
}
/** /**
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。 * completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致); * UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
@@ -493,6 +535,15 @@ export function toolResultSummary(tc: AiToolCallInfo): string {
: (typeof r.id === 'string' ? r.id : '') : (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : '' return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
} }
case 'http_request': {
const status = typeof r.status === 'number' ? r.status : 0
const method = typeof r.method === 'string' ? r.method : 'HTTP'
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
const ok = status >= 200 && status < 300
return ok
? t('aiTool.httpOk', { method, status, ms })
: t('aiTool.httpFailed', { method, status })
}
default: return '' default: return ''
} }
} }

View File

@@ -82,6 +82,11 @@ export default {
renamedCrossVolume: 'Moved {from} → {to} (cross-volume, {bytes})', renamedCrossVolume: 'Moved {from} → {to} (cross-volume, {bytes})',
writeOverwrite: 'overwrote {old} → {neww}', writeOverwrite: 'overwrote {old} → {neww}',
encodingLabel: 'encoding: {enc}', encodingLabel: 'encoding: {enc}',
// http_request result summary (method + status + elapsed; body in expanded view)
httpOk: '{method} {status} OK ({ms} ms)',
httpFailed: '{method} {status} failed',
httpTruncated: 'Response truncated ({bytes}), see details',
httpRequestFallback: 'HTTP Request',
foundN: 'Found {n}', foundN: 'Found {n}',
boundDir: 'Bound directory "{path}" stack: {stack}', boundDir: 'Bound directory "{path}" stack: {stack}',
deleteFilePermanent: 'Permanently deleted {path}', deleteFilePermanent: 'Permanently deleted {path}',
@@ -125,6 +130,7 @@ export default {
purgeProject: 'Purge Project', purgeProject: 'Purge Project',
updateProject: 'Update Project', updateProject: 'Update Project',
runWorkflow: 'Run Workflow', runWorkflow: 'Run Workflow',
httpRequest: 'HTTP Request',
}, },
// UX-260617-14: approval loading fallback timeout notice (no backend ack, card still pending_approval → reset + prompt) // UX-260617-14: approval loading fallback timeout notice (no backend ack, card still pending_approval → reset + prompt)
@@ -133,6 +139,9 @@ export default {
// UX-260617-18: tool failure detection copy (regex narrowed to line-anchored err_msg prefix) // UX-260617-18: tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
// (reuse executionFailed/executionFailedHint, no new key) // (reuse executionFailed/executionFailedHint, no new key)
// http_request High-risk confirm: external network call, data may leak via URL/body
confirmHighHttp: 'Send this HTTP request to an external server?',
// Approval argument key labels // Approval argument key labels
argLabel: { argLabel: {
id: 'ID', id: 'ID',
@@ -146,6 +155,12 @@ export default {
priority: 'Priority', priority: 'Priority',
tags: 'Tags', tags: 'Tags',
source: 'Source', source: 'Source',
method: 'Method',
url: 'URL',
body: 'Body',
headers: 'Headers',
timeout_secs: 'Timeout (s)',
parse: 'Parse',
}, },
}, },
} }

View File

@@ -83,6 +83,11 @@ export default {
renamedCrossVolume: '已移动 {from} → {to}(跨卷,{bytes})', renamedCrossVolume: '已移动 {from} → {to}(跨卷,{bytes})',
writeOverwrite: '覆盖 {old} → {neww}', writeOverwrite: '覆盖 {old} → {neww}',
encodingLabel: '编码:{enc}', encodingLabel: '编码:{enc}',
// http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示)
httpOk: '{method} {status} 成功({ms} 毫秒)',
httpFailed: '{method} {status} 失败',
httpTruncated: '响应已截断({bytes}),详见下方',
httpRequestFallback: 'HTTP 请求',
foundN: '找到 {n} 项', foundN: '找到 {n} 项',
boundDir: '已绑定目录「{path}」技术栈:{stack}', boundDir: '已绑定目录「{path}」技术栈:{stack}',
deleteFilePermanent: '已硬删除 {path}', deleteFilePermanent: '已硬删除 {path}',
@@ -107,6 +112,8 @@ export default {
confirmHighDelete: '确定永久删除?此操作不可恢复', confirmHighDelete: '确定永久删除?此操作不可恢复',
confirmHighExec: '确定执行此命令?', confirmHighExec: '确定执行此命令?',
confirmHighGeneric: '确定执行此高危操作?', confirmHighGeneric: '确定执行此高危操作?',
// http_request 高危二次确认:外发网络请求,数据可经 URL/body 泄露
confirmHighHttp: '确定向外部服务器发起此 HTTP 请求?',
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要) // 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
resultDetail: '{raw}{detail}', resultDetail: '{raw}{detail}',
@@ -127,6 +134,7 @@ export default {
purgeProject: '彻底删除项目', purgeProject: '彻底删除项目',
updateProject: '更新项目', updateProject: '更新项目',
runWorkflow: '运行工作流', runWorkflow: '运行工作流',
httpRequest: 'HTTP 请求',
}, },
// UX-260617-14:审批 loading 超时兜底提示(后端无回执,卡片仍 pending_approval 时复位 + 提示用户) // UX-260617-14:审批 loading 超时兜底提示(后端无回执,卡片仍 pending_approval 时复位 + 提示用户)
@@ -148,6 +156,12 @@ export default {
priority: '优先级', priority: '优先级',
tags: '标签', tags: '标签',
source: '来源', source: '来源',
method: '方法',
url: 'URL',
body: '请求体',
headers: '请求头',
timeout_secs: '超时(秒)',
parse: '解析',
}, },
}, },
} }