Compare commits
3
Commits
b157bc9077
...
4a396f4f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a396f4f88 | ||
|
|
7adaf97377 | ||
|
|
6ffcba7e4d |
@@ -0,0 +1,432 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
||||
.await
|
||||
.unwrap_or_else(|_| EnvSnapshot::fallback());
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
|
||||
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "pwsh".to_string();
|
||||
}
|
||||
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "powershell".to_string();
|
||||
}
|
||||
return "cmd".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
|
||||
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
|
||||
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
||||
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
@@ -39,6 +39,20 @@ impl Node for ScriptNode {
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
|
||||
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
|
||||
// - 黑名单匹配时:直接拒绝执行
|
||||
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
if let Some(denied) = check_command_policy(&cmd_name) {
|
||||
tracing::warn!(
|
||||
command = %command,
|
||||
reason = %denied,
|
||||
"ScriptNode 命令被策略拒绝"
|
||||
);
|
||||
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
|
||||
}
|
||||
|
||||
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
|
||||
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
|
||||
for &kw in &dangerous_keywords {
|
||||
@@ -105,3 +119,41 @@ impl Node for ScriptNode {
|
||||
"script"
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
|
||||
///
|
||||
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
///
|
||||
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
|
||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝
|
||||
///
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令)
|
||||
if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") {
|
||||
let blacklist: Vec<String> = blacklist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)");
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
|
||||
let whitelist: Vec<String> = whitelist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -937,6 +937,25 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||
} else {
|
||||
tracing::info!("v33: pending_approvals 列已存在,跳过");
|
||||
}
|
||||
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
|
||||
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
|
||||
let has_updated_at: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_updated_at {
|
||||
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
|
||||
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
|
||||
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
|
||||
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
|
||||
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
|
||||
}
|
||||
} else {
|
||||
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
|
||||
tracing::info!("迁移 v33 完成");
|
||||
Ok(())
|
||||
|
||||
@@ -823,6 +823,15 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。
|
||||
//
|
||||
// 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5
|
||||
// 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定
|
||||
// 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样
|
||||
// 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。
|
||||
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
|
||||
system_prompt = format!("{}\n\n{}", system_prompt, env_prompt);
|
||||
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
||||
let pinned_goals_snapshot: Vec<String> = {
|
||||
let session = session_arc.lock().await;
|
||||
|
||||
@@ -660,7 +660,12 @@ pub async fn ai_approve(
|
||||
state.ai_tools.execute(&approval.tool_name, args.clone()),
|
||||
).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)),
|
||||
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
|
||||
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
|
||||
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
|
||||
if approval.tool_name == "run_command" && cfg!(windows) {
|
||||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
|
||||
} else { "" })),
|
||||
}
|
||||
};
|
||||
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
|
||||
|
||||
@@ -76,6 +76,12 @@ pub(crate) async fn ensure_conversation_title(
|
||||
return;
|
||||
}
|
||||
|
||||
// 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。
|
||||
// session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后
|
||||
// user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。
|
||||
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
|
||||
let summary_msgs = merge_consecutive_roles(summary_msgs);
|
||||
|
||||
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
|
||||
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
|
||||
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
|
||||
@@ -208,3 +214,36 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
}
|
||||
|
||||
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
|
||||
/// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严,
|
||||
/// 不同 role 不合并(保留边界),仅合并连续同 role。
|
||||
fn merge_consecutive_roles(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
if msgs.is_empty() {
|
||||
return msgs;
|
||||
}
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
|
||||
for m in msgs {
|
||||
if let Some(last) = out.last_mut() {
|
||||
// role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。
|
||||
let same_role = match (&last.role, &m.role) {
|
||||
(MessageRole::System, MessageRole::System)
|
||||
| (MessageRole::User, MessageRole::User)
|
||||
| (MessageRole::Assistant, MessageRole::Assistant)
|
||||
| (MessageRole::Tool, MessageRole::Tool) => true,
|
||||
_ => false,
|
||||
};
|
||||
if same_role {
|
||||
// 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。
|
||||
// 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。
|
||||
if !m.content.is_empty() {
|
||||
last.content.push('\n');
|
||||
last.content.push_str(&m.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2316,8 +2316,13 @@ fn register_file_tools(
|
||||
]),
|
||||
RiskLevel::High,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
// BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
|
||||
// JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
|
||||
// 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
|
||||
// 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
||||
.to_string();
|
||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
@@ -2332,7 +2337,7 @@ fn register_file_tools(
|
||||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||||
|
||||
let request = ShellRequest {
|
||||
command: command.to_string(),
|
||||
command: command.clone(),
|
||||
working_dir: Some(working_dir.clone()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
@@ -2344,15 +2349,24 @@ fn register_file_tools(
|
||||
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
||||
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
||||
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
||||
//
|
||||
// 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
|
||||
// LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
|
||||
// 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
|
||||
let shell_hint = if cfg!(windows) {
|
||||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let result = execute(request).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("命令执行超时") {
|
||||
anyhow::anyhow!(
|
||||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
|
||||
timeout_secs
|
||||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
|
||||
timeout_secs, shell_hint
|
||||
)
|
||||
} else {
|
||||
e
|
||||
anyhow::anyhow!("{}{}", msg, shell_hint)
|
||||
}
|
||||
})?;
|
||||
|
||||
|
||||
@@ -57,11 +57,31 @@ pub fn run() {
|
||||
app.manage(app_state);
|
||||
|
||||
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
|
||||
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
|
||||
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
|
||||
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
|
||||
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::time::Instant::now() - std::time::Duration::from_secs(3600),
|
||||
));
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("ai-client-ready", move |_event| {
|
||||
let session_arc = session_for_handshake.clone();
|
||||
let app_h = app_handle.clone();
|
||||
let last_at = last_handshake_at.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
|
||||
{
|
||||
let mut last = last_at.lock().await;
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < std::time::Duration::from_secs(3) {
|
||||
tracing::info!(
|
||||
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
|
||||
elapsed.as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
*last = std::time::Instant::now();
|
||||
}
|
||||
let mut session = session_arc.lock().await;
|
||||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
||||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<!-- 审批超时:用户未处理审批弹窗时的自动拒绝阅值;0 表示不限时(对齐旧默认) -->
|
||||
<SettingRow ref="rowApprovalTimeout" :label="$t('settings.labelApprovalTimeout')" :desc="$t('settings.descApprovalTimeout')">
|
||||
<select v-model="settings.approvalTimeout" class="setting-select" @change="onApprovalTimeoutChange">
|
||||
<option :value="0">{{ $t('settings.approvalTimeoutUnlimited') }}</option>
|
||||
<option :value="300000">{{ $t('settings.approvalTimeout5m') }}</option>
|
||||
<option :value="900000">{{ $t('settings.approvalTimeout15m') }}</option>
|
||||
<option :value="1800000">{{ $t('settings.approvalTimeout30m') }}</option>
|
||||
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
|
||||
@@ -88,10 +99,13 @@ const settings = reactive({
|
||||
// clampMode 兜底脏值。
|
||||
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
|
||||
logLevel: 'info',
|
||||
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
|
||||
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
|
||||
})
|
||||
|
||||
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
|
||||
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
|
||||
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
|
||||
|
||||
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
|
||||
// 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。
|
||||
@@ -130,6 +144,20 @@ watch(
|
||||
function markSaved() {
|
||||
rowLogLevel.value?.markSaved()
|
||||
}
|
||||
|
||||
// 审批超时合法选项白名单(对齐 <select> option);脏值(外部改库为非法数字)回退默认 15min,
|
||||
// 防 <select v-model> 无匹配 option 致控件空白。允许的取值:0/5/15/30/60 分钟对应毫秒数。
|
||||
const VALID_APPROVAL_TIMEOUTS = [0, 300_000, 900_000, 1_800_000, 3_600_000]
|
||||
function clampApprovalTimeout(raw: number): number {
|
||||
return VALID_APPROVAL_TIMEOUTS.includes(raw) ? raw : 900_000
|
||||
}
|
||||
|
||||
// 审批超时下拉变更:落 KV(df-approval-timeout,ms)+ 显「已保存」反馈。
|
||||
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
|
||||
function onApprovalTimeoutChange() {
|
||||
appSettings.set('df-approval-timeout', settings.approvalTimeout)
|
||||
rowApprovalTimeout.value?.markSaved()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -41,6 +41,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 命令执行安全:ScriptNode 白/黑名单(前端仅 UI + 持久化,后端当前从环境变量读) ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelScriptSafety') }}</h2>
|
||||
</div>
|
||||
<div class="script-safety">
|
||||
<p class="script-safety-desc">{{ $t('settings.descScriptSafety') }}</p>
|
||||
|
||||
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
|
||||
<input
|
||||
v-model="scriptWhitelist"
|
||||
class="setting-select script-safety-input"
|
||||
:placeholder="$t('settings.placeholderScriptWhitelist')"
|
||||
@blur="onScriptWhitelistCommit"
|
||||
@keyup.enter="onScriptWhitelistCommit"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
|
||||
<input
|
||||
v-model="scriptBlacklist"
|
||||
class="setting-select script-safety-input"
|
||||
:placeholder="$t('settings.placeholderScriptBlacklist')"
|
||||
@blur="onScriptBlacklistCommit"
|
||||
@keyup.enter="onScriptBlacklistCommit"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -48,8 +80,11 @@ import { ref, onMounted } from 'vue'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
|
||||
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
|
||||
|
||||
@@ -138,6 +173,27 @@ async function reload() {
|
||||
await load()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ScriptNode 命令执行安全 — 白/黑名单 UI
|
||||
// ------------------------------------------------------------
|
||||
// 前端仅做 UI + 持久化到 appSettings(df-script-whitelist / df-script-blacklist)。
|
||||
// 后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取
|
||||
// (同一逗号分隔语义),前后端打通留后续:前端写入需经 IPC/启动期注入环境变量。
|
||||
// 输入语义:逗号分隔命令名(如 `git,npm,cargo`),空串=该项不限制。
|
||||
// ============================================================
|
||||
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
|
||||
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
|
||||
|
||||
function onScriptWhitelistCommit() {
|
||||
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
|
||||
emit('toast', t('settings.savedHint'), 'info')
|
||||
}
|
||||
|
||||
function onScriptBlacklistCommit() {
|
||||
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
|
||||
emit('toast', t('settings.savedHint'), 'info')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -165,4 +221,17 @@ onMounted(load)
|
||||
.add-row { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.dir-input { flex: 1; min-width: 0; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* ===== ScriptNode 命令执行安全面板 ===== */
|
||||
.script-safety { display: flex; flex-direction: column; gap: 4px; }
|
||||
.script-safety-desc {
|
||||
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
||||
}
|
||||
.script-safety-input {
|
||||
min-width: 280px;
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
}
|
||||
.script-safety-note {
|
||||
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,10 +80,13 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
||||
|
||||
// ===== 安全 =====
|
||||
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
|
||||
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
|
||||
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
||||
|
||||
// ===== 高级 =====
|
||||
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
|
||||
{ category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
|
||||
{ category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' },
|
||||
{ category: 'advanced', key: 'approvalTimeout', labelKey: 'settings.labelApprovalTimeout', descKey: 'settings.descApprovalTimeout' },
|
||||
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
|
||||
]
|
||||
|
||||
@@ -70,8 +70,26 @@ export function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
|
||||
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
|
||||
|
||||
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝
|
||||
const APPROVAL_TIMEOUT_MS = Infinity // 已决策:不做超时,用户不审批就一直等
|
||||
/// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。
|
||||
/// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。
|
||||
const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout'
|
||||
const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟
|
||||
|
||||
/**
|
||||
* 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`,
|
||||
* 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。
|
||||
*
|
||||
* 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效
|
||||
* (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。
|
||||
*/
|
||||
function getApprovalTimeoutMs(): number {
|
||||
const raw = appSettings.get<number>(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS)
|
||||
// 防御:负数 / NaN 视为默认值;0 保留(语义=不限时)
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
|
||||
return APPROVAL_TIMEOUT_DEFAULT_MS
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** toolCallId → 审批超时计时器 */
|
||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
@@ -91,6 +109,9 @@ export function startApprovalTimer(
|
||||
kind: 'risk' | 'path' = 'risk',
|
||||
): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
// 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为)
|
||||
const timeoutMs = getApprovalTimeoutMs()
|
||||
if (timeoutMs === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死
|
||||
@@ -107,7 +128,7 @@ export function startApprovalTimer(
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}, APPROVAL_TIMEOUT_MS)
|
||||
}, timeoutMs)
|
||||
_approvalTimers.set(toolCallId, timer)
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,15 @@ export default {
|
||||
labelLogLevel: 'Log level',
|
||||
descLogLevel: 'Verbosity of workflow logs',
|
||||
|
||||
// ===== Approval timeout =====
|
||||
labelApprovalTimeout: 'Approval timeout',
|
||||
descApprovalTimeout: 'Auto-reject threshold when user does not respond to an approval prompt (only affects approvals started after the change)',
|
||||
approvalTimeoutUnlimited: 'No limit',
|
||||
approvalTimeout5m: '5 minutes',
|
||||
approvalTimeout15m: '15 minutes (default)',
|
||||
approvalTimeout30m: '30 minutes',
|
||||
approvalTimeout60m: '60 minutes',
|
||||
|
||||
labelShowTokenUsage: 'Show token usage',
|
||||
descShowTokenUsage: 'Show token consumption of each reply in conversations',
|
||||
|
||||
@@ -159,6 +168,17 @@ export default {
|
||||
allowedDirsAddFail: 'Failed to add authorized directory',
|
||||
allowedDirsRemoveFail: 'Failed to remove authorized directory',
|
||||
|
||||
// ===== ScriptNode command execution safety =====
|
||||
panelScriptSafety: '🛡️ Command Execution Safety',
|
||||
descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.',
|
||||
labelScriptWhitelist: 'Command whitelist',
|
||||
descScriptWhitelist: 'Comma-separated command names (e.g. git,npm,cargo); empty = unrestricted. When non-empty, only listed commands are allowed.',
|
||||
labelScriptBlacklist: 'Command blacklist',
|
||||
descScriptBlacklist: 'Comma-separated command names (e.g. rm,format,shutdown); any match rejects execution.',
|
||||
placeholderScriptWhitelist: 'Leave empty for no restriction, or: git,npm,cargo',
|
||||
placeholderScriptBlacklist: 'Leave empty for no blacklist, or: rm,format',
|
||||
noteScriptSafety: 'Note: backend ScriptNode currently reads DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST env vars (same comma-separated semantics). This UI only persists on the frontend; wiring to backend is planned for a later release.',
|
||||
|
||||
// ===== Knowledge base panel =====
|
||||
panelKnowledge: '📚 Knowledge base',
|
||||
labelAutoExtract: 'Auto-distill knowledge',
|
||||
|
||||
@@ -125,6 +125,15 @@ export default {
|
||||
labelLogLevel: '日志级别',
|
||||
descLogLevel: '工作流日志详细程度',
|
||||
|
||||
// ===== 审批超时 =====
|
||||
labelApprovalTimeout: '审批超时',
|
||||
descApprovalTimeout: '用户未处理审批弹窗时的自动拒绝阅值(只影响下一笔发起的审批)',
|
||||
approvalTimeoutUnlimited: '不限时',
|
||||
approvalTimeout5m: '5 分钟',
|
||||
approvalTimeout15m: '15 分钟(默认)',
|
||||
approvalTimeout30m: '30 分钟',
|
||||
approvalTimeout60m: '60 分钟',
|
||||
|
||||
labelShowTokenUsage: '显示 Token 用量',
|
||||
descShowTokenUsage: '在对话中展示每次回复的 token 消耗',
|
||||
|
||||
@@ -159,6 +168,17 @@ export default {
|
||||
allowedDirsAddFail: '添加授权目录失败',
|
||||
allowedDirsRemoveFail: '删除授权目录失败',
|
||||
|
||||
// ===== ScriptNode 命令执行安全 =====
|
||||
panelScriptSafety: '🛡️ 命令执行安全',
|
||||
descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。',
|
||||
labelScriptWhitelist: '命令白名单',
|
||||
descScriptWhitelist: '逗号分隔命令名(如 git,npm,cargo);留空表示不限制。非空时仅允许名单内命令执行。',
|
||||
labelScriptBlacklist: '命令黑名单',
|
||||
descScriptBlacklist: '逗号分隔命令名(如 rm,format,shutdown);命中任一项即拒绝执行。',
|
||||
placeholderScriptWhitelist: '留空不限制,或输入:git,npm,cargo',
|
||||
placeholderScriptBlacklist: '留空表示无黑名单,或输入:rm,format',
|
||||
noteScriptSafety: '注:后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取,与同语义逗号分隔。此处配置仅前端持久化,与后端打通待后续版本。',
|
||||
|
||||
// ===== 知识库面板 =====
|
||||
panelKnowledge: '📚 知识库',
|
||||
labelAutoExtract: '自动提炼知识',
|
||||
|
||||
Reference in New Issue
Block a user