@@ -81,7 +81,7 @@ pub fn register(
& snap ,
) ? ;
let path = resolved . to_str ( ) . ok_or_else ( | | anyhow ::anyhow! ( " 路径含非法字符 " ) ) ? ;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2: 原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
// 单次 File::open 取 metadata+read 消除 TOCTOU原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio ::fs ::File ;
use tokio ::io ::AsyncReadExt ;
let mut file = File ::open ( path ) . await
@@ -143,7 +143,7 @@ pub fn register(
} ) ) ;
}
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
// 无 offset 时旧实现 content.clone() 全量返回大文件,
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content . lines ( ) . count ( ) ;
@@ -157,7 +157,7 @@ pub fn register(
} else {
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
// 旧实现固定 take(500) 忽略 limit,
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
const DEFAULT_PREVIEW_LINES : usize = 500 ;
let limit = args [ " limit " ] . as_u64 ( )
@@ -319,7 +319,7 @@ pub fn register(
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)
// 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 ( ) ;
@@ -348,7 +348,7 @@ pub fn register(
) ? ;
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 等)
// encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args . get ( " encoding " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " utf-8 " ) ;
let bytes : Vec < u8 > = if encoding . eq_ignore_ascii_case ( " base64 " ) {
@@ -360,7 +360,7 @@ pub fn register(
} else {
anyhow ::bail! ( " 不支持的 encoding: {}(仅 utf-8 / base64) " , encoding ) ;
} ;
// 写入大小上限(FR-S2: 原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
// 写入大小上限原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if bytes . len ( ) > 1_048_576 {
anyhow ::bail! ( " 写入内容超过 1MB 限制 ({} 字节) " , bytes . len ( ) ) ;
}
@@ -378,8 +378,8 @@ pub fn register(
Err ( _ ) = > None , // 不存在(新建)
} ;
if let Some ( parent ) = target . parent ( ) {
// FR-S8: parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
// BUG-260623-01: 原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
// 原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
if ! snap . is_authorized ( parent ) {
anyhow ::bail! ( " 禁止在项目目录之外创建目录 " ) ;
@@ -397,7 +397,7 @@ pub fn register(
let _ = tokio ::fs ::remove_file ( & tmp ) . await ;
return Err ( anyhow ::anyhow! ( " 原子替换失败: {} " , e ) ) ;
}
// R-P2-2: rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size . map ( | s | s > 0 ) . unwrap_or ( false ) {
@@ -464,7 +464,7 @@ pub fn register(
let new_text = args [ " new_text " ] . as_str ( )
. ok_or_else ( | | anyhow ::anyhow! ( " 缺少 new_text 参数 " ) ) ? ;
// F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor)
// 三模式互斥校验(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 ) ;
@@ -491,7 +491,7 @@ pub fn register(
anyhow ::bail! ( " 文件超过 1MB 限制 ({} 字节) " , file_meta . len ( ) ) ;
}
// TD-260621-03: 读改写整体锁内防 lost update。
// 读改写整体锁内防 lost update。
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
@@ -528,7 +528,7 @@ pub fn register(
}
}
// F-260617-01: 三模式分派计算 new_content + match_count + warning
// 三模式分派计算 new_content + match_count + warning
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
// replace_lines 模式:行号区间 splice(越界 Err)
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err)
@@ -548,7 +548,7 @@ pub fn register(
} ) ) ;
}
// L2: old_text 精确匹配(CAS 语义)
// 失败时附相近片段提示(AC-5): 把文件里与 old_text 相似的几行列给 LLM,
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
// 退回原提示,不误导。
if ! content . contains ( old_text ) {
@@ -656,7 +656,7 @@ pub fn register(
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)
// is_binary: 流式读前 8KB 检测 \x00
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
@@ -701,7 +701,7 @@ pub fn register(
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 放行
// 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if ! snap . is_authorized ( parent ) {
anyhow ::bail! ( " 禁止在项目目录之外创建目录 " ) ;
}
@@ -779,7 +779,7 @@ pub fn register(
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,
// 软删除跨盘降级同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
@@ -846,7 +846,7 @@ pub fn register(
// 目标父目录不存在则创建(对齐 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 放行
// 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if ! snap . is_authorized ( parent ) {
anyhow ::bail! ( " 禁止在项目目录之外创建目录 " ) ;
}
@@ -959,7 +959,7 @@ pub fn register(
let mut matches_out : Vec < FileGrepHit > = Vec ::new ( ) ;
let mut total : usize = 0 ;
let mut truncated = false ;
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
let root_meta = tokio ::fs ::metadata ( root ) . await
@@ -1103,7 +1103,7 @@ pub fn register(
let mut out = serde_json ::json! ( {
" path " : path , " pattern " : pattern , " results " : page_results , " total " : total , " has_more " : has_more
} ) ;
// AC-5: total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total = = 0 {
out [ " hint " ] = serde_json ::json! ( " 未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配 " ) ;
@@ -1156,7 +1156,7 @@ pub fn register(
timeout_secs : Some ( timeout_secs ) ,
shell_type : Default ::default ( ) ,
} ;
// F-260616-04: 超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
@@ -1391,13 +1391,13 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
}
// ============================================================
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选(AC-5)
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
// ============================================================
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
///
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
/// 治 LLM 拼错文件名/少写扩展名后盲目重试(AC-5) 。低频错误路径(文件不存在),同步 std::fs::read_dir
/// 治 LLM 拼错文件名/少写扩展名后盲目重试。低频错误路径(文件不存在),同步 std::fs::read_dir
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
fn similar_file_names ( dir : & std ::path ::Path , needle : & str , max : usize ) -> Vec < String > {
if needle . is_empty ( ) {
@@ -1445,7 +1445,7 @@ fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<St
scored . into_iter ( ) . map ( | ( _ , s ) | s ) . collect ( )
}
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选(AC-5) 。
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选。
///
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
fn file_not_found_error ( path : & str ) -> anyhow ::Error {