diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 3e45499..569f962 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -33,9 +33,12 @@ pub(crate) const MAX_LIST_RESULTS: usize = 50; /// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 /// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。 -const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; +/// +/// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源 +/// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。 +pub(crate) const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; /// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min) -const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600; +pub(crate) const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600; /// 生成行级 unified diff(无外部依赖,基于 LCS)。 /// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。 @@ -122,7 +125,7 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String { /// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别 /// 解码后 `..%2f` / `..\` 等变体。 /// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。 -fn validate_path(path: &str) -> anyhow::Result<()> { +pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> { // 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串) // percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。 use percent_encoding::percent_decode_str; @@ -158,7 +161,7 @@ fn validate_path(path: &str) -> anyhow::Result<()> { /// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。 /// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。 /// 按 char 边界截(防切多字节 UTF-8 中间 panic)。 -fn truncate_output(s: &str, max: usize) -> (String, bool) { +pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) { if s.len() <= max { return (s.to_string(), false); } @@ -280,7 +283,7 @@ pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> /// /// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。 /// 单用户桌面应用,Mutex 够用且简单(~15 行),不需要全局文件队列。 -static FILE_LOCKS: LazyLock>> = +pub(crate) static FILE_LOCKS: LazyLock>> = LazyLock::new(|| TokioMutex::new(HashMap::new())); /// 计算文件指纹 `modified_secs_len`(TD-260621-04 闭环)。 @@ -288,7 +291,7 @@ static FILE_LOCKS: LazyLock>> = /// read_file 返回此值 → LLM 回传 patch_file.expected_hash → patch_file 比对,闭环防并发修改。 /// 抽单一真相源避免 read_file 输出与 patch_file 校验两处 format 漂移(modified_secs_len 格式必须严格一致)。 /// modified 取不到(系统不支持)退 0,仍保留 len 维度作弱保护。 -fn compute_file_hash(meta: &std::fs::Metadata) -> String { +pub(crate) fn compute_file_hash(meta: &std::fs::Metadata) -> String { let modified = meta .modified() .ok() @@ -313,7 +316,7 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String { /// /// 兜底/回退:flag 关或 helper 内部 panic 不影响——降级是纯增强,失败上抛 anyhow 让调用方 /// 走原 failed tool_result 路径(LLM 据此修参重试,非死循环:重试由阶段4 retry_guard 兜底)。 -async fn rename_or_cross_volume_copy( +pub(crate) async fn rename_or_cross_volume_copy( from: &str, to: &str, ) -> anyhow::Result { @@ -350,7 +353,7 @@ async fn rename_or_cross_volume_copy( /// /// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录), /// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。 -fn resolve_workspace_path_with_allowed( +pub(crate) fn resolve_workspace_path_with_allowed( path: &str, allowed: &AllowedDirs, ) -> anyhow::Result { @@ -1420,1110 +1423,17 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { /// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc>`, /// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。 /// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。 +/// +/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原 +/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 +/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 仍断言 48 总量 + 工具名集合稳定。 fn register_file_tools( registry: &mut AiToolRegistry, allowed_dirs: &Arc>, data_dir: PathBuf, ) { - // F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。 - // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), - // 故该工具闭包不捕获 allowed_dirs,维持原行为。 - let allowed_dirs = allowed_dirs.clone(); - - // ── 文件系统 ── - registry.register( - "read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", - df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]), - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) - use tokio::fs::File; - use tokio::io::AsyncReadExt; - let mut file = File::open(path).await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - anyhow::anyhow!( - "无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表", - path - ) - } else { - anyhow::anyhow!("无法访问文件 {}: {}", path, e) - } - })?; - let metadata = file.metadata().await - .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; - if metadata.len() > 1_048_576 { - anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); - } - // TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。 - // 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。 - let file_hash = compute_file_hash(&metadata); - // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话) - let mut content = String::new(); - if let Err(e) = file.read_to_string(&mut content).await { - if e.kind() == std::io::ErrorKind::InvalidData { - return Ok(serde_json::json!({ - "path": path, "content": null, "binary": true, - "size": metadata.len(), "file_hash": file_hash, - "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取" - })); - } - anyhow::bail!("读取文件失败: {}", e); - } - // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页 - if let Some(search) = args["search"].as_str() { - const SEARCH_MAX: usize = 50; - let search_offset = args["offset"].as_u64().unwrap_or(0) as usize; - let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize; - // 先收集全部匹配行用于 total 计数,再做 skip/take 分页 - let mut all_matches: Vec = Vec::new(); - for (idx, line) in content.lines().enumerate() { - if line.contains(search) { - all_matches.push(serde_json::json!({ "line": idx + 1, "content": line })); - } - } - let total = all_matches.len(); - let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect(); - let has_more = (search_offset + page_matches.len()) < total; - return Ok(serde_json::json!({ - "path": path, "size": metadata.len(), "file_hash": file_hash, - "search": search, - "matches": page_matches, - "total": total, - "has_more": has_more, - })); - } - // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) - // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, - // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 - // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 - let line_count = content.lines().count(); - let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() { - let lines: Vec<&str> = content.lines().collect(); - let skip = offset as usize; - let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; - let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect(); - let more = (skip + page.len()) < line_count; - (page.join("\n"), Some(skip), more) - } else { - // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), - // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 - // BUG-260623-01: 旧实现固定 take(500) 忽略 limit, - // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。 - const DEFAULT_PREVIEW_LINES: usize = 500; - let limit = args["limit"].as_u64() - .unwrap_or(DEFAULT_PREVIEW_LINES as u64) - .min(2000) as usize; - let page: Vec<&str> = content.lines().take(limit).collect(); - let more = line_count > page.len(); - (page.join("\n"), None, more) - }; - Ok(serde_json::json!({ - "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, - "offset": offset_used, - "returned_lines": result.lines().count(), - "has_more": has_more, - })) - }) - })}, - ); - registry.register( - "read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。", - df_ai::ai_tools::object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]), - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?; - let full = args["full"].as_bool().unwrap_or(false); - let kind_hint = args["kind"].as_str(); - let drill = args["drill"].as_str().filter(|s| !s.is_empty()); - use tokio::fs::File; - use tokio::io::AsyncReadExt; - let mut file = File::open(path).await - .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; - let metadata = file.metadata().await - .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; - if metadata.len() > 1_048_576 { - anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); - } - let file_hash = compute_file_hash(&metadata); - let mut content = String::new(); - if let Err(e) = file.read_to_string(&mut content).await { - if e.kind() == std::io::ErrorKind::InvalidData { - return Ok(serde_json::json!({ - "path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash, - "fallback": true, "reason": "binary", - "suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容", - })); - } - anyhow::bail!("读取文件失败: {}", e); - } - // 调 code_intel 纯函数(三态 + 兜底,不 panic) - Ok(crate::commands::ai::code_intel::read_symbol( - &content, &file_hash, path, symbol, full, kind_hint, drill, - )) - }) - })}, - ); - registry.register( - "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示", - df_ai::ai_tools::object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let p = args["path"].as_str().unwrap_or(""); - if p.is_empty() { - return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); - } - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed(p, &snap)?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let recursive = args["recursive"].as_bool().unwrap_or(false); - let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); - // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) - // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 - let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; - let mut entries = Vec::new(); - let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; - Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) - }) - })}, - ); - registry.register( - "write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", - df_ai::ai_tools::object_schema(vec![ - ("path", "string", true), - ("content", "string", true), - ("encoding", "string", false), - ]), - RiskLevel::Medium, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; - // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) - // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec 写字节 - let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); - let bytes: Vec = if encoding.eq_ignore_ascii_case("base64") { - use base64::{engine::general_purpose::STANDARD, Engine as _}; - STANDARD.decode(raw) - .map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))? - } else if encoding.eq_ignore_ascii_case("utf-8") { - raw.as_bytes().to_vec() - } else { - anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding); - }; - // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) - if bytes.len() > 1_048_576 { - anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); - } - let target = std::path::Path::new(path); - // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) - // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 - let old_size: Option = match tokio::fs::metadata(target).await { - Ok(m) if m.len() > 0 => { - let bak = format!("{}.bak", path); - tokio::fs::copy(path, &bak).await - .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; - Some(m.len()) - } - Ok(_) => Some(0), // 空文件(无需备份) - Err(_) => None, // 不存在(新建) - }; - if let Some(parent) = target.parent() { - // FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 - // BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目 - // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。 - if !snap.is_authorized(parent) { - anyhow::bail!("禁止在项目目录之外创建目录"); - } - tokio::fs::create_dir_all(parent).await - .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; - } - // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) - let tmp = format!("{}.tmp-write", path); - if let Err(e) = tokio::fs::write(&tmp, &bytes).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); - } - if let Err(e) = tokio::fs::rename(&tmp, path).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(anyhow::anyhow!("原子替换失败: {}", e)); - } - // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); - // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 - // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) - if old_size.map(|s| s > 0).unwrap_or(false) { - let bak = format!("{}.bak", path); - let _ = tokio::fs::remove_file(&bak).await; - } - // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak - if let Some(old) = old_size { - if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 { - tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len()); - } - } - Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding })) - }) - })}, - ); - - // ── 局部文件编辑 (Medium risk) ── - // F-260617-01: 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点)。 - // object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。 - // 三模式均需 path + new_text;old_text/replace_lines/anchor 三选一(互斥);expected_hash 可选通用。 - let patch_file_schema = { - let mut props = serde_json::Map::new(); - props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" })); - props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" })); - props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" })); - props.insert("replace_lines".into(), serde_json::json!({ - "type": "object", - "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移", - "properties": { - "start": { "type": "integer", "description": "起始行号(1-based,含)" }, - "end": { "type": "integer", "description": "结束行号(1-based,含)" } - }, - "required": ["start", "end"] - })); - props.insert("anchor".into(), serde_json::json!({ - "type": "object", - "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文", - "properties": { - "start": { "type": "string", "description": "首行子串标记(大小写敏感)" }, - "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" } - }, - "required": ["start", "end"] - })); - props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" })); - props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" })); - serde_json::json!({ - "type": "object", - "properties": props, - "required": ["path", "new_text"], - }) - }; - registry.register( - "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", - patch_file_schema, - RiskLevel::Medium, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let new_text = args["new_text"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; - - // F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor) - // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) - let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); - let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); - let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false); - let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count(); - if mode_count == 0 { - anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一"); - } - if mode_count > 1 { - anyhow::bail!( - "模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)", - mode_count - ); - } - - // 文件存在性 / 大小限制(三模式通用,先于内容读取) - let target = std::path::Path::new(path); - if !target.exists() { - anyhow::bail!("文件不存在: {}", path); - } - let file_meta = tokio::fs::metadata(target).await - .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; - if file_meta.len() > 1_048_576 { - anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len()); - } - - // TD-260621-03:读改写整体锁内防 lost update。 - // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件: - // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。 - // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。 - // 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。 - let _patch_guard = FILE_LOCKS.lock().await; - - // 读文件内容 + 校验(锁内,纯读 + CPU 计算) - use tokio::io::AsyncReadExt; - let mut file = tokio::fs::File::open(path).await - .map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?; - let mut content = String::new(); - file.read_to_string(&mut content).await - .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?; - - // 二进制检测 - if content.contains('\0') { - anyhow::bail!("不支持二进制文件"); - } - - // L3: expected_hash 指纹校验(防外部修改,TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式) - if let Some(expected) = args["expected_hash"].as_str() { - let current_hash = compute_file_hash(&file_meta); - if current_hash != expected { - anyhow::bail!( - "文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容", - expected, current_hash - ); - } - } - - // F-260617-01: 三模式分派计算 new_content + match_count + warning - // old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处) - // replace_lines 模式:行号区间 splice(越界 Err) - // anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err) - let new_content: String; - let match_count: usize; - let warning: Option; - - if has_old_text { - let old_text = args["old_text"].as_str() - .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?; - if old_text.is_empty() { - anyhow::bail!("old_text 不能为空"); - } - if old_text == new_text { - return Ok(serde_json::json!({ - "path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改" - })); - } - // L2: old_text 精确匹配(CAS 语义) - if !content.contains(old_text) { - anyhow::bail!("未找到目标文本,文件可能已被修改"); - } - let mc = content.matches(old_text).count(); - match_count = mc; - warning = if mc > 1 { - Some(format!("匹配到 {} 处,仅替换第 1 处", mc)) - } else { None }; - new_content = content.replacen(old_text, new_text, 1); - } else if has_replace_lines { - let rl = args.get("replace_lines") - .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?; - let start = rl["start"].as_u64() - .ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize; - let end = rl["end"].as_u64() - .ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize; - new_content = apply_line_range(&content, start, end, new_text)?; - match_count = 1; - warning = None; - } else { - // has_anchor - let an = args.get("anchor") - .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?; - let a_start = an["start"].as_str() - .ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?; - let a_end = an["end"].as_str() - .ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?; - let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?; - new_content = apply_line_range(&content, start_line, end_line, new_text)?; - match_count = 1; - warning = None; - } - - // 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup) - // .bak 备份 - let bak = format!("{}.bak", path); - tokio::fs::copy(path, &bak).await - .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; - - // 原子写: tmp → rename - let tmp = format!("{}.tmp-write", path); - if let Err(e) = tokio::fs::write(&tmp, &new_content).await { - let _ = tokio::fs::remove_file(&tmp).await; - let _ = tokio::fs::remove_file(&bak).await; - return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); - } - if let Err(e) = tokio::fs::rename(&tmp, path).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak)); - } - // 成功:清理 .bak - let _ = tokio::fs::remove_file(&bak).await; - drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁) - - let size_diff = new_content.len() as i64 - content.len() as i64; - // 生成 unified diff 供前端审批卡/审计留痕展示 - // content(旧)与 new_content(新)作用域内可用,无需重读文件 - let diff = generate_diff(&content, &new_content); - let mut result = serde_json::json!({ - "path": path, - "changed": true, - "size_diff": size_diff, - "matches_found": match_count, - "diff": diff, - }); - if let Some(w) = warning { - result["warning"] = serde_json::Value::String(w); - } - Ok(result) - }) - })}, - ); - - // ── 文件元信息 (Low risk) ── - registry.register( - "file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", - df_ai::ai_tools::object_schema(vec![("path", "string", true)]), - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let p = std::path::Path::new(path); - if !p.exists() { - return Ok(serde_json::json!({ "path": path, "exists": false })); - } - let metadata = tokio::fs::metadata(path).await - .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; - let is_dir = metadata.is_dir(); - let size = metadata.len(); - let modified = metadata.modified() - .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as i64); - // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) - // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, - // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 - // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, - // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。 - let is_binary = if !is_dir && size > 0 { - use tokio::io::AsyncReadExt; - let file = match tokio::fs::File::open(path).await { - Ok(f) => f, - Err(_) => return Ok(serde_json::json!({ - "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null, - "modified": modified, "is_binary": false, "is_dir": is_dir, - "error": "读取文件头失败" - })), - }; - let mut reader = tokio::io::BufReader::new(file); - let mut sample = vec![0u8; 8192]; - let n = reader.read(&mut sample).await.unwrap_or(0); - sample[..n].contains(&0x00) - } else { false }; - // lines: 文本文件 \n 计数(>2MB 跳过避免全量读) - let lines = if !is_dir && !is_binary && size <= 2_097_152 { - tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64) - } else { None }; - Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir })) - }) - })}, - ); - - // ── 追加写入 (Medium risk) ── - registry.register( - "append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", - df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]), - RiskLevel::Medium, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; - if let Some(parent) = std::path::Path::new(path).parent() { - // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 - if !snap.is_authorized(parent) { - anyhow::bail!("禁止在项目目录之外创建目录"); - } - tokio::fs::create_dir_all(parent).await - .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; - } - use tokio::io::AsyncWriteExt; - let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await - .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?; - let bytes = content.as_bytes(); - file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?; - file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?; - let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0); - Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size })) - }) - })}, - ); - - // ── 删除文件 (High risk, F-260615-16) ── - registry.register( - "delete_file", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准", - df_ai::ai_tools::object_schema(vec![ - ("path", "string", true), - ("permanent", "boolean", false), - ]), - RiskLevel::High, - { let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - let data_dir = data_dir.clone(); - Box::pin(async move { - // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - &snap, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false); - let target = std::path::Path::new(path); - if !target.exists() { - anyhow::bail!("文件不存在: {}", path); - } - // 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底) - let metadata = tokio::fs::metadata(target).await - .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; - if metadata.is_dir() { - anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path); - } - let file_name = target.file_name() - .ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))? - .to_string_lossy().to_string(); - - if permanent { - // 硬删除:不可逆,对齐 delete_project purge 语义 - tokio::fs::remove_file(path).await - .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?; - Ok(serde_json::json!({ - "path": path, - "deleted": true, - "permanent": true, - "backed_up": false, - })) - } else { - // 软删除:移到 .trash/-,可恢复(对齐 list_trash 机制) - // .trash 放 app_data_dir 下,不依赖编译期 workspace_root - let trash_dir = data_dir.join(".trash"); - tokio::fs::create_dir_all(&trash_dir).await - .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?; - let backup_name = format!("{}-{}", new_id(), file_name); - let backup_path = trash_dir.join(&backup_name); - let backup_path_str = backup_path.to_string_lossy().to_string(); - // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, - // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, - // 失败回滚删 backup 保源完整)。 - // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), - // 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败 - // (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。 - // .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。 - // helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。 - if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await { - anyhow::bail!("移入回收站失败({})", e); - } - Ok(serde_json::json!({ - "path": path, - "deleted": true, - "permanent": false, - "backed_up": true, - "backup_path": backup_path_str, - })) - } - }) - })}, - ); - - // ── 重命名/移动文件 (Medium risk, F-260615-15) ── - // 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码 - // 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。 - registry.register( - "rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准", - df_ai::ai_tools::object_schema(vec![ - ("from", "string", true), - ("to", "string", true), - ("overwrite", "boolean", false), - ]), - RiskLevel::Medium, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed, - // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸 - let snap = allowed_dirs.read().await.clone(); - let from_resolved = resolve_workspace_path_with_allowed( - args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?, - &snap, - )?; - let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?; - let to_resolved = resolve_workspace_path_with_allowed( - args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?, - &snap, - )?; - let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?; - let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false); - - // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底) - let from_target = std::path::Path::new(from_path); - if !from_target.exists() { - anyhow::bail!("源文件不存在: {}", from_path); - } - let from_metadata = tokio::fs::metadata(from_target).await - .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?; - if from_metadata.is_dir() { - anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path); - } - let bytes_moved = from_metadata.len(); - - // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败) - let to_target = std::path::Path::new(to_path); - if let Some(parent) = to_target.parent() { - // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 - if !snap.is_authorized(parent) { - anyhow::bail!("禁止在项目目录之外创建目录"); - } - tokio::fs::create_dir_all(parent).await - .map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?; - } - - // 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to - if to_target.exists() { - if !overwrite { - anyhow::bail!("目标已存在且 overwrite=false: {}", to_path); - } - tokio::fs::remove_file(to_path).await - .map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?; - } - - // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题) - // 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级), - // 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。 - let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?; - Ok(serde_json::json!({ - "from": from_path, - "to": to_path, - "renamed": true, - "bytes_moved": bytes_moved, - "cross_volume": cross_volume, - })) - }) - })}, - ); - - // ── 跨文件内容搜索 grep (Medium risk, F-260621) ── - // 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。 - // 参考 memory [[devflow-patch-file-design]] 工具落地模式 + Claude Code grep 工具语义。 - // - // 参数语义(对齐 Claude Code grep): - // - pattern(必填):正则表达式(默认),大小写敏感。-i 切大小写不敏感。 - // 注意:与 search_files 不同(search_files 字面包含 + 大小写不敏感),grep 用 regex 更强表达力, - // 但"无特殊字符的 pattern"等价字面包含(如 "foo" 匹配含 foo 的行)。 - // - path(搜索根):锚 workspace + path_auth 授权(resolve_workspace_path_with_allowed)。 - // - glob(可选):文件名过滤,如 "*.rs"。支持基础 glob(* / ? / [seq] 单段),复用 PatternBuilder。 - // - output_mode:content(默认,返 file:line:content+context) / files_with_matches(只返命中文件名) / - // count(每文件命中行数)。对齐 Claude Code grep 三模式。 - // - -n(行号,默认 true)/ -i(大小写不敏感,默认 false)/ -C context_lines(上下文行数,默认 0)。 - // - max_results:防撑爆 LLM context,默认 50(对齐 read_file search/search_files 50 条上限)。 - // - // 安全:递归遍历跳过噪音目录(.git/node_modules/target/.trash 等 is_noise_dir)+ - // 噪音文件(.bak/.tmp-write 等 is_noise_file)+ symlink(对齐 list_dir_recursive 防逃逸)+ - // 二进制文件(对齐 read_file/list_directory:\0 检测)。 - // path_auth:授权目录内放行;未授权走 AiDirAuthRequired 申请(audit/mod.rs check_file_tool_auth - // 经 extract_file_tool_paths 单路径分支触发,非 search_files 盲拒)。 - // risk:Low(只读内容搜索,对齐 read_file/search_files;目录授权走 path_auth 独立层, - // 不混入 risk_level——read_file 同样读内容+走 path_auth 但 Low。原 Med 过保守致每次搜索审批)。 - // BUG-260624-04:用户报"搜索代码也要授权",根因即此 Med 错标 + tool_display_hint 无 grep 条目 - // 致 reason fallback "创建操作"文案误导。降 Low + 补 hint 双修。 - // 注册顺序:read_file/list_directory 后,search_files 前(高频检索工具靠前)。 - let grep_schema = { - let mut props = serde_json::Map::new(); - props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" })); - props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" })); - props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" })); - props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] })); - props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" })); - props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" })); - props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 })); - props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 })); - serde_json::json!({ - "type": "object", - "properties": props, - "required": ["pattern"], - }) - }; - registry.register( - "grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", - grep_schema, - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let p = args["path"].as_str().unwrap_or(""); - if p.is_empty() { - return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); - } - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed(p, &snap)?; - let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let pattern = args["pattern"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; - if pattern.is_empty() { - anyhow::bail!("pattern 不能为空"); - } - let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); - let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true); - let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize; - let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content"); - let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize; - - // 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹) - let mut re_builder = regex::RegexBuilder::new(pattern); - re_builder.case_insensitive(case_insensitive); - let (re, _auto_fixed) = match re_builder.build() { - Ok(r) => (r, false), - Err(e) => { - // LLM 常传 `a|b|c` 未分组模式(如 ` (r, true), - Err(_) => anyhow::bail!( - "正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组", - pattern, e - ), - } - } - }; - - // glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。 - // 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。 - let glob_re = match glob_opt { - Some(g) => Some(compile_glob_to_regex(g) - .map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?), - None => None, - }; - - // 递归遍历+逐文件读+行匹配,收集结果 - let mut matches_out: Vec = Vec::new(); - let mut total: usize = 0; - let mut truncated = false; - // BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。 - // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 - // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 - let root_meta = tokio::fs::metadata(root).await - .map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?; - if root_meta.is_file() { - let file_name = std::path::Path::new(root) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - grep_one_file( - std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode, - context_lines, max_results, &mut matches_out, &mut total, &mut truncated, - ).await?; - } else { - grep_recursive( - root, &re, glob_re.as_ref(), output_mode, - context_lines, max_results, 0, 6, - &mut matches_out, &mut total, &mut truncated, - ).await?; - } - - // output_mode 分派返回结构 - let result = match output_mode { - "files_with_matches" => { - // 仅返命中文件路径列表(去重,顺序保留首次命中) - let files: Vec = matches_out.iter() - .map(|h| h.file.clone()) - .collect(); - serde_json::json!({ - "path": root, - "pattern": pattern, - "output_mode": output_mode, - "files": files, - "total": files.len(), - "truncated": truncated, - }) - } - "count" => { - // 每文件命中行数 - let counts: Vec = matches_out.iter() - .map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() })) - .collect(); - let total_files = counts.len(); - serde_json::json!({ - "path": root, - "pattern": pattern, - "output_mode": output_mode, - "counts": counts, - "total": total, - "total_files": total_files, - "truncated": truncated, - }) - } - _ => { - // content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}] - let mut lines: Vec = Vec::new(); - for hit in &matches_out { - for lm in &hit.line_matches { - let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null }; - let mut entry = serde_json::json!({ - "file": hit.file, - "line": line_no, - "content": lm.content, - }); - if context_lines > 0 && !lm.context.is_empty() { - entry["context"] = serde_json::Value::String(lm.context.clone()); - } - lines.push(entry); - } - } - serde_json::json!({ - "path": root, - "pattern": pattern, - "output_mode": "content", - "matches": lines, - "total": total, - "truncated": truncated, - }) - } - }; - Ok(result) - }) - })}, - ); - - // ── 文件搜索 (Low risk) ── - registry.register( - "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", - df_ai::ai_tools::object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), - RiskLevel::Low, - { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { - let allowed_dirs = allowed_dirs.clone(); - Box::pin(async move { - let p = args["path"].as_str().unwrap_or(""); - if p.is_empty() { - return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); - } - let snap = allowed_dirs.read().await.clone(); - let resolved = resolve_workspace_path_with_allowed(p, &snap)?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; - let recursive = args["recursive"].as_bool().unwrap_or(false); - let pattern_lower = pattern.to_lowercase(); - const MAX_RESULTS: usize = 50; - let offset = args["offset"].as_u64().unwrap_or(0) as usize; - let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize; - // 先收集全部结果用于 total 计数,再做 skip/take 分页 - let mut all_results = Vec::new(); - let mut total = 0u64; - // 用较大上限收集全量(分页由内存 skip/take 控制) - search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?; - let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); - let has_more = (offset + page_results.len()) < total as usize; - Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) - }) - })}, - ); - - // ── 命令执行(run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中, - // 移到末位让 read_file/write_file 等高频文件工具在前,run_command 仅命令执行场景才该用) ── - registry.register( - "run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。", - df_ai::ai_tools::object_schema(vec![ - ("command", "string", true), - ("working_dir", "string", false), - ("timeout_secs", "integer", false), - ]), - RiskLevel::High, - Box::new(|args: serde_json::Value| Box::pin(async move { - // BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致 - // JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str() - // 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸 - // 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。 - let command = args["command"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))? - .to_string(); - // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 - // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 - let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { - Some(d) => { - validate_path(d)?; - d.to_string() - } - None => String::new(), - }; - // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 - // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) - let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); - - let request = ShellRequest { - command: command.clone(), - // 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123) - working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) }, - env: HashMap::new(), - timeout_secs: Some(timeout_secs), - shell_type: Default::default(), - }; - // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 - // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() - // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → - // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 - // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, - // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 - // - // 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`), - // LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、 - // 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。 - let shell_hint = if cfg!(windows) { - "\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)" - } else { - "" - }; - let result = execute(request).await.map_err(|e| { - let msg = e.to_string(); - if msg.contains("命令执行超时") { - anyhow::anyhow!( - "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}", - timeout_secs, shell_hint - ) - } else { - anyhow::anyhow!("{}{}", msg, shell_hint) - } - })?; - - // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) - const MAX_OUT: usize = 10_000; - let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); - let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); - - Ok(serde_json::json!({ - "command": command, - "working_dir": working_dir, - "exit_code": result.exit_code, - "duration_ms": result.duration_ms, - "stdout": stdout, - "stderr": stderr, - "truncated": stdout_trunc || stderr_trunc, - })) - })), - ); - - // ── 环境感知 (Low risk, L1 agent 元能力层) ── - // detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。 - // 返回 JSON:{ os, default_shell, python_path, node_path }。 - // - // 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!), - // AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。 - // 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测, - // 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。 - // - // 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后, - // 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。 - // - // 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。 - // 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。 - // - // 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测, - // 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。 - // 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。 - // - // 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因, - // 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。 - registry.register( - "detect_environment", - "探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)", - df_ai::ai_tools::object_schema(vec![]), - RiskLevel::Low, - Box::new(|_args: serde_json::Value| Box::pin(async move { - // 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown) - let os = if cfg!(target_os = "windows") { - "Windows" - } else if cfg!(target_os = "macos") { - "macOS" - } else if cfg!(target_os = "linux") { - "Linux" - } else { - "Unknown" - }; - // 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。 - // 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。 - // 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。 - let default_shell = if cfg!(target_os = "windows") { - "PowerShell" - } else { - "bash" - }; - - // 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测), - // 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。 - let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") { - Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"), - Err(_) => true, // 未设 = 默认开 - }; - - let mut errors: Vec = Vec::new(); - let (python_path, node_path) = if probe_enabled { - // Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。 - // probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。 - // Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。 - let py = if cfg!(target_os = "windows") { - probe_executable("python", "where python", &mut errors).await - } else { - let p3 = probe_executable("python3", "which python3", &mut errors).await; - if p3.is_some() { - p3 - } else { - // python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。 - probe_executable("python", "which python", &mut errors).await - } - }; - let node = if cfg!(target_os = "windows") { - probe_executable("node", "where node", &mut errors).await - } else { - probe_executable("node", "which node", &mut errors).await - }; - (py, node) - } else { - // 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。 - (None, None) - }; - - Ok(serde_json::json!({ - "os": os, - "default_shell": default_shell, - "python_path": python_path, - "node_path": node_path, - "probe_enabled": probe_enabled, - "errors": errors, - })) - })), - ); + super::tools::file::register(registry, allowed_dirs, data_dir); } /// 探测可执行文件路径(which/where 风格),8s 超时兜底。 @@ -2534,7 +1444,7 @@ fn register_file_tools( /// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。 /// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop), /// shell_type 用 default(Windows=PowerShell / Unix=sh)。 -async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec) -> Option { +pub(crate) async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec) -> Option { let request = ShellRequest { command: cmd.to_string(), working_dir: None, @@ -2573,7 +1483,7 @@ async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec) -> O /// /// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部 /// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断 -fn list_dir_recursive<'a>( +pub(crate) fn list_dir_recursive<'a>( path: &'a str, recursive: bool, depth: usize, @@ -2687,7 +1597,7 @@ pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> { } /// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等) -fn is_noise_dir(name: &str) -> bool { +pub(crate) fn is_noise_dir(name: &str) -> bool { const NOISE_DIRS: &[&str] = &[ ".git", "node_modules", "target", "dist", "build", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea", @@ -2701,7 +1611,7 @@ fn is_noise_dir(name: &str) -> bool { /// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理), /// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。 /// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。 -fn is_noise_file(name: &str) -> bool { +pub(crate) fn is_noise_file(name: &str) -> bool { const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"]; NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx)) } @@ -2719,16 +1629,16 @@ fn is_noise_file(name: &str) -> bool { // ============================================================ /// 单行命中(1-based 行号 + 行内容 + 上下文) -struct LineMatch { - line: usize, - content: String, - context: String, +pub(crate) struct LineMatch { + pub line: usize, + pub content: String, + pub context: String, } /// 单文件命中聚合(file=绝对/锚定路径,line_matches 按行号升序) -struct FileGrepHit { - file: String, - line_matches: Vec, +pub(crate) struct FileGrepHit { + pub file: String, + pub line_matches: Vec, } /// 编译单段 glob(* / ? / [seq] / 字面字符)为 regex,锚定 ^...$ 整段匹配文件名。 @@ -2741,7 +1651,7 @@ struct FileGrepHit { /// /// 简化设计:不引入 glob crate(避免新顶层依赖),手写单段 glob→regex 转换。 /// 失败(glob 含非法 regex 构造,如未闭合 `[`)上抛,调用方 map_err 友好提示。 -fn compile_glob_to_regex(glob: &str) -> anyhow::Result { +pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result { let mut out = String::with_capacity(glob.len() + 4); out.push('^'); let mut chars = glob.chars().peekable(); @@ -2792,7 +1702,7 @@ fn compile_glob_to_regex(glob: &str) -> anyhow::Result { /// - 达 `max_results` 命中行数即停止(返 `truncated=true`),`total` 仍累加全部命中数 /// /// 输出聚合到 `out`(按文件分组),`total`/`truncated` 由调用方读后构造响应。 -fn grep_recursive<'a>( +pub(crate) fn grep_recursive<'a>( dir: &'a str, re: &'a regex::Regex, glob: Option<&'a regex::Regex>, @@ -2855,7 +1765,7 @@ fn grep_recursive<'a>( /// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用—— /// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。 /// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。 -async fn grep_one_file( +pub(crate) async fn grep_one_file( path: &std::path::Path, file_name: &str, re: ®ex::Regex, @@ -2962,7 +1872,7 @@ async fn grep_one_file( } /// 递归搜索文件(字符串包含匹配,大小写不敏感) -fn search_files_recursive<'a>( +pub(crate) fn search_files_recursive<'a>( path: &'a str, pattern: &'a str, recursive: bool, diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs new file mode 100644 index 0000000..0386639 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -0,0 +1,1141 @@ +//! 文件系统类 AI 工具声明式注册(register_file_tools 13 个迁入)。 +//! +//! 迁自 `tool_registry.rs::register_file_tools`(原 13 个 read_file/read_symbol/ +//! list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/ +//! grep/search_files/run_command/detect_environment),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_file_tools` 内 async move 块(逻辑等价), +//! 仅闭包包装(`{ let allowed_dirs = ...; Box::new(move |args| { ... Box::pin(async move {...}) }) }`) +//! 改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有 fn/const 改 pub(crate)): +//! DEFAULT_RUN_COMMAND_TIMEOUT_SECS / MAX_RUN_COMMAND_TIMEOUT_SECS / truncate_output / +//! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines / +//! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash / +//! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex / +//! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file / +//! FileGrepHit / LineMatch。 +//! +//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量): +//! - 文件工具主体捕获 `allowed_dirs: Arc>`。 +//! - delete_file 额外捕获 data_dir:PathBuf,用打包 capture +//! `delete_ctx: Arc<(Arc>, PathBuf)>`,handler 首行解包为 +//! `let allowed_dirs = &delete_ctx.0; let data_dir = &delete_ctx.1;`,其余 body 逐字不变。 +//! - run_command/detect_environment 零捕获(原 `Box::new(|args| ...)` 不含 allowed_dirs), +//! 用 `Arc<()>` 占位 capture(handler body 不引用它),对齐 declare_tool_no_capture 测试模式。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use tokio::sync::RwLock; + +use crate::state::AllowedDirs; + +// 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate)) +use crate::commands::ai::tool_registry::{ + apply_line_range, compile_glob_to_regex, compute_file_hash, generate_diff, grep_one_file, + grep_recursive, list_dir_recursive, probe_executable, rename_or_cross_volume_copy, + resolve_anchor_to_lines, resolve_workspace_path_with_allowed, search_files_recursive, + truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS, + FILE_LOCKS, FileGrepHit, +}; + +// run_command 依赖 df_execute::shell::{execute, ShellRequest} + std HashMap +// new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id) +use df_execute::shell::{execute, ShellRequest}; +use df_types::types::new_id; +use std::collections::HashMap; + +/// 注册 13 个文件系统类工具到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(read/symbol/list/file_info/search/grep/detect_environment=Low, +/// write/patch/append/rename=Medium,delete/run_command=High) +/// - handler body 与原 async move 块逐字一致(逻辑零变更,路径授权/risk/diff/patch 全保留) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register( + registry: &mut AiToolRegistry, + allowed_dirs: &Arc>, + data_dir: std::path::PathBuf, +) { + // ── read_file (Low 只读) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "read_file", + "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", + RiskLevel::Low, + schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]), + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) + use tokio::fs::File; + use tokio::io::AsyncReadExt; + let mut file = File::open(path).await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表", + path + ) + } else { + anyhow::anyhow!("无法访问文件 {}: {}", path, e) + } + })?; + let metadata = file.metadata().await + .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; + if metadata.len() > 1_048_576 { + anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); + } + // TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。 + // 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。 + let file_hash = compute_file_hash(&metadata); + // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话) + let mut content = String::new(); + if let Err(e) = file.read_to_string(&mut content).await { + if e.kind() == std::io::ErrorKind::InvalidData { + return Ok(serde_json::json!({ + "path": path, "content": null, "binary": true, + "size": metadata.len(), "file_hash": file_hash, + "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取" + })); + } + anyhow::bail!("读取文件失败: {}", e); + } + // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页 + if let Some(search) = args["search"].as_str() { + const SEARCH_MAX: usize = 50; + let search_offset = args["offset"].as_u64().unwrap_or(0) as usize; + let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize; + // 先收集全部匹配行用于 total 计数,再做 skip/take 分页 + let mut all_matches: Vec = Vec::new(); + for (idx, line) in content.lines().enumerate() { + if line.contains(search) { + all_matches.push(serde_json::json!({ "line": idx + 1, "content": line })); + } + } + let total = all_matches.len(); + let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect(); + let has_more = (search_offset + page_matches.len()) < total; + return Ok(serde_json::json!({ + "path": path, "size": metadata.len(), "file_hash": file_hash, + "search": search, + "matches": page_matches, + "total": total, + "has_more": has_more, + })); + } + // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) + // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, + // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 + // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 + let line_count = content.lines().count(); + let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() { + let lines: Vec<&str> = content.lines().collect(); + let skip = offset as usize; + let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; + let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect(); + let more = (skip + page.len()) < line_count; + (page.join("\n"), Some(skip), more) + } else { + // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), + // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 + // BUG-260623-01: 旧实现固定 take(500) 忽略 limit, + // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。 + const DEFAULT_PREVIEW_LINES: usize = 500; + let limit = args["limit"].as_u64() + .unwrap_or(DEFAULT_PREVIEW_LINES as u64) + .min(2000) as usize; + let page: Vec<&str> = content.lines().take(limit).collect(); + let more = line_count > page.len(); + (page.join("\n"), None, more) + }; + Ok(serde_json::json!({ + "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, + "offset": offset_used, + "returned_lines": result.lines().count(), + "has_more": has_more, + })) + } + ); + + // ── read_symbol (Low 只读, AST 符号解析) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "read_symbol", + "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。", + RiskLevel::Low, + schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]), + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?; + let full = args["full"].as_bool().unwrap_or(false); + let kind_hint = args["kind"].as_str(); + let drill = args["drill"].as_str().filter(|s| !s.is_empty()); + use tokio::fs::File; + use tokio::io::AsyncReadExt; + let mut file = File::open(path).await + .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; + let metadata = file.metadata().await + .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; + if metadata.len() > 1_048_576 { + anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); + } + let file_hash = compute_file_hash(&metadata); + let mut content = String::new(); + if let Err(e) = file.read_to_string(&mut content).await { + if e.kind() == std::io::ErrorKind::InvalidData { + return Ok(serde_json::json!({ + "path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash, + "fallback": true, "reason": "binary", + "suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容", + })); + } + anyhow::bail!("读取文件失败: {}", e); + } + // 调 code_intel 纯函数(三态 + 兜底,不 panic) + Ok(crate::commands::ai::code_intel::read_symbol( + &content, &file_hash, path, symbol, full, kind_hint, drill, + )) + } + ); + + // ── list_directory (Low 只读) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "list_directory", + "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示", + RiskLevel::Low, + schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), + args => { + let p = args["path"].as_str().unwrap_or(""); + if p.is_empty() { + return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); + } + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed(p, &snap)?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let recursive = args["recursive"].as_bool().unwrap_or(false); + let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); + // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) + // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 + let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; + let mut entries = Vec::new(); + let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; + Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) + } + ); + + // ── write_file (Medium 写) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "write_file", + "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", + RiskLevel::Medium, + schema: object_schema(vec![ + ("path", "string", true), + ("content", "string", true), + ("encoding", "string", false), + ]), + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; + // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) + // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec 写字节 + let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); + let bytes: Vec = if encoding.eq_ignore_ascii_case("base64") { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + STANDARD.decode(raw) + .map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))? + } else if encoding.eq_ignore_ascii_case("utf-8") { + raw.as_bytes().to_vec() + } else { + anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding); + }; + // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) + if bytes.len() > 1_048_576 { + anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); + } + let target = std::path::Path::new(path); + // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) + // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 + let old_size: Option = match tokio::fs::metadata(target).await { + Ok(m) if m.len() > 0 => { + let bak = format!("{}.bak", path); + tokio::fs::copy(path, &bak).await + .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; + Some(m.len()) + } + Ok(_) => Some(0), // 空文件(无需备份) + Err(_) => None, // 不存在(新建) + }; + if let Some(parent) = target.parent() { + // FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 + // BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目 + // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。 + if !snap.is_authorized(parent) { + anyhow::bail!("禁止在项目目录之外创建目录"); + } + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; + } + // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) + let tmp = format!("{}.tmp-write", path); + if let Err(e) = tokio::fs::write(&tmp, &bytes).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); + } + if let Err(e) = tokio::fs::rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {}", e)); + } + // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); + // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 + // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) + if old_size.map(|s| s > 0).unwrap_or(false) { + let bak = format!("{}.bak", path); + let _ = tokio::fs::remove_file(&bak).await; + } + // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak + if let Some(old) = old_size { + if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 { + tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len()); + } + } + Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding })) + } + ); + + // ── patch_file (Medium, 三模式互斥) ── + // object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。 + let patch_file_schema = { + let mut props = serde_json::Map::new(); + props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" })); + props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" })); + props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" })); + props.insert("replace_lines".into(), serde_json::json!({ + "type": "object", + "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移", + "properties": { + "start": { "type": "integer", "description": "起始行号(1-based,含)" }, + "end": { "type": "integer", "description": "结束行号(1-based,含)" } + }, + "required": ["start", "end"] + })); + props.insert("anchor".into(), serde_json::json!({ + "type": "object", + "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文", + "properties": { + "start": { "type": "string", "description": "首行子串标记(大小写敏感)" }, + "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" } + }, + "required": ["start", "end"] + })); + props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" })); + props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["path", "new_text"], + }) + }; + declare_tool!( + registry, + allowed_dirs: Arc>, + "patch_file", + "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", + RiskLevel::Medium, + schema: patch_file_schema, + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let new_text = args["new_text"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; + + // F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor) + // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) + let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); + let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); + let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false); + let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count(); + if mode_count == 0 { + anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一"); + } + if mode_count > 1 { + anyhow::bail!( + "模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)", + mode_count + ); + } + + // 文件存在性 / 大小限制(三模式通用,先于内容读取) + let target = std::path::Path::new(path); + if !target.exists() { + anyhow::bail!("文件不存在: {}", path); + } + let file_meta = tokio::fs::metadata(target).await + .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; + if file_meta.len() > 1_048_576 { + anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len()); + } + + // TD-260621-03:读改写整体锁内防 lost update。 + // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件: + // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。 + // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。 + // 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。 + let _patch_guard = FILE_LOCKS.lock().await; + + // 读文件内容 + 校验(锁内,纯读 + CPU 计算) + use tokio::io::AsyncReadExt; + let mut file = tokio::fs::File::open(path).await + .map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?; + let mut content = String::new(); + file.read_to_string(&mut content).await + .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?; + + // 二进制检测 + if content.contains('\0') { + anyhow::bail!("不支持二进制文件"); + } + + // L3: expected_hash 指纹校验(防外部修改,TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式) + if let Some(expected) = args["expected_hash"].as_str() { + let current_hash = compute_file_hash(&file_meta); + if current_hash != expected { + anyhow::bail!( + "文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容", + expected, current_hash + ); + } + } + + // F-260617-01: 三模式分派计算 new_content + match_count + warning + // old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处) + // replace_lines 模式:行号区间 splice(越界 Err) + // anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err) + let new_content: String; + let match_count: usize; + let warning: Option; + + if has_old_text { + let old_text = args["old_text"].as_str() + .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?; + if old_text.is_empty() { + anyhow::bail!("old_text 不能为空"); + } + if old_text == new_text { + return Ok(serde_json::json!({ + "path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改" + })); + } + // L2: old_text 精确匹配(CAS 语义) + if !content.contains(old_text) { + anyhow::bail!("未找到目标文本,文件可能已被修改"); + } + let mc = content.matches(old_text).count(); + match_count = mc; + warning = if mc > 1 { + Some(format!("匹配到 {} 处,仅替换第 1 处", mc)) + } else { None }; + new_content = content.replacen(old_text, new_text, 1); + } else if has_replace_lines { + let rl = args.get("replace_lines") + .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?; + let start = rl["start"].as_u64() + .ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize; + let end = rl["end"].as_u64() + .ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize; + new_content = apply_line_range(&content, start, end, new_text)?; + match_count = 1; + warning = None; + } else { + // has_anchor + let an = args.get("anchor") + .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?; + let a_start = an["start"].as_str() + .ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?; + let a_end = an["end"].as_str() + .ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?; + let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?; + new_content = apply_line_range(&content, start_line, end_line, new_text)?; + match_count = 1; + warning = None; + } + + // 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup) + // .bak 备份 + let bak = format!("{}.bak", path); + tokio::fs::copy(path, &bak).await + .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; + + // 原子写: tmp → rename + let tmp = format!("{}.tmp-write", path); + if let Err(e) = tokio::fs::write(&tmp, &new_content).await { + let _ = tokio::fs::remove_file(&tmp).await; + let _ = tokio::fs::remove_file(&bak).await; + return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); + } + if let Err(e) = tokio::fs::rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak)); + } + // 成功:清理 .bak + let _ = tokio::fs::remove_file(&bak).await; + drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁) + + let size_diff = new_content.len() as i64 - content.len() as i64; + // 生成 unified diff 供前端审批卡/审计留痕展示 + // content(旧)与 new_content(新)作用域内可用,无需重读文件 + let diff = generate_diff(&content, &new_content); + let mut result = serde_json::json!({ + "path": path, + "changed": true, + "size_diff": size_diff, + "matches_found": match_count, + "diff": diff, + }); + if let Some(w) = warning { + result["warning"] = serde_json::Value::String(w); + } + Ok(result) + } + ); + + // ── file_info (Low 只读) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "file_info", + "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", + RiskLevel::Low, + schema: object_schema(vec![("path", "string", true)]), + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let p = std::path::Path::new(path); + if !p.exists() { + return Ok(serde_json::json!({ "path": path, "exists": false })); + } + let metadata = tokio::fs::metadata(path).await + .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; + let is_dir = metadata.is_dir(); + let size = metadata.len(); + let modified = metadata.modified() + .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64); + // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) + // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, + // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 + // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, + // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。 + let is_binary = if !is_dir && size > 0 { + use tokio::io::AsyncReadExt; + let file = match tokio::fs::File::open(path).await { + Ok(f) => f, + Err(_) => return Ok(serde_json::json!({ + "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null, + "modified": modified, "is_binary": false, "is_dir": is_dir, + "error": "读取文件头失败" + })), + }; + let mut reader = tokio::io::BufReader::new(file); + let mut sample = vec![0u8; 8192]; + let n = reader.read(&mut sample).await.unwrap_or(0); + sample[..n].contains(&0x00) + } else { false }; + // lines: 文本文件 \n 计数(>2MB 跳过避免全量读) + let lines = if !is_dir && !is_binary && size <= 2_097_152 { + tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64) + } else { None }; + Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir })) + } + ); + + // ── append_file (Medium 追加) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "append_file", + "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", + RiskLevel::Medium, + schema: object_schema(vec![("path", "string", true), ("content", "string", true)]), + args => { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; + if let Some(parent) = std::path::Path::new(path).parent() { + // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 + if !snap.is_authorized(parent) { + anyhow::bail!("禁止在项目目录之外创建目录"); + } + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; + } + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await + .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?; + let bytes = content.as_bytes(); + file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?; + file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?; + let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0); + Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size })) + } + ); + + // ── delete_file (High, 双捕获 allowed_dirs + data_dir) ── + // declare_tool! 宏仅支持单 capture。delete_file 同时需 allowed_dirs + data_dir, + // 故打包为 delete_ctx: Arc<(Arc>, PathBuf)> 单 capture, + // handler 首行解包为 (allowed_dirs, data_dir),其余 body 逐字照搬(逻辑零变更)。 + let delete_ctx = Arc::new((allowed_dirs.clone(), data_dir.clone())); + declare_tool!( + registry, + delete_ctx: Arc<(Arc>, std::path::PathBuf)>, + "delete_file", + "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准", + RiskLevel::High, + schema: object_schema(vec![ + ("path", "string", true), + ("permanent", "boolean", false), + ]), + args => { + // 单 capture 宏适配:解包 delete_ctx 取 (allowed_dirs, data_dir),其余 body 逐字照搬 + let allowed_dirs = &delete_ctx.0; + let data_dir = &delete_ctx.1; + // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false); + let target = std::path::Path::new(path); + if !target.exists() { + anyhow::bail!("文件不存在: {}", path); + } + // 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底) + let metadata = tokio::fs::metadata(target).await + .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; + if metadata.is_dir() { + anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path); + } + let file_name = target.file_name() + .ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))? + .to_string_lossy().to_string(); + + if permanent { + // 硬删除:不可逆,对齐 delete_project purge 语义 + tokio::fs::remove_file(path).await + .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?; + Ok(serde_json::json!({ + "path": path, + "deleted": true, + "permanent": true, + "backed_up": false, + })) + } else { + // 软删除:移到 .trash/-,可恢复(对齐 list_trash 机制) + // .trash 放 app_data_dir 下,不依赖编译期 workspace_root + let trash_dir = data_dir.join(".trash"); + tokio::fs::create_dir_all(&trash_dir).await + .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?; + let backup_name = format!("{}-{}", new_id(), file_name); + let backup_path = trash_dir.join(&backup_name); + let backup_path_str = backup_path.to_string_lossy().to_string(); + // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, + // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, + // 失败回滚删 backup 保源完整)。 + // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), + // 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败 + // (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。 + // .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。 + // helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。 + if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await { + anyhow::bail!("移入回收站失败({})", e); + } + Ok(serde_json::json!({ + "path": path, + "deleted": true, + "permanent": false, + "backed_up": true, + "backup_path": backup_path_str, + })) + } + } + ); + + // ── rename_file (Medium 重命名/移动) ── + // 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码 + // 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。 + declare_tool!( + registry, + allowed_dirs: Arc>, + "rename_file", + "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准", + RiskLevel::Medium, + schema: object_schema(vec![ + ("from", "string", true), + ("to", "string", true), + ("overwrite", "boolean", false), + ]), + args => { + // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed, + // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸 + let snap = allowed_dirs.read().await.clone(); + let from_resolved = resolve_workspace_path_with_allowed( + args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?, + &snap, + )?; + let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?; + let to_resolved = resolve_workspace_path_with_allowed( + args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?, + &snap, + )?; + let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?; + let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false); + + // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底) + let from_target = std::path::Path::new(from_path); + if !from_target.exists() { + anyhow::bail!("源文件不存在: {}", from_path); + } + let from_metadata = tokio::fs::metadata(from_target).await + .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?; + if from_metadata.is_dir() { + anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path); + } + let bytes_moved = from_metadata.len(); + + // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败) + let to_target = std::path::Path::new(to_path); + if let Some(parent) = to_target.parent() { + // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 + if !snap.is_authorized(parent) { + anyhow::bail!("禁止在项目目录之外创建目录"); + } + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?; + } + + // 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to + if to_target.exists() { + if !overwrite { + anyhow::bail!("目标已存在且 overwrite=false: {}", to_path); + } + tokio::fs::remove_file(to_path).await + .map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?; + } + + // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题) + // 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级), + // 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。 + let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?; + Ok(serde_json::json!({ + "from": from_path, + "to": to_path, + "renamed": true, + "bytes_moved": bytes_moved, + "cross_volume": cross_volume, + })) + } + ); + + // ── grep (Low 跨文件内容搜索) ── + // 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。 + let grep_schema = { + let mut props = serde_json::Map::new(); + props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" })); + props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" })); + props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" })); + props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] })); + props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" })); + props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" })); + props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 })); + props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["pattern"], + }) + }; + declare_tool!( + registry, + allowed_dirs: Arc>, + "grep", + "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", + RiskLevel::Low, + schema: grep_schema, + args => { + let p = args["path"].as_str().unwrap_or(""); + if p.is_empty() { + return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); + } + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed(p, &snap)?; + let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let pattern = args["pattern"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; + if pattern.is_empty() { + anyhow::bail!("pattern 不能为空"); + } + let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); + let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true); + let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize; + let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content"); + let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize; + + // 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹) + let mut re_builder = regex::RegexBuilder::new(pattern); + re_builder.case_insensitive(case_insensitive); + let (re, _auto_fixed) = match re_builder.build() { + Ok(r) => (r, false), + Err(e) => { + // LLM 常传 `a|b|c` 未分组模式(如 ` (r, true), + Err(_) => anyhow::bail!( + "正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组", + pattern, e + ), + } + } + }; + + // glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。 + // 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。 + let glob_re = match glob_opt { + Some(g) => Some(compile_glob_to_regex(g) + .map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?), + None => None, + }; + + // 递归遍历+逐文件读+行匹配,收集结果 + let mut matches_out: Vec = Vec::new(); + let mut total: usize = 0; + let mut truncated = false; + // BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。 + // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 + // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 + let root_meta = tokio::fs::metadata(root).await + .map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?; + if root_meta.is_file() { + let file_name = std::path::Path::new(root) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + grep_one_file( + std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode, + context_lines, max_results, &mut matches_out, &mut total, &mut truncated, + ).await?; + } else { + grep_recursive( + root, &re, glob_re.as_ref(), output_mode, + context_lines, max_results, 0, 6, + &mut matches_out, &mut total, &mut truncated, + ).await?; + } + + // output_mode 分派返回结构 + let result = match output_mode { + "files_with_matches" => { + // 仅返命中文件路径列表(去重,顺序保留首次命中) + let files: Vec = matches_out.iter() + .map(|h| h.file.clone()) + .collect(); + serde_json::json!({ + "path": root, + "pattern": pattern, + "output_mode": output_mode, + "files": files, + "total": files.len(), + "truncated": truncated, + }) + } + "count" => { + // 每文件命中行数 + let counts: Vec = matches_out.iter() + .map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() })) + .collect(); + let total_files = counts.len(); + serde_json::json!({ + "path": root, + "pattern": pattern, + "output_mode": output_mode, + "counts": counts, + "total": total, + "total_files": total_files, + "truncated": truncated, + }) + } + _ => { + // content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}] + let mut lines: Vec = Vec::new(); + for hit in &matches_out { + for lm in &hit.line_matches { + let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null }; + let mut entry = serde_json::json!({ + "file": hit.file, + "line": line_no, + "content": lm.content, + }); + if context_lines > 0 && !lm.context.is_empty() { + entry["context"] = serde_json::Value::String(lm.context.clone()); + } + lines.push(entry); + } + } + serde_json::json!({ + "path": root, + "pattern": pattern, + "output_mode": "content", + "matches": lines, + "total": total, + "truncated": truncated, + }) + } + }; + Ok(result) + } + ); + + // ── search_files (Low 文件名搜索) ── + declare_tool!( + registry, + allowed_dirs: Arc>, + "search_files", + "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", + RiskLevel::Low, + schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), + args => { + let p = args["path"].as_str().unwrap_or(""); + if p.is_empty() { + return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); + } + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed(p, &snap)?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; + let recursive = args["recursive"].as_bool().unwrap_or(false); + let pattern_lower = pattern.to_lowercase(); + const MAX_RESULTS: usize = 50; + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize; + // 先收集全部结果用于 total 计数,再做 skip/take 分页 + let mut all_results = Vec::new(); + let mut total = 0u64; + // 用较大上限收集全量(分页由内存 skip/take 控制) + search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?; + let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); + let has_more = (offset + page_results.len()) < total as usize; + Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) + } + ); + + // ── run_command (High 零捕获, Arc<()> 占位 capture) ── + // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs(放开目录让 AI 在用户任意项目目录闭环)。 + // declare_tool! 宏需 capture 形参,用 Arc<()> 占位(handler body 不引用它)。 + let dummy: Arc<()> = Arc::new(()); + declare_tool!( + registry, + dummy: Arc<()>, + "run_command", + "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。", + RiskLevel::High, + schema: object_schema(vec![ + ("command", "string", true), + ("working_dir", "string", false), + ("timeout_secs", "integer", false), + ]), + args => { + // BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致 + // JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str() + // 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸 + // 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。 + let command = args["command"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))? + .to_string(); + // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 + // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 + let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { + Some(d) => { + validate_path(d)?; + d.to_string() + } + None => String::new(), + }; + // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 + // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); + + let request = ShellRequest { + command: command.clone(), + // 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123) + working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) }, + env: HashMap::new(), + timeout_secs: Some(timeout_secs), + shell_type: Default::default(), + }; + // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 + // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() + // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → + // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 + // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, + // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 + // + // 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`), + // LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、 + // 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。 + let shell_hint = if cfg!(windows) { + "\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)" + } else { + "" + }; + let result = execute(request).await.map_err(|e| { + let msg = e.to_string(); + if msg.contains("命令执行超时") { + anyhow::anyhow!( + "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}", + timeout_secs, shell_hint + ) + } else { + anyhow::anyhow!("{}{}", msg, shell_hint) + } + })?; + + // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) + const MAX_OUT: usize = 10_000; + let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); + let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); + + Ok(serde_json::json!({ + "command": command, + "working_dir": working_dir, + "exit_code": result.exit_code, + "duration_ms": result.duration_ms, + "stdout": stdout, + "stderr": stderr, + "truncated": stdout_trunc || stderr_trunc, + })) + } + ); + + // ── detect_environment (Low 零捕获, Arc<()> 占位 capture, L1 agent 元能力层) ── + // 返回 JSON:{ os, default_shell, python_path, node_path }。 + let dummy_env: Arc<()> = Arc::new(()); + declare_tool!( + registry, + dummy_env: Arc<()>, + "detect_environment", + "探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)", + RiskLevel::Low, + schema: object_schema(vec![]), + args => { + // 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown) + let os = if cfg!(target_os = "windows") { + "Windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "linux") { + "Linux" + } else { + "Unknown" + }; + // 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。 + // 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。 + // 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。 + let default_shell = if cfg!(target_os = "windows") { + "PowerShell" + } else { + "bash" + }; + + // 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测), + // 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。 + let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") { + Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"), + Err(_) => true, // 未设 = 默认开 + }; + + let mut errors: Vec = Vec::new(); + let (python_path, node_path) = if probe_enabled { + // Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。 + // probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。 + // Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。 + let py = if cfg!(target_os = "windows") { + probe_executable("python", "where python", &mut errors).await + } else { + let p3 = probe_executable("python3", "which python3", &mut errors).await; + if p3.is_some() { + p3 + } else { + // python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。 + probe_executable("python", "which python", &mut errors).await + } + }; + let node = if cfg!(target_os = "windows") { + probe_executable("node", "where node", &mut errors).await + } else { + probe_executable("node", "which node", &mut errors).await + }; + (py, node) + } else { + // 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。 + (None, None) + }; + + Ok(serde_json::json!({ + "os": os, + "default_shell": default_shell, + "python_path": python_path, + "node_path": node_path, + "probe_enabled": probe_enabled, + "errors": errors, + })) + } + ); +} + +// probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn +// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。 diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 4a8737e..900fa0a 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -11,3 +11,4 @@ pub mod list_projects; pub mod project; pub mod task; +pub mod file;