diff --git a/crates/df-ai/src/namespace_store.rs b/crates/df-ai/src/namespace_store.rs index 5f2a4da..fa21051 100644 --- a/crates/df-ai/src/namespace_store.rs +++ b/crates/df-ai/src/namespace_store.rs @@ -12,6 +12,12 @@ use std::time::Instant; /// 引用路径前缀 pub const NAMESPACE_REF_PREFIX: &str = "namespace://"; +/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。 +/// +/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。 +/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。 +pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]"; + /// namespace 字节阈值(> 2048 bytes 触发) pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048; diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index a21a394..c6ba4ef 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -120,8 +120,9 @@ pub(super) async fn maybe_auto_compress( // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 // 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到 - // "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(重启后 namespace - // 清空/残留引用)→ 保留占位符降级,非崩溃。 + // "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(条目被 LRU 淘汰 / + // 重启后 namespace 清空 / 跨会话残留引用)→ 替换为 EVICTED_PLACEHOLDER 提示文案 + // (非保留无意义 namespace:// URI)+ warn 日志。 { let session = session_arc.lock().await; for m in &mut active_msgs { @@ -129,6 +130,13 @@ pub(super) async fn maybe_auto_compress( let path = m.content.clone(); if let Some(original) = session.namespace_store.read_only(&path) { m.content = original.to_string(); + } else { + tracing::warn!( + conv_id = %conv_id, + path = %path, + "[namespace] 引用已淘汰,替换为提示文案" + ); + m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); } } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index defc7a6..43fa785 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -809,7 +809,13 @@ pub(crate) async fn run_agentic_loop( // conv_id 来源:run_agentic_loop 入参(loop 启动快照,与 guard 一致)。 let (stop_flag, notify) = { let session = session_arc.lock().await; - let conv = session.conv_read(&conv_id).expect("[F-09] loop 入口桥接后 per_conv 必存在"); + // panic-guard:原裸 .expect 在 conv 已删(并发删除/状态竞态)时 panic, + // guard 不 reset/终态不发/registry 不清致永久卡。改为 None 显式退出(对齐同函数其他 return 点; + // guard Drop 兜底复位 generating)。 + let Some(conv) = session.conv_read(&conv_id) else { + tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出"); + return; + }; (conv.stop_flag.clone(), conv.notify.clone()) }; @@ -1215,12 +1221,20 @@ pub(crate) async fn run_agentic_loop( // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 - // read_only None(重启后 namespace 清空 / 跨会话残留引用)→ 保留占位符降级,非崩溃。 + // read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→ + // 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。 for m in &mut history_msgs { if df_ai::namespace_store::is_namespace_ref(&m.content) { let path = m.content.clone(); if let Some(original) = session.namespace_store.read_only(&path) { m.content = original.to_string(); + } else { + tracing::warn!( + conv_id = %conv_id, + path = %path, + "[namespace] 引用已淘汰,替换为提示文案" + ); + m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); } } } diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index 9098489..b9d9a5f 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -19,7 +19,7 @@ use super::cache::{find_cached_high_risk_result, pending_placeholder_for}; use super::diff::build_write_file_diff; use super::reason::build_approval_reason; use super::record::audit_tool_call; -use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey}; +use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey}; /// 按 risk_level + auto_exec_mode 判定是否自动执行。 /// @@ -102,6 +102,22 @@ pub(super) async fn check_trust_hits( conv_id: &str, ) -> Option { let key = trust_key_for(&draft.name, args)?; + // 安全门控(run_command 会话信任按目录不按命令的补丁): + // run_command 的信任 key 只用 working_dir,一次批准 → 同目录任意命令 auto。 + // 破坏性命令(rm/del/format/... 不可逆)绝不 auto 放行,即使同目录已批准过, + // 也必须每轮走正常审批。命中黑名单直接返 None,让流程继续走 pending 审批。 + if matches!(key, TrustKey::Execute { .. }) { + if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) { + if is_destructive_command(cmd) { + tracing::info!( + tool = %draft.name, + new_tool_call_id = %draft.id, + "[会话信任] 命中破坏性命令黑名单,跳过 auto 放行(走正常审批)" + ); + return None; + } + } + } // 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key let hit = { let session = session_arc.lock().await; diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 07ca1be..9c185ff 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -139,17 +139,33 @@ async fn execute_with_heartbeat( let _guard = HeartbeatGuard { stop, handle: heartbeat }; // tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步 // 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted - // 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。 - // 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。 - match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await { + // 永不发 → 前端"回答完卡住/超时清空"。外层 timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。 + // 心跳 30s 续命前端 watchdog。 + // + // 外层 timeout 取值(BUF-run-cmd-timeout):原硬编码 60s 会先于 run_command 自带 timeout_secs + // (上限 600s)drop,致 run_command 跑长构建(cargo/npm)永远到不了用户配的 timeout_secs。 + // run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707 + // run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout, + // 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。 + let outer_secs: u64 = if name == "run_command" { + args.get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(60) + .clamp(60, 600) + } else { + 60 + }; + match tokio::time::timeout(Duration::from_secs(outer_secs), tools.execute(name, args)).await { Ok(result) => result, Err(_elapsed) => { tracing::error!( conv_id = %conv_id, tool = %name, - "[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)" + outer_secs, + "[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)", + outer_secs ); - Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)")) + Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs)) } } } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 3a85da6..1c4070b 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -489,6 +489,26 @@ fn dir_of_path_normalized(path: &str) -> String { } } +/// 判定 run_command 的命令串是否「破坏性」(rm/del/format/... 等不可逆操作)。 +/// +/// 用于会话信任放行门控:破坏性命令**永不**走 session_trust auto 放行,即使同会话 +/// 已批准过同目录的 run_command,也必须每轮走正常审批(避免「一次批准同目录 → +/// rm -rf 任意文件被自动放行」的安全漏洞)。 +/// +/// 判定规则:取命令**首 token** 精确匹配,避免前缀法误伤 delta/delve 等以 del 开头的 +/// 合法命令。黑名单只覆盖「不可逆破坏」类;curl/wget/scp 等下载拷贝类的风险(数据外泄/ +/// 恶意脚本)由 run_command 的 High risk 审批(用户每轮看完整命令)覆盖,不进黑名单, +/// 避免误伤频繁合法下载。 +/// 局限:不解析 sudo/doas/env 等提权前缀(`sudo rm` 首 token 是 sudo 不命中)——本层是 +/// 「尽力」补充,主防线仍是 High risk 审批。 +pub fn is_destructive_command(cmd: &str) -> bool { + const DESTRUCTIVE_VERBS: &[&str] = &[ + "rm", "rmdir", "del", "erase", "format", "remove-item", "dd", "mkfs", + ]; + let first = cmd.trim().split_whitespace().next().unwrap_or("").to_lowercase(); + DESTRUCTIVE_VERBS.iter().any(|v| first == *v) +} + /// run_command / write_file 共用的目录 key 规范化: /// 尝试 canonicalize(去 symlink / .. / 大小写归一),失败回退原字面量 trim。 fn normalize_dir_key(dir: &str) -> String { diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index c63da4c..fe9b3cc 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -270,7 +270,21 @@ pub async fn run_workflow_inner( // F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用 let cb_task_id = task_id.clone(); let cb_target_status = target_status.clone(); - tauri::async_runtime::spawn(async move { + // panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。 + // panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove), + // 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。 + // 注:依赖 panic=unwind(tokio 默认),若构建设 panic=abort 则 catch_unwind 不生效(尽力兜底)。 + tauri::async_runtime::spawn({ + // panic 兜底需要这些引用:move 进 catch_unwind 闭包前各取一份 clone 供 panic 分支使用。 + let panic_exec_id = exec_id.clone(); + let panic_db = db.clone(); + let panic_event_bus = event_bus.clone(); + let panic_registry = state_registry.clone(); + async move { + use std::panic::AssertUnwindSafe; + use futures::FutureExt; + // 原闭包主体包进 catch_unwind:panic 以 Err(Box) 返回,Ok 走正常路径。 + let outcome = AssertUnwindSafe(async move { let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone()); // 注册执行器状态机:StateMachine 内部 Arc,clone 共享底层 HashMap, // cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode @@ -364,6 +378,46 @@ pub async fn run_workflow_inner( }) .await; } + }) // 闭 catch_unwind 内 async move + .catch_unwind() + .await; + + // panic 分支:catch_unwind 返回 Err(Box),走已存在 failed 清理路径。 + match outcome { + Ok(_) => {} + Err(panic_payload) => { + // 提取 panic 消息(String/&'static str 常见,其他类型用兜底文案) + let msg = panic_payload + .downcast_ref::() + .map(|s| s.clone()) + .or_else(|| panic_payload.downcast_ref::<&'static str>().map(|s| s.to_string())) + .unwrap_or_else(|| "工作流执行 panic".to_string()); + tracing::error!( + execution_id = %panic_exec_id, + "工作流执行 panic,走 failed 分支兜底: {}", + msg + ); + let workflows = WorkflowRepo::new(&panic_db); + if let Err(e) = workflows.update_field(&panic_exec_id, "status", "failed").await { + tracing::error!("更新工作流状态失败(panic 兜底): {}", e); + } + if let Err(e) = workflows + .update_field(&panic_exec_id, "completed_at", &now_millis()) + .await + { + tracing::error!("更新工作流完成时间失败(panic 兜底): {}", e); + } + panic_registry.lock().await.remove(&panic_exec_id); + panic_event_bus + .send(WorkflowEvent::WorkflowFailed { + execution_id: panic_exec_id.clone(), + error: msg, + failed_node: String::new(), + }) + .await; + } + } + } }); Ok(execution_id.to_string())