修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导
- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
This commit is contained in:
@@ -463,9 +463,9 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,连续追问视为同目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
@@ -751,12 +751,12 @@ pub async fn ai_approve(
|
||||
Ok("completed".to_string())
|
||||
}
|
||||
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||
fn authz_debug(msg: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true)
|
||||
.open("E:/wk-lab/devflow/authz-debug.log")
|
||||
{
|
||||
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
@@ -774,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。
|
||||
///
|
||||
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
|
||||
/// 失败回退 None(对比退化,不报跨盘提示)。
|
||||
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
|
||||
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
|
||||
fn workspace_drive() -> Option<String> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
drive_letter(&root.to_string_lossy())
|
||||
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
|
||||
@@ -1366,6 +1359,13 @@ pub async fn ai_chat_edit(
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||
conv.model_override = model_override.clone();
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -1531,10 +1531,10 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,与 send 路径一致)。
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
|
||||
// pinned_goal 已更新,续跑用新目标)。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
|
||||
@@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
|
||||
"run_command" => {
|
||||
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => normalize_dir_key(d),
|
||||
None => workspace_root_str(),
|
||||
None => String::new(),
|
||||
};
|
||||
Some(TrustKey::Execute { dir })
|
||||
}
|
||||
@@ -423,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
normalize_dir_key(&parent.to_string_lossy())
|
||||
}
|
||||
Err(_) => workspace_root_str(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +783,7 @@ pub struct PerConvState {
|
||||
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。
|
||||
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
|
||||
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goal: Option<String>,
|
||||
|
||||
@@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String {
|
||||
format!("{}_{}", modified.unwrap_or(0), meta.len())
|
||||
}
|
||||
|
||||
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
|
||||
///
|
||||
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
|
||||
@@ -339,19 +330,15 @@ async fn rename_or_cross_volume_copy(
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
|
||||
/// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录。
|
||||
///
|
||||
/// 双层校验:
|
||||
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
|
||||
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
|
||||
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
|
||||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||||
///
|
||||
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
|
||||
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
|
||||
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
|
||||
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
|
||||
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
|
||||
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
|
||||
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
|
||||
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
|
||||
fn resolve_workspace_path_with_allowed(
|
||||
path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
@@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed(
|
||||
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
|
||||
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
|
||||
///
|
||||
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
|
||||
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
|
||||
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
|
||||
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
|
||||
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
|
||||
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
|
||||
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响。
|
||||
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
|
||||
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
|
||||
resolve_workspace_path_impl(path, &AllowedDirs::default())
|
||||
}
|
||||
|
||||
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
|
||||
validate_path(path)?;
|
||||
let root = workspace_root();
|
||||
let resolved = if Path::new(path).is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
|
||||
// 无授权目录时给出明确引导,避免指向编译期常量。
|
||||
let root = allowed.first_persistent_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
|
||||
))?;
|
||||
root.join(path)
|
||||
};
|
||||
// 词法层:防明显越界(不存在路径的兜底)。
|
||||
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
|
||||
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
|
||||
if !allowed.is_authorized(&resolved) {
|
||||
anyhow::bail!("路径不在授权目录内: {}", path);
|
||||
}
|
||||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
|
||||
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
|
||||
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
|
||||
if resolved.exists() {
|
||||
let canon_resolved = resolved.canonicalize()?;
|
||||
if !allowed.is_authorized(&canon_resolved) {
|
||||
@@ -449,10 +436,11 @@ async fn bind_dir_to_project(
|
||||
pub fn build_ai_tool_registry(
|
||||
db: &Arc<Database>,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
data_dir: PathBuf,
|
||||
) -> AiToolRegistry {
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register_data_tools(&mut registry, db);
|
||||
register_file_tools(&mut registry, allowed_dirs);
|
||||
register_file_tools(&mut registry, allowed_dirs, data_dir);
|
||||
register_http_tools(&mut registry);
|
||||
registry
|
||||
}
|
||||
@@ -1412,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
fn register_file_tools(
|
||||
registry: &mut AiToolRegistry,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
data_dir: PathBuf,
|
||||
) {
|
||||
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
|
||||
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
|
||||
@@ -1967,8 +1956,9 @@ fn register_file_tools(
|
||||
("permanent", "boolean", false),
|
||||
]),
|
||||
RiskLevel::High,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
{ let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
Box::pin(async move {
|
||||
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -2004,15 +1994,15 @@ fn register_file_tools(
|
||||
}))
|
||||
} else {
|
||||
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
|
||||
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
|
||||
let trash_dir = workspace_root().join(".trash");
|
||||
// .trash 放 app_data_dir 下,不依赖编译期 workspace_root
|
||||
let trash_dir = data_dir.join(".trash");
|
||||
tokio::fs::create_dir_all(&trash_dir).await
|
||||
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
|
||||
let backup_name = format!("{}-{}", new_id(), file_name);
|
||||
let backup_path = trash_dir.join(&backup_name);
|
||||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||||
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
|
||||
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
|
||||
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
|
||||
// 失败回滚删 backup 保源完整)。
|
||||
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
|
||||
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
|
||||
@@ -2325,16 +2315,14 @@ fn register_file_tools(
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
|
||||
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
|
||||
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
|
||||
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
|
||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => {
|
||||
validate_path(d)?;
|
||||
d.to_string()
|
||||
}
|
||||
None => workspace_root().to_string_lossy().to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
||||
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||||
|
||||
@@ -140,6 +140,21 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool
|
||||
state.ideas.restore(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
||||
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
|
||||
#[tauri::command]
|
||||
pub async fn relate_ideas(
|
||||
state: State<'_, AppState>,
|
||||
subject_id: String,
|
||||
target_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.ideas
|
||||
.sync_related_ids(&subject_id, &target_ids)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
|
||||
#[tauri::command]
|
||||
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
|
||||
@@ -199,10 +214,10 @@ pub async fn promote_idea(
|
||||
..record
|
||||
};
|
||||
if let Err(e) = state.ideas.update_full(&updated).await {
|
||||
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
|
||||
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
|
||||
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
|
||||
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await {
|
||||
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}");
|
||||
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
|
||||
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
|
||||
}
|
||||
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
|
||||
}
|
||||
|
||||
@@ -47,3 +47,9 @@ pub async fn settings_delete(
|
||||
) -> Result<bool, String> {
|
||||
state.settings.delete(&key).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 获取 DevFlow 数据目录路径(运行期确定,跨平台)
|
||||
#[tauri::command]
|
||||
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
|
||||
Ok(state.data_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ pub fn run() {
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let db_path = data_dir.join(db_name);
|
||||
|
||||
// 初始化全局状态(打开数据库 + 执行迁移)
|
||||
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?;
|
||||
// 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
|
||||
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
|
||||
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
|
||||
if let Err(e) = tauri::async_runtime::block_on(
|
||||
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
|
||||
@@ -272,6 +272,7 @@ pub fn run() {
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
commands::idea::update_idea,
|
||||
commands::idea::relate_ideas,
|
||||
commands::idea::delete_idea,
|
||||
commands::idea::restore_idea,
|
||||
commands::idea::list_deleted_ideas,
|
||||
@@ -352,6 +353,7 @@ pub fn run() {
|
||||
commands::settings::settings_set,
|
||||
commands::settings::settings_get_all,
|
||||
commands::settings::settings_delete,
|
||||
commands::settings::get_data_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -393,6 +393,8 @@ pub struct AppState {
|
||||
pub knowledge_events: KnowledgeEventsRepo,
|
||||
/// 知识库行为配置(提取 + 注入)
|
||||
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
|
||||
/// 应用数据目录(运行期确定:Windows $APPDATA/devflow, macOS ~/Library/Application Support/devflow)
|
||||
pub data_dir: PathBuf,
|
||||
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
|
||||
pub settings: SettingsRepo,
|
||||
// ── LLM 并发控制 ──
|
||||
@@ -468,6 +470,12 @@ impl AllowedDirs {
|
||||
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
|
||||
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
|
||||
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
|
||||
self.persistent.iter().next()
|
||||
}
|
||||
|
||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
|
||||
@@ -577,11 +585,16 @@ pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||||
// 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else if let Some(root) = allowed.first_persistent_dir() {
|
||||
root.join(raw_path)
|
||||
} else {
|
||||
workspace_root_path().join(raw_path)
|
||||
// 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
|
||||
return PathAuthDecision::NeedsAuthorization {
|
||||
dir: PathBuf::from("."),
|
||||
};
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
@@ -597,7 +610,7 @@ pub fn check_path_authorization(
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| workspace_root_path());
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
@@ -638,11 +651,15 @@ fn best_effort_canonicalize(p: &Path) -> PathBuf {
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||||
/// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。
|
||||
///
|
||||
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器
|
||||
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md
|
||||
/// (workspace_root 分发适配)。当前仅开发机自用有效。
|
||||
/// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。
|
||||
/// 目标方案(D 混合策略):workspace_root 改为运行期动态确定,从已绑定项目的
|
||||
/// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
|
||||
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
|
||||
/// 已修复:
|
||||
/// - authz_debug → std::env::temp_dir() (chat.rs)
|
||||
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
@@ -651,15 +668,25 @@ fn workspace_root_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 应用数据目录(运行期确定,跨平台)。
|
||||
/// 不依赖编译期常量,打包分发后仍有效。
|
||||
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
|
||||
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
|
||||
pub fn data_dir_path(state: &AppState) -> &Path {
|
||||
&state.data_dir
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
|
||||
pub async fn init(db_path: &Path) -> Result<Self> {
|
||||
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
|
||||
let db = Arc::new(Database::open(db_path).await?);
|
||||
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
|
||||
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
|
||||
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default()));
|
||||
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(
|
||||
&db, &allowed_dirs, data_dir.clone(),
|
||||
));
|
||||
// Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
|
||||
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
|
||||
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
|
||||
@@ -690,6 +717,7 @@ impl AppState {
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
knowledge_events: KnowledgeEventsRepo::new(&db),
|
||||
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
|
||||
data_dir: data_dir.clone(),
|
||||
settings: SettingsRepo::new(&db),
|
||||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||
@@ -722,6 +750,19 @@ impl AppState {
|
||||
state.reload_allowed_dirs().await;
|
||||
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
|
||||
state.reload_knowledge_config().await;
|
||||
|
||||
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
|
||||
let old_trash = workspace_root_path().join(".trash");
|
||||
let new_trash = data_dir.join(".trash");
|
||||
if old_trash.exists() && !new_trash.exists() {
|
||||
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
|
||||
tracing::warn!(
|
||||
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
|
||||
old_trash, new_trash, e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -853,14 +894,10 @@ impl AppState {
|
||||
|
||||
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
|
||||
pub async fn get_allowed_dirs(&self) -> Vec<String> {
|
||||
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
|
||||
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
|
||||
let root = workspace_root_path();
|
||||
let guard = self.allowed_dirs.read().await;
|
||||
let mut out: Vec<String> = guard
|
||||
.persistent
|
||||
.iter()
|
||||
.filter(|p| **p != root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -1336,4 +1373,3 @@ mod tests {
|
||||
assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user