From cf0f5b6a6489edb33d666e309227de6721cfb61d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 12:11:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P8f?= =?UTF-8?q?=20=E5=BC=80=E5=B7=A5=E6=A0=87=20=F0=9F=94=B5=EF=BC=88P8f?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 340bf5a..ff3202e 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -284,8 +284,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - acceptance: "agent_verify: [cargo test mcp_tools] 全局和项目路由的 tools/list 都含 plan_onboarding 与 onboard_project;项目路由上强制本项目,传跨项目参数返回 scope_violation;onboard_project 的 required 含 expected_digest 和 confirmed;annotations 中 plan 为 readOnly、apply 为 idempotent。[pnpm typecheck] 通过。[MCP 全局] 对 enterprise 做 plan 全部 noop,做 apply 结果 done 且各步都是 converged。[读文件] project-context.yaml 已补 plan、digest 与状态语义(T1)。" - 说明:两个工具的 schema 单独成函数(避开 json! 递归上限),dispatch 里只留一行委托,减少与 A4(rmcp)迁移的冲突面;AGENTS.md 架构速查补 src-tauri/src/onboard/。 -### 📋 P8f — register_project 控制面投影 -- status: todo +### 🔵 P8f — register_project 控制面投影 +- status: in_progress - complexity: S - files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs - depends: P8-0 From 0851aa9783afc6e9ef2cb95ce76d8435bcf715b1 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 12:29:26 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(projects/parity):=20register=5Fproject?= =?UTF-8?q?=20=E6=8E=A7=E5=88=B6=E9=9D=A2=E6=8A=95=E5=BD=B1=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E6=9C=AC=E4=BD=93=E6=8B=92=E7=BB=9D=E5=90=8C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=87=8D=E5=A4=8D=E7=99=BB=E8=AE=B0=EF=BC=88?= =?UTF-8?q?P8f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects:从 create_project 抽出登记本体 register_project_records(连接注入), UI create_project 与 MCP register_project 共用;只写数据库,不建目录、不执行 git init、不接入。 拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID; 重复检查与插入放进同一 IMMEDIATE 事务;workspace 插入去掉 OR IGNORE,ID 冲突不再静默成功 - 同目录判定 registration_path_key:去长路径前缀、斜杠统一、合并重复分隔符、去尾部分隔符、 不区分大小写;win_path 与 wsl_path 两列都比较,WSL Linux 路径按 wsl.localhost 换算; 错误信息给出已有 project_id(根治 PR #34 审查 L1/L2:同目录两条登记互相抢占 Codex 托管块) - mcp_tools:新增 register_project(path 必填、name 可选,project_id 由炼境生成 UUID,归入当前空间, structuredContent 返回);只开放在全局路由,is_project_scoped_tool 显式排除;描述写明须用户确认目标 - 批量添加对已登记目录计入 skipped,模板新建与挂载仓库复用同一重复目录检查 - project-context.yaml「MCP 工具接口」段同步(T1) cargo test 335 passed / 0 failed / 5 ignored(新增 10 条),0 warning;pnpm typecheck 通过; 9 处变异全部被对应用例捕获。 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 3 +- src-tauri/src/commands/git_repos.rs | 14 + src-tauri/src/commands/projects.rs | 533 ++++++++++++++++++++- src-tauri/src/commands/templates.rs | 6 + src-tauri/src/mcp_tools.rs | 104 +++- src/components/dashboard/BatchAddModal.tsx | 2 +- 6 files changed, 648 insertions(+), 14 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index aa4b317..b166687 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -17,7 +17,8 @@ contracts: - "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。" + - "src-tauri/src/commands/projects.rs" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记——目录按 projects::registration_path_key 字面规范化比较(去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写;win_path 与 wsl_path 两列都算,WSL 的 Linux 路径按 wsl.localhost 的 UNC 路径换算),错误信息给出已有 project_id;MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID)并以 structuredContent 返回,归入当前空间;描述要求调用前向用户列出目标并确认。批量添加对同一目录跳过(计入 skipped),模板新建与挂载仓库复用同一重复目录检查。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/git_repos.rs b/src-tauri/src/commands/git_repos.rs index 2dd093c..844834e 100644 --- a/src-tauri/src/commands/git_repos.rs +++ b/src-tauri/src/commands/git_repos.rs @@ -155,6 +155,20 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { let platform = input.platform.as_deref().unwrap_or("windows"); + // 同一目录已登记则拒绝(P8f,与登记本体同一检查) + if let Some((existing_id, existing_path)) = + crate::commands::projects::duplicate_registration_of( + &conn, + Some(platform), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + )? + { + return Err(format!( + "该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载" + )); + } + // 创建 project_workspace(若同一 space 已挂载则报错) conn.execute( "INSERT INTO project_workspaces diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 6d772a0..31d1606 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -43,7 +43,7 @@ const PROJECT_VIEW_SQL: &str = " LEFT JOIN git_repos gr ON gr.id = pw.repo_id "; -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] pub struct CreateProjectInput { pub id: String, pub name: String, @@ -179,9 +179,179 @@ pub fn get_project(id: String) -> Result { }) } -#[tauri::command] -pub fn create_project(input: CreateProjectInput) -> Result<(), String> { - let conn = pool().get().map_err(|e| e.to_string())?; +// ── 登记本体(P8f)───────────────────────────────────────────────────────────── + +/// 登记路径的比较键:去 `\\?\` 长路径前缀、`/` 统一为 `\`、合并重复分隔符(保留 UNC 开头的 `\\`)、 +/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 junction。 +pub(crate) fn registration_path_key(path: &str) -> String { + let unified = path.trim().replace('/', "\\"); + let unified = if let Some(rest) = unified.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = unified.strip_prefix(r"\\?\") { + rest.to_string() + } else { + unified + }; + let (prefix, rest) = match unified.strip_prefix(r"\\") { + Some(rest) => (r"\\", rest), + None => ("", unified.as_str()), + }; + let mut collapsed = String::with_capacity(rest.len()); + for ch in rest.chars() { + if ch == '\\' && collapsed.ends_with('\\') { + continue; + } + collapsed.push(ch); + } + format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase() +} + +fn wsl_distro(conn: &rusqlite::Connection) -> String { + conn.query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + [], + |row| row.get(0), + ) + .unwrap_or_else(|_| "Ubuntu".to_string()) +} + +/// 一条登记指向的本机目录:Windows 项目取 win_path;WSL 项目取 wsl_path——Linux 路径按 +/// `\\wsl.localhost\\…` 换算(与 `mcp_inject::project_root_with_conn` 同口径), +/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用。空路径与相对路径一律拒绝。 +fn registration_root( + platform: Option<&str>, + win_path: Option<&str>, + wsl_path: Option<&str>, + distro: &str, +) -> Result { + let is_wsl = platform == Some("wsl"); + let raw = if is_wsl { wsl_path } else { win_path } + .map(str::trim) + .unwrap_or_default(); + if raw.is_empty() { + return Err("项目路径为空:登记须给出已存在目录的绝对路径".to_string()); + } + if is_wsl && raw.starts_with('/') { + let inner = raw.trim_start_matches('/').replace('/', "\\"); + return Ok(std::path::PathBuf::from(format!( + r"\\wsl.localhost\{distro}\{inner}" + ))); + } + let path = std::path::PathBuf::from(raw); + if !path.is_absolute() { + return Err(format!("项目路径不是绝对路径:{raw:?}")); + } + Ok(path) +} + +/// 同一目录的已有登记(按 [`registration_path_key`] 比较,win_path 与 wsl_path 两列都算): +/// 返回其 project_id 与登记路径。 +pub(crate) fn find_registration_by_path( + conn: &rusqlite::Connection, + root: &std::path::Path, +) -> Result, String> { + let distro = wsl_distro(conn); + let key = registration_path_key(&root.to_string_lossy()); + let mut stmt = conn + .prepare("SELECT id, win_path, wsl_path FROM project_workspaces ORDER BY id") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + for row in rows { + let (id, win_path, wsl_path) = row.map_err(|e| e.to_string())?; + let candidates = [ + registration_root(None, win_path.as_deref(), None, &distro), + registration_root(Some("wsl"), None, wsl_path.as_deref(), &distro), + ]; + if let Some(existing) = candidates + .into_iter() + .flatten() + .find(|path| registration_path_key(&path.to_string_lossy()) == key) + { + return Ok(Some((id, existing.display().to_string()))); + } + } + Ok(None) +} + +/// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等 +/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。 +pub(crate) fn ensure_path_not_registered( + conn: &rusqlite::Connection, + root: &std::path::Path, +) -> Result<(), String> { + match find_registration_by_path(conn, root)? { + None => Ok(()), + Some((id, path)) => Err(format!( + "该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记" + )), + } +} + +/// 其他登记入口(批量添加、挂载仓库)复用的重复目录检查:路径缺省或无法解析时不在此拦截, +/// 沿用各入口原有行为;能解析出目录且已登记时返回已有登记。 +pub(crate) fn duplicate_registration_of( + conn: &rusqlite::Connection, + platform: Option<&str>, + win_path: Option<&str>, + wsl_path: Option<&str>, +) -> Result, String> { + match registration_root(platform, win_path, wsl_path, &wsl_distro(conn)) { + Ok(root) => find_registration_by_path(conn, &root), + Err(_) => Ok(None), + } +} + +/// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。 +/// 只写数据库:不建目录、不执行 git init、不接入。 +/// 拒绝:空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记 +/// ([`ensure_path_not_registered`])、已存在的 ID。 +pub(crate) fn register_project_records( + conn: &rusqlite::Connection, + input: &CreateProjectInput, +) -> Result<(), String> { + if input.id.trim().is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + let root = registration_root( + input.platform.as_deref(), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + &wsl_distro(conn), + )?; + if !root.is_dir() { + return Err(format!( + "项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)", + root.display() + )); + } + if input.name.trim().is_empty() { + return Err("项目名称不能为空".to_string()); + } + + // 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条 + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + ensure_path_not_registered(&tx, &root)?; + let id_taken = tx + .query_row( + "SELECT 1 FROM project_workspaces WHERE id = ?1", + params![input.id], + |_| Ok(()), + ) + .optional() + .map_err(|e| e.to_string())? + .is_some(); + if id_taken { + return Err(format!("项目 ID 已存在:{}", input.id)); + } let profile_id = if let Some(ref pid) = input.profile_id { // 快速路径:profile 已存在,直接新建 workspace @@ -189,7 +359,7 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { } else { // 标准路径:先建 profile let pid = format!("prof-{}", input.id); - conn.execute( + tx.execute( "INSERT OR IGNORE INTO project_profiles (id, name, description, type, status, priority, tech_stack, repo_url, upstream_url, default_branch, @@ -215,8 +385,9 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { pid }; - conn.execute( - "INSERT OR IGNORE INTO project_workspaces + // 不用 OR IGNORE:冲突必须报错,不能静默返回成功 + tx.execute( + "INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform, recent_focus, startup_commands, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,datetime('now'))", @@ -233,13 +404,71 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { ) .map_err(|e| { if e.to_string().contains("UNIQUE") { - "Project id already exists".into() + "该项目档案在当前空间已有登记".to_string() } else { e.to_string() } })?; + tx.commit().map_err(|e| e.to_string()) +} - Ok(()) +/// MCP `register_project` 的返回:新登记的 project_id 与目录。 +#[derive(Debug, Serialize)] +pub(crate) struct RegisteredProject { + pub project_id: String, + pub name: String, + pub path: String, + pub space_id: Option, +} + +/// MCP `register_project` 的入参换算(连接由调用方注入):只登记 Windows 目录;project_id 由炼境生成 +/// (UUID,与 UI 选目录新建一致);名称缺省取目录名;归入当前空间(UI 首页按当前空间列项目)。 +/// 校验全部交给 [`register_project_records`]。 +pub(crate) fn register_project_at( + conn: &rusqlite::Connection, + path: &str, + name: Option<&str>, +) -> Result { + let path = path.trim(); + let name = name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) + }); + let space_id: Option = conn + .query_row( + "SELECT id FROM spaces WHERE is_current = 1 ORDER BY created_at LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let input = CreateProjectInput { + id: uuid::Uuid::new_v4().to_string(), + name, + space_id, + win_path: Some(path.to_string()), + platform: Some("windows".to_string()), + ..CreateProjectInput::default() + }; + register_project_records(conn, &input)?; + Ok(RegisteredProject { + project_id: input.id, + name: input.name, + path: path.to_string(), + space_id: input.space_id, + }) +} + +#[tauri::command] +pub fn create_project(input: CreateProjectInput) -> Result<(), String> { + let conn = pool().get().map_err(|e| e.to_string())?; + register_project_records(&conn, &input) } #[tauri::command] @@ -493,10 +722,17 @@ pub struct BatchProjectInput { pub platform: Option, } -/// 批量创建项目(已存在的 workspace id 跳过) +/// 批量创建项目(已存在的 workspace id 或已登记的同一目录跳过) #[tauri::command] pub fn batch_create_projects(items: Vec) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; + batch_register_projects(&conn, items) +} + +fn batch_register_projects( + conn: &rusqlite::Connection, + items: Vec, +) -> Result, String> { let mut skipped = Vec::new(); for item in items { let exists: bool = conn @@ -506,7 +742,14 @@ pub fn batch_create_projects(items: Vec) -> Result std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lj_p8f_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn workspace_count(conn: &rusqlite::Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM project_workspaces", [], |row| { + row.get(0) + }) + .unwrap() + } + + fn windows_input(id: &str, win_path: &str) -> CreateProjectInput { + CreateProjectInput { + id: id.to_string(), + name: id.to_string(), + win_path: Some(win_path.to_string()), + ..CreateProjectInput::default() + } + } + + #[test] + fn registration_path_key_folds_case_separators_and_trailing_slash() { + let key = registration_path_key(r"E:\Code-Project\My-App"); + for variant in [ + r"e:\code-project\my-app", + "E:/Code-Project/My-App", + r"E:\Code-Project\My-App\", + "E:/Code-Project/My-App/", + r"E:\\Code-Project\\\My-App", + r"\\?\E:\Code-Project\My-App", + " E:\\Code-Project\\My-App ", + ] { + assert_eq!(registration_path_key(variant), key, "{variant}"); + } + assert_ne!( + registration_path_key(r"E:\Code-Project\My-App2"), + key, + "不同目录不得被归并" + ); + assert_eq!( + registration_path_key("//wsl.localhost/Ubuntu/home/u/app/"), + registration_path_key(r"\\wsl.localhost\ubuntu\home\u\app"), + "UNC 保留开头的双分隔符" + ); + assert_eq!( + registration_path_key(r"\\?\UNC\server\share\app"), + registration_path_key(r"\\server\share\app") + ); + assert_eq!(registration_path_key(r"E:\"), registration_path_key("e:")); + } + + #[test] + fn registration_rejects_empty_relative_and_missing_paths_without_side_effects() { + let conn = crate::db::conn_with_schema(); + let missing = std::env::temp_dir().join(format!( + "lj_p8f_missing_{}_never_created", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&missing); + + let cases = [ + ("empty", String::new()), + ("blank", " ".to_string()), + ("relative", "src-tauri".to_string()), + // cargo test 的 cwd 是 crate 根,src 目录真实存在:只靠绝对路径校验拦下 + ("relative_existing", "src".to_string()), + ("drive_relative", "E:projects".to_string()), + ("missing", missing.display().to_string()), + ]; + for (label, path) in &cases { + let error = register_project_records(&conn, &windows_input(label, path)) + .expect_err(&format!("{label} 应被拒绝")); + assert!(!error.is_empty(), "{label}"); + } + assert_eq!(workspace_count(&conn), 0, "被拒绝的登记不得写入任何行"); + let profiles: i64 = conn + .query_row("SELECT COUNT(*) FROM project_profiles", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(profiles, 0, "被拒绝的登记不得留下 profile"); + assert!(!missing.exists(), "登记不建目录"); + + let wsl_empty = CreateProjectInput { + platform: Some("wsl".to_string()), + win_path: Some(r"E:\not-used".to_string()), + ..windows_input("wsl-empty", "") + }; + assert!( + register_project_records(&conn, &wsl_empty).is_err(), + "WSL 项目以 wsl_path 为准,为空即拒绝" + ); + } + + #[test] + fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("dup"); + let canonical = dir.display().to_string(); + register_project_records(&conn, &windows_input("first", &canonical)).unwrap(); + assert!(!dir.join(".git").exists(), "登记不执行 git init"); + + for (index, variant) in [ + canonical.to_uppercase(), + canonical.to_lowercase(), + canonical.replace('\\', "/"), + format!("{canonical}\\"), + format!("{}/", canonical.replace('\\', "/")), + ] + .iter() + .enumerate() + { + let error = + register_project_records(&conn, &windows_input(&format!("dup-{index}"), variant)) + .expect_err(&format!("同一目录的写法 {variant} 应被拒绝")); + assert!( + error.contains("project_id=first"), + "错误里应给出已存在的 project_id: {error}" + ); + } + assert_eq!(workspace_count(&conn), 1, "重复登记不得写入第二行"); + + // UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝 + let via_wsl = CreateProjectInput { + platform: Some("wsl".to_string()), + wsl_path: Some(canonical.to_uppercase()), + ..windows_input("dup-wsl", "") + }; + assert!(register_project_records(&conn, &via_wsl).is_err()); + assert_eq!(workspace_count(&conn), 1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-w', 'w')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, wsl_path, platform) + VALUES ('wsl-app', 'prof-w', '/home/u/app', 'wsl')", + [], + ) + .unwrap(); + let found = find_registration_by_path( + &conn, + std::path::Path::new(r"\\wsl.localhost\UBUNTU\home\u\app\"), + ) + .unwrap(); + assert_eq!(found.map(|(id, _)| id).as_deref(), Some("wsl-app")); + let other = find_registration_by_path( + &conn, + std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\u\app2"), + ) + .unwrap(); + assert!(other.is_none()); + } + + #[test] + fn registration_rejects_taken_id_instead_of_silently_ignoring() { + let conn = crate::db::conn_with_schema(); + let first = temp_project_dir("id_a"); + let second = temp_project_dir("id_b"); + register_project_records( + &conn, + &windows_input("same-id", &first.display().to_string()), + ) + .unwrap(); + let error = register_project_records( + &conn, + &windows_input("same-id", &second.display().to_string()), + ) + .expect_err("ID 已存在应报错,不能静默返回成功"); + assert!(error.contains("same-id"), "{error}"); + let stored: String = conn + .query_row( + "SELECT win_path FROM project_workspaces WHERE id = 'same-id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, first.display().to_string(), "原登记不被改写"); + std::fs::remove_dir_all(&first).unwrap(); + std::fs::remove_dir_all(&second).unwrap(); + } + + #[test] + fn register_project_at_generates_id_defaults_name_and_joins_current_space() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO spaces (id, name, local_root, is_current) VALUES ('home', 'home', '', 1)", + [], + ) + .unwrap(); + let dir = temp_project_dir("mcp"); + let registered = + register_project_at(&conn, &format!(" {} ", dir.display()), None).unwrap(); + + assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok()); + assert_eq!( + registered.name, + dir.file_name().unwrap().to_string_lossy(), + "名称缺省取目录名" + ); + assert_eq!(registered.space_id.as_deref(), Some("home")); + let (win_path, platform, space): (String, String, Option) = conn + .query_row( + "SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1", + params![registered.project_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!( + win_path, + dir.display().to_string(), + "存入去掉首尾空白的路径" + ); + assert_eq!(platform, "windows"); + assert_eq!(space.as_deref(), Some("home")); + + let again = register_project_at(&conn, &dir.display().to_string(), Some("别名")) + .expect_err("同一目录再次登记应被拒绝"); + assert!(again.contains(®istered.project_id), "{again}"); + assert!(register_project_at(&conn, "", None).is_err()); + assert_eq!(workspace_count(&conn), 1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn batch_registration_skips_directories_already_registered() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("batch"); + let path = dir.display().to_string(); + register_project_records(&conn, &windows_input("existing", &path)).unwrap(); + let item = |id: &str, win_path: String| BatchProjectInput { + id: id.to_string(), + name: id.to_string(), + win_path: Some(win_path), + wsl_path: None, + status: "active".to_string(), + priority: "mid".to_string(), + space_id: None, + platform: Some("windows".to_string()), + }; + let fresh = r"Z:\lj-p8f-batch-fresh".to_string(); + let skipped = batch_register_projects( + &conn, + vec![ + item("dup", path.to_uppercase()), + item("fresh", fresh.clone()), + item("fresh-again", format!("{}\\", fresh.to_lowercase())), + ], + ) + .unwrap(); + assert_eq!(skipped, vec!["dup".to_string(), "fresh-again".to_string()]); + assert_eq!(workspace_count(&conn), 2); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs index c82cba8..58c779a 100644 --- a/src-tauri/src/commands/templates.rs +++ b/src-tauri/src/commands/templates.rs @@ -227,6 +227,12 @@ pub fn create_project_from_template( if project_root.exists() { return Err(format!("目录已存在: {}", project_win_path)); } + // 模板新建会先建目录,过不了登记本体的「目录须已存在」校验,只复用其重复目录检查(P8f): + // 目录已删但登记仍在时,不能再新建出第二条同目录登记 + { + let conn = db::pool().get().map_err(|e| e.to_string())?; + crate::commands::projects::ensure_path_not_registered(&conn, project_root)?; + } // ── 2. 创建根目录 ───────────────────────────────────────────────────────── let _ = app.emit("template:progress", "__step:1"); diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index c9df25d..c988f5c 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -400,11 +400,32 @@ pub fn tools_list_result() -> Value { if let Value::Array(envoy_tools) = envoy_tool_schemas() { tools.splice(after_list..after_list, envoy_tools); } + let after_scan = tools + .iter() + .position(|tool| tool["name"] == "scan_unregistered_projects") + .map_or(tools.len(), |index| index + 1); + tools.insert(after_scan, register_project_schema()); } apply_tool_annotations(&mut result); result } +/// 登记项目(P8f,单独成函数:并入主清单会超出 json! 宏递归上限)。只开放在全局地址。 +fn register_project_schema() -> Value { + json!({ + "name": "register_project", + "description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。project_id 由炼境生成并返回。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "项目目录的 Windows 绝对路径,目录须已存在(如 E:\\code-project\\my-app)" }, + "name": { "type": "string", "description": "可选。项目显示名;省略则取目录名" } + }, + "required": ["path"] + } + }) +} + /// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限) fn envoy_tool_schemas() -> Value { json!([ @@ -447,7 +468,11 @@ fn is_project_scoped_tool(tool: &Value) -> bool { if name == "get_diagnostics" { return true; } - if matches!(name, "unregister_project" | "setup_gitea_repo") { + // 登记与删除登记属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id) + if matches!( + name, + "register_project" | "unregister_project" | "setup_gitea_repo" + ) { return false; } tool.get("inputSchema") @@ -738,6 +763,12 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .as_str() .unwrap_or("接入验收结构化结果已返回") .to_string(), + "register_project" => format!( + "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", + value["project_id"].as_str().unwrap_or("?"), + value["name"].as_str().unwrap_or("?"), + value["path"].as_str().unwrap_or("?") + ), _ => "结构化结果已返回".to_string(), } } @@ -748,6 +779,7 @@ fn tool_success_response(tool_name: &str, text: String) -> Value { "get_toolchain_status", "get_project_resource_status", "verify_onboarding", + "register_project", ]; if STRUCTURED_TOOLS.contains(&tool_name) { if let Ok(structured) = serde_json::from_str::(&text) { @@ -1208,6 +1240,13 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), + "register_project" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let registered = crate::commands::projects::register_project_at(&conn, path, name)?; + serde_json::to_string_pretty(®istered).map_err(|e| e.to_string()) + } "unregister_project" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); if pid.is_empty() { @@ -2529,6 +2568,69 @@ mod tests { assert_eq!(global_write["structuredContent"]["code"], "scope_violation"); } + #[test] + fn register_project_is_global_only_write_tool_requiring_user_confirmation() { + let global = tools_list_result(); + let tool = global["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "register_project") + .expect("全局路由应提供 register_project") + .clone(); + assert_eq!(tool["inputSchema"]["required"], json!(["path"])); + assert_eq!(tool["annotations"]["readOnlyHint"], false); + assert_eq!(tool["annotations"]["destructiveHint"], false); + let description = tool["description"].as_str().unwrap(); + for phrase in ["用户", "确认", "不建目录", "git init", "project_id"] { + assert!(description.contains(phrase), "描述应写明「{phrase}」"); + } + + let scoped = tools_list_result_for_project("p1"); + assert!( + !scoped["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "register_project"), + "项目路由的 tools/list 不得含 register_project" + ); + // 日后 schema 若带上 project_id,也不能因此漏进项目路由 + let mut with_project_id = tool; + with_project_id["inputSchema"]["properties"]["project_id"] = json!({"type": "string"}); + assert!(!is_project_scoped_tool(&with_project_id)); + } + + #[tokio::test] + async fn register_project_over_project_route_is_scope_violation_before_io() { + let response = tools_call_for_project( + Some(&json!({ + "name": "register_project", + "arguments": { "path": "E:\\somewhere" } + })), + "p1", + ) + .await; + assert_eq!(response["isError"], true); + assert_eq!(response["structuredContent"]["code"], "scope_violation"); + } + + #[test] + fn register_project_response_carries_structured_project_id() { + let text = json!({ + "project_id": "0b6c", + "name": "my-app", + "path": "E:\\code\\my-app", + "space_id": null + }) + .to_string(); + let response = tool_success_response("register_project", text); + assert_eq!(response["structuredContent"]["project_id"], "0b6c"); + let summary = response["content"][0]["text"].as_str().unwrap(); + assert!(summary.contains("project_id=0b6c"), "{summary}"); + assert!(summary.contains("尚未接入"), "{summary}"); + } + #[test] fn envoy_tools_split_between_project_scope_and_control_plane() { let scoped = tools_list_result_for_project("p1"); diff --git a/src/components/dashboard/BatchAddModal.tsx b/src/components/dashboard/BatchAddModal.tsx index ad27997..266641e 100644 --- a/src/components/dashboard/BatchAddModal.tsx +++ b/src/components/dashboard/BatchAddModal.tsx @@ -99,7 +99,7 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) { qc.invalidateQueries({ queryKey: ["projects"] }); if (skipped.length > 0) { - showToast(`已添加 ${selected.length - skipped.length} 个,跳过 ${skipped.length} 个(ID 已存在)`); + showToast(`已添加 ${selected.length - skipped.length} 个,跳过 ${skipped.length} 个(ID 已存在或目录已登记)`); } else { showToast(`成功添加 ${selected.length} 个项目 ✓`); } From bd4ebaf09625b5422b577547e519c16e83e7beb8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 12:29:49 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P8f?= =?UTF-8?q?=20=E8=AE=B0=E5=AE=9E=E7=8E=B0=E3=80=81=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E8=A7=84=E5=88=99=E4=B8=8E=E9=AA=8C=E8=AF=81=EF=BC=8C?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=AE=9E=E6=B5=8B=E5=BE=85=20P8e=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=90=8E=E7=94=B1=E7=BB=9F=E7=AD=B9=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=88P8f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片保持 🔵:acceptance 的 [MCP 全局] register_project → plan_onboarding → onboard_project 依赖 P8e 才有的两个 MCP 工具。另记行为变化(UI 添加项目改为拒绝空路径、相对或不存在的路径、 ID 冲突)、其他登记入口的取舍、没验什么、known_limits 与有意偏差。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index ff3202e..5e87eaf 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -287,10 +287,19 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ### 🔵 P8f — register_project 控制面投影 - status: in_progress - complexity: S -- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs +- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/templates.rs(实施扩展), src-tauri/src/commands/git_repos.rs(实施扩展), src/components/dashboard/BatchAddModal.tsx(实施扩展), docs/ai-context/project-context.yaml(T1) - depends: P8-0 - acceptance: "agent_verify: [cargo test] 空路径、相对路径、不存在的路径、重复登记都被拒绝,不建目录、不执行 git init。[MCP 全局] 对临时 git 仓库依次调用 register_project、plan_onboarding、onboard_project,结果为 done;项目路由的 tools/list 不含 register_project。" - 为什么(Q7):create_project 只写 DB、MCP 里也没有登记项目的工具,agent 复刻不了人「新建 → 接入」的操作路径(违反同源同边界)。只开放在全局路由,写操作须用户确认;抽出 create_project 本体,连接由调用方注入。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8f):projects.rs 抽出登记本体 `register_project_records(conn, input)`(连接注入),UI `create_project` 只做取连接的薄包装,MCP `register_project` 经 `register_project_at(conn, path, name)` 换算入参后调同一本体——边界只写一处。本体只写数据库:不建目录、不执行 git init、不接入;依次拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;重复检查与 profile+workspace 插入放在同一 IMMEDIATE 事务(沿用 envoy 先查后插的做法),并发登记同一目录时后到者等前者提交后再查。workspace 插入由 `INSERT OR IGNORE` 改为 `INSERT`,ID 冲突不再静默返回成功。MCP `register_project`(schema 单独成函数,插在 scan_unregistered_projects 之后):入参 path(Windows 绝对路径,必填)与可选 name(缺省取目录名);project_id 由炼境生成 UUID(与 UI 选目录新建一致),归入当前空间(UI 首页按当前空间列项目);返回 JSON 并列入 STRUCTURED_TOOLS,structuredContent 带 project_id、name、path、space_id,摘要写明「只写登记、尚未接入,接入先 plan_onboarding 预览、经用户确认再 onboard_project」。is_project_scoped_tool 显式排除 register_project(与 unregister_project、setup_gitea_repo 并列),项目路由 tools/list 不含它,项目路由调用返回 scope_violation;annotations 为非只读、非破坏、非幂等。描述写明调用前须向用户列出目标目录与名称并取得确认。 +- 重复路径规范化(`projects::registration_path_key`,纯字面):去首尾空白 → `/` 统一为 `\` → 去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`)→ 合并重复分隔符(保留 UNC 开头的 `\\`)→ 去尾部分隔符 → 小写。比较对象是每条已有登记的 win_path 与 wsl_path 两列:Windows 路径原样;WSL 的 Linux 路径按 `\\wsl.localhost\\…` 换算(与 mcp_inject::project_root_with_conn 同口径),wsl_path 已是 Windows/UNC 形式的原样使用。命中即拒绝,错误写明「该目录已登记为项目 project_id=<已有 ID>(<登记路径>)……请直接使用该 project_id」。 +- 其他登记入口(UI 取舍):① 模板新建(create_project_from_template)要先建目录、再跑初始化命令、最后入库,过不了本体「目录须已存在」,所以保持原流程不变,只在建目录前复用 `ensure_path_not_registered`(目录已删但登记仍在时,不再新建出第二条同目录登记);② 批量添加(batch_create_projects)保持「跳过不报错」语义,同一目录已登记(含同批前项)计入 skipped,提示文案改为「ID 已存在或目录已登记」,不加存在性与绝对路径校验(扫描结果必为已存在目录);③ 挂载仓库(mount_repo)给出路径时同样拒绝同一目录。 +- 行为变化(报备):UI「添加项目」此前允许不选目录、允许相对或不存在的路径、ID 冲突时静默成功;现在与 MCP 共用本体,这些情况都报错(空路径登记曾让接入链路把文件写进炼境进程 cwd,见 mcp_inject::validate_registered_path 的 2026-07-15 回归)。WSL 平台以 wsl_path 为准校验,Linux 路径的存在性经 `\\wsl.localhost` 判定,settings.wsl_distro 与实际发行版不符时会被拒绝(此时 project_root 同样会解析错,提前暴露)。 +- verification: "2026-10-03:全量 cargo test 335 passed / 0 failed / 5 ignored(master 为 325,新增 10 条),编译 0 warning;pnpm typecheck 通过。projects 新增 7 条:registration_path_key 归并大小写、斜杠方向、尾部分隔符、重复分隔符、长路径前缀,UNC 保留双分隔符,不同目录不归并;空串、空白、相对路径(含 cwd 下真实存在的 src)、盘符相对路径、不存在的目录全部拒绝,零行写入、无孤儿 profile、不存在的目录未被创建;同一临时目录以大写、小写、正斜杠、尾部分隔符等 5 种写法及改走 WSL 平台再登记均被拒绝,错误含 project_id=first,表内仍 1 行,目录内没有 .git;WSL Linux 路径的登记能按 UNC 写法查到;ID 冲突报错且原登记不被改写;register_project_at 生成 UUID、名称取目录名、归入当前空间、存去掉首尾空白的路径,同目录再登记的错误含已有 project_id;批量添加跳过已登记目录与同批重复目录。mcp_tools 新增 3 条:全局 tools/list 含 register_project(required=[path]、非只读非破坏、描述含用户确认、不建目录、git init、project_id),项目路由 tools/list 不含,schema 加上 project_id 后 is_project_scoped_tool 仍为 false;项目路由调用 register_project 返回 scope_violation;成功回执 structuredContent 带 project_id、摘要写明尚未接入。变异验证(先暂存,改源码,跑 projects:: 与 mcp_tools::,再从暂存区还原)9 处全部被杀:去掉重复目录检查(改 IMMEDIATE 事务前后各验一次)、比较键不小写、不去尾部分隔符、不统一斜杠方向、去掉目录存在校验、去掉绝对路径校验、批量添加不跳过同目录、is_project_scoped_tool 不排除 register_project。" +- 没验什么:① acceptance 的 [MCP 全局] 链路 register_project → plan_onboarding → onboard_project 依赖 P8e 才有的两个 MCP 工具,本卡只实现与测试 register_project;**链路实测待 P8e 合并后由统筹会话完成**(要在运行中的炼境上登记一个临时 git 仓库,属写运行中 DB,按授权规则处理),卡片保持 🔵。② 没在运行中的炼境上调过 register_project(未重编译安装,且会写运行中 DB)。③ IMMEDIATE 事务防并发重复只有结构保证,没有并发测试。④ 模板新建与挂载仓库的重复目录检查没有单测(Tauri command 直接取连接池,模板路径还要建目录、跑命令),由共用的 find_registration_by_path 测试间接覆盖。⑤ UI 添加项目的报错只看了代码路径(ProjectModal 以 toast 显示错误),没有人工点过。⑥ 本卡为 S 级,未做独立审查。 +- known_limits: "① 规范化只做字面:`..`、符号链接、junction、8.3 短名与长名不归一;WSL 路径也按不区分大小写比较(Linux 上仅大小写不同的两个目录会被误判为同一目录,极少见)。② update_project 把路径改成一个已登记目录不拦(不在本卡范围);存量的同目录重复登记(如待用户处理的 beast-mode / 940dfbb4)不受影响,仍按统筹文档 §3 由用户执行数据合并。③ 批量添加的 wsl_convert 模式把 E:\x 存成 /mnt/e/x,与同目录的 Windows 登记比较键不同,查不出这类跨写法重复。④ scan_unregistered_projects 判定已登记仍是区分大小写的字面比较,可能把只差大小写的已登记目录列成未登记;对它调 register_project 会被本卡的重复检查拒绝并给出已有 project_id。" +- 有意偏差:① 未加 confirmed 参数——任务要求是「描述写明写操作须用户确认目标」;登记只写炼境 DB、可经 unregister_project 撤销,按 unregister_project 的先例只在描述里约束。② MCP 不收 project_id,统一由炼境生成 UUID,避免 agent 自拟 ID 与已有命名冲突。③ 重复目录检查扩到批量添加、模板新建、挂载仓库三个入口(files 标「实施扩展」):PR #34 审查 L1/L2 的根因是同一目录多条登记,只堵 create_project 和 MCP 两个入口治不了根。 +- T1:新 MCP 工具 register_project 已同步 docs/ai-context/project-context.yaml「MCP 工具接口」段(paths 补 projects.rs,doc 补路由、本体、规范化与入参语义);Tauri command 签名未变,src/lib/commands.ts 无需改动。 ### 📋 P8g — UI 一键接入 - status: todo From 96cfe761f4ad3049c210d97b68957744e6f34853 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 13:15:36 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(projects/parity):=20=E5=A4=84=E7=BD=AE?= =?UTF-8?q?=20P8f=20=E7=8B=AC=E7=AB=8B=E5=AE=A1=E6=9F=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E8=90=BD=E5=BA=93=E8=A7=84=E8=8C=83=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=8A=A0=20canonical=20=E9=94=AE=E6=9F=A5?= =?UTF-8?q?=E9=87=8D=E3=80=81=E6=81=A2=E5=A4=8D=20UI=20=E6=97=A0=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=8D=A0=E4=BD=8D=E3=80=81=E6=8C=82=E8=BD=BD=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=90=8C=E4=BA=8B=E5=8A=A1=EF=BC=88P8f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1:Windows 目录 canonicalize 去长路径前缀后落库,register_project 回执带落库目录; scan_unregistered_projects 的已登记判定与父目录去重改用同一比较键 - M2:UI 添加项目不选目录时沿用既有概念占位(RootRequirement),MCP 入口仍拒绝空路径 - L1:已有登记按 platform 只取有效根目录,切换平台残留列不再误拦 - L2:新目录在写事务外另算 canonical 键,识别 8.3 短名与 .. 段 - L3:mount_repo 抽本体,重复检查前移、profile 与 workspace 同一 IMMEDIATE 事务 - L4(部分):registration_path_key 复用 normalize_dsh_root 与 strip_verbatim_prefix - I3:目录遗留别的 project_id 的 context.json 时回执带 warning - I4:register_project 分发抽成函数,按 schema 参数名测试 - 改动的既有断言:register_project_at 落库由「去首尾空白的原样路径」改为规范形式 Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/git_repos.rs | 128 +++++-- src-tauri/src/commands/projects.rs | 507 +++++++++++++++++++++------ src-tauri/src/mcp_inject.rs | 5 +- src-tauri/src/mcp_tools.rs | 165 +++++++-- 5 files changed, 658 insertions(+), 149 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index b166687..710d1c7 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -18,7 +18,7 @@ contracts: - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" - "src-tauri/src/commands/projects.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记——目录按 projects::registration_path_key 字面规范化比较(去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写;win_path 与 wsl_path 两列都算,WSL 的 Linux 路径按 wsl.localhost 的 UNC 路径换算),错误信息给出已有 project_id;MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID)并以 structuredContent 返回,归入当前空间;描述要求调用前向用户列出目标并确认。批量添加对同一目录跳过(计入 skipped),模板新建与挂载仓库复用同一重复目录检查。" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/git_repos.rs b/src-tauri/src/commands/git_repos.rs index 844834e..f8e65b5 100644 --- a/src-tauri/src/commands/git_repos.rs +++ b/src-tauri/src/commands/git_repos.rs @@ -1,6 +1,6 @@ use crate::db::pool; use crate::models::GitRepo; -use rusqlite::params; +use rusqlite::{params, Transaction, TransactionBehavior}; use serde::Deserialize; // ── 输入结构体 ───────────────────────────────────────────────────────────────── @@ -118,9 +118,25 @@ pub fn update_git_repo_deploy(id: String, input: UpdateGitRepoDeployInput) -> Re #[tauri::command] pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; + mount_repo_records(&conn, &input) +} + +/// 挂载本体(连接由调用方注入):重复目录检查在任何写入之前,profile 与 workspace 在同一 IMMEDIATE +/// 事务里写入——被拒绝(同一目录已登记、同一空间已挂载)时不留孤儿 profile(P8f 审查 L3)。 +fn mount_repo_records(conn: &rusqlite::Connection, input: &MountRepoInput) -> Result<(), String> { + let platform = input.platform.as_deref().unwrap_or("windows"); + // 比较键要读磁盘(WSL 目录可能唤起发行版),放在写事务外算 + let keys = crate::commands::projects::registration_keys_for( + conn, + Some(platform), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + ); + let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; // 读取 git_repo 基础信息 - let repo: GitRepo = conn + let repo: GitRepo = tx .query_row( "SELECT * FROM git_repos WHERE id = ?1", params![input.repo_id], @@ -128,10 +144,21 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { ) .map_err(|e| format!("仓库不存在: {e}"))?; + // 同一目录已登记则拒绝(P8f,与登记本体同一检查) + if let Some(keys) = &keys { + if let Some((existing_id, existing_path)) = + crate::commands::projects::find_registration_by_keys(&tx, keys)? + { + return Err(format!( + "该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载" + )); + } + } + let profile_id = format!("prof-{}", input.workspace_id); // 创建 project_profile(若已存在则忽略) - conn.execute( + tx.execute( "INSERT OR IGNORE INTO project_profiles (id, name, description, repo_url, upstream_url, default_branch, staging_url, prod_url, server_note, status, priority, tech_stack) @@ -153,24 +180,8 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { ) .map_err(|e| e.to_string())?; - let platform = input.platform.as_deref().unwrap_or("windows"); - - // 同一目录已登记则拒绝(P8f,与登记本体同一检查) - if let Some((existing_id, existing_path)) = - crate::commands::projects::duplicate_registration_of( - &conn, - Some(platform), - input.win_path.as_deref(), - input.wsl_path.as_deref(), - )? - { - return Err(format!( - "该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载" - )); - } - // 创建 project_workspace(若同一 space 已挂载则报错) - conn.execute( + tx.execute( "INSERT INTO project_workspaces (id, profile_id, repo_id, space_id, win_path, wsl_path, platform) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", @@ -192,7 +203,7 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { } })?; - Ok(()) + tx.commit().map_err(|e| e.to_string()) } /// 删除仓库注册记录(同时删除关联的 workspace/profile) @@ -203,3 +214,78 @@ pub fn delete_git_repo(id: String) -> Result<(), String> { .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn count(conn: &rusqlite::Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() + } + + fn mount_input(workspace_id: &str, win_path: &std::path::Path) -> MountRepoInput { + MountRepoInput { + workspace_id: workspace_id.to_string(), + repo_id: "repo-1".to_string(), + space_id: None, + win_path: Some(win_path.display().to_string()), + wsl_path: None, + platform: None, + } + } + + /// P8f 审查 L3:重复目录检查在任何写入之前,挂载失败不留孤儿 profile + #[test] + fn mount_rejections_leave_no_orphan_profile() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO git_repos (id, repo_url, name) VALUES ('repo-1', 'https://git.invalid/r1.git', 'r1')", + [], + ) + .unwrap(); + let base = std::env::temp_dir().join(format!("lj_p8f_mount_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (taken, fresh) = (base.join("taken"), base.join("fresh")); + std::fs::create_dir_all(&taken).unwrap(); + std::fs::create_dir_all(&fresh).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-legacy', 'legacy')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path, platform) + VALUES ('old', 'prof-legacy', ?1, 'windows')", + params![taken.display().to_string().to_uppercase()], + ) + .unwrap(); + + let error = mount_repo_records(&conn, &mount_input("w-dup", &taken)) + .expect_err("同一目录已登记应拒绝挂载"); + assert!(error.contains("project_id=old"), "{error}"); + assert_eq!( + count(&conn, "project_profiles"), + 1, + "被拒绝的挂载不留 profile" + ); + + // workspace ID 冲突(profile 是新建的)同样整体回滚 + let error = mount_repo_records(&conn, &mount_input("old", &fresh)) + .expect_err("workspace ID 冲突应报错"); + assert!(!error.is_empty()); + assert_eq!( + count(&conn, "project_profiles"), + 1, + "冲突回滚,新建的 prof-old 不留下" + ); + assert_eq!(count(&conn, "project_workspaces"), 1); + + mount_repo_records(&conn, &mount_input("w-new", &fresh)).unwrap(); + assert_eq!(count(&conn, "project_profiles"), 2); + assert_eq!(count(&conn, "project_workspaces"), 2); + std::fs::remove_dir_all(&base).unwrap(); + } +} diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 31d1606..90da36a 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -181,29 +181,34 @@ pub fn get_project(id: String) -> Result { // ── 登记本体(P8f)───────────────────────────────────────────────────────────── -/// 登记路径的比较键:去 `\\?\` 长路径前缀、`/` 统一为 `\`、合并重复分隔符(保留 UNC 开头的 `\\`)、 -/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 junction。 +/// 登记路径的字面比较键:去首尾空白、`/` 统一为 `\`、去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`), +/// 再按 DSH 白名单键([`mcp_inject::normalize_dsh_root`])合并重复分隔符(保留 UNC 开头的 `\\`)、 +/// 去尾部分隔符、不区分大小写——与 DSH 白名单共用同一实现,口径不分叉。 +/// 不读磁盘:`..`、8.3 短名、junction 等同目录写法由 [`registration_keys`] 的 canonical 键补上。 pub(crate) fn registration_path_key(path: &str) -> String { let unified = path.trim().replace('/', "\\"); - let unified = if let Some(rest) = unified.strip_prefix(r"\\?\UNC\") { - format!(r"\\{rest}") - } else if let Some(rest) = unified.strip_prefix(r"\\?\") { - rest.to_string() - } else { - unified - }; - let (prefix, rest) = match unified.strip_prefix(r"\\") { - Some(rest) => (r"\\", rest), - None => ("", unified.as_str()), - }; - let mut collapsed = String::with_capacity(rest.len()); - for ch in rest.chars() { - if ch == '\\' && collapsed.ends_with('\\') { - continue; - } - collapsed.push(ch); - } - format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase() + mcp_inject::normalize_dsh_root(&mcp_inject::strip_verbatim_prefix(&unified)) +} + +/// 已存在目录的规范形式:`std::fs::canonicalize` 后去掉 `\\?\` 前缀——得到真实大小写与长文件名、 +/// 反斜杠写法,消掉 `.`、`..` 段与尾部分隔符;junction、符号链接与 subst 盘解析到真实目标 +/// (与 DSH 白名单取 realpath 同口径)。目录不存在或无法解析时为 None。 +fn canonical_dir(path: &std::path::Path) -> Option { + std::fs::canonicalize(path).ok().map(|canonical| { + std::path::PathBuf::from(mcp_inject::strip_verbatim_prefix( + &canonical.to_string_lossy(), + )) + }) +} + +/// 待登记目录的比较键:字面键,加上目录存在时的 canonical 键(与字面键不同时)。 +/// 会读磁盘(WSL 目录可能唤起发行版),须在写事务外计算;已有登记一侧只取字面键、不读磁盘。 +fn registration_keys(root: &std::path::Path) -> Vec { + let literal = registration_path_key(&root.to_string_lossy()); + let canonical = canonical_dir(root) + .map(|path| registration_path_key(&path.to_string_lossy())) + .filter(|canonical| canonical != &literal); + std::iter::once(literal).chain(canonical).collect() } fn wsl_distro(conn: &rusqlite::Connection) -> String { @@ -215,9 +220,10 @@ fn wsl_distro(conn: &rusqlite::Connection) -> String { .unwrap_or_else(|_| "Ubuntu".to_string()) } -/// 一条登记指向的本机目录:Windows 项目取 win_path;WSL 项目取 wsl_path——Linux 路径按 -/// `\\wsl.localhost\\…` 换算(与 `mcp_inject::project_root_with_conn` 同口径), -/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用。空路径与相对路径一律拒绝。 +/// 一条登记的有效根目录:Windows 项目(platform 缺省亦然)取 win_path;WSL 项目取 wsl_path—— +/// Linux 路径按 `\\wsl.localhost\\…` 换算(取列与 Linux 路径换算同 `mcp_inject::project_root_with_conn`), +/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用(project_root_with_conn 对这种 wsl_path 会错误拼接, +/// 是已有缺陷,另列后续小修)。空路径与相对路径一律拒绝。 fn registration_root( platform: Option<&str>, win_path: Option<&str>, @@ -244,16 +250,17 @@ fn registration_root( Ok(path) } -/// 同一目录的已有登记(按 [`registration_path_key`] 比较,win_path 与 wsl_path 两列都算): -/// 返回其 project_id 与登记路径。 -pub(crate) fn find_registration_by_path( +/// 已有登记中与给定比较键([`registration_keys`])同一目录的那条,返回其 project_id 与有效根目录。 +/// 每条已有登记只取其有效根目录([`registration_root`],按 platform 取列,与 project_root_with_conn +/// 同一取列口径):切换平台后残留在另一列的旧路径不参与比较,不会误拦。已有行只算字面键、不读磁盘, +/// 可在写事务内调用。 +pub(crate) fn find_registration_by_keys( conn: &rusqlite::Connection, - root: &std::path::Path, + keys: &[String], ) -> Result, String> { let distro = wsl_distro(conn); - let key = registration_path_key(&root.to_string_lossy()); let mut stmt = conn - .prepare("SELECT id, win_path, wsl_path FROM project_workspaces ORDER BY id") + .prepare("SELECT id, platform, win_path, wsl_path FROM project_workspaces ORDER BY id") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { @@ -261,85 +268,155 @@ pub(crate) fn find_registration_by_path( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, )) }) .map_err(|e| e.to_string())?; for row in rows { - let (id, win_path, wsl_path) = row.map_err(|e| e.to_string())?; - let candidates = [ - registration_root(None, win_path.as_deref(), None, &distro), - registration_root(Some("wsl"), None, wsl_path.as_deref(), &distro), - ]; - if let Some(existing) = candidates - .into_iter() - .flatten() - .find(|path| registration_path_key(&path.to_string_lossy()) == key) - { + let (id, platform, win_path, wsl_path) = row.map_err(|e| e.to_string())?; + let Ok(existing) = registration_root( + platform.as_deref(), + win_path.as_deref(), + wsl_path.as_deref(), + &distro, + ) else { + continue; + }; + if keys.contains(®istration_path_key(&existing.to_string_lossy())) { return Ok(Some((id, existing.display().to_string()))); } } Ok(None) } +/// 同一目录的已有登记(会读磁盘算 canonical 键,勿在写事务内调用)。 +pub(crate) fn find_registration_by_path( + conn: &rusqlite::Connection, + root: &std::path::Path, +) -> Result, String> { + find_registration_by_keys(conn, ®istration_keys(root)) +} + +fn duplicate_registration_error(id: &str, path: &str) -> String { + format!( + "该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记" + ) +} + /// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等 -/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。 +/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。会读磁盘,勿在写事务内调用。 pub(crate) fn ensure_path_not_registered( conn: &rusqlite::Connection, root: &std::path::Path, ) -> Result<(), String> { match find_registration_by_path(conn, root)? { None => Ok(()), - Some((id, path)) => Err(format!( - "该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记" - )), + Some((id, path)) => Err(duplicate_registration_error(&id, &path)), } } -/// 其他登记入口(批量添加、挂载仓库)复用的重复目录检查:路径缺省或无法解析时不在此拦截, -/// 沿用各入口原有行为;能解析出目录且已登记时返回已有登记。 +/// 其他登记入口(批量添加、挂载仓库)的比较键:路径缺省或无法解析时为 None(不在此拦截, +/// 沿用各入口原有行为)。会读磁盘,须在写事务外计算。 +pub(crate) fn registration_keys_for( + conn: &rusqlite::Connection, + platform: Option<&str>, + win_path: Option<&str>, + wsl_path: Option<&str>, +) -> Option> { + registration_root(platform, win_path, wsl_path, &wsl_distro(conn)) + .ok() + .map(|root| registration_keys(&root)) +} + +/// 其他登记入口复用的重复目录检查:能解析出目录且已登记时返回已有登记。 pub(crate) fn duplicate_registration_of( conn: &rusqlite::Connection, platform: Option<&str>, win_path: Option<&str>, wsl_path: Option<&str>, ) -> Result, String> { - match registration_root(platform, win_path, wsl_path, &wsl_distro(conn)) { - Ok(root) => find_registration_by_path(conn, &root), - Err(_) => Ok(None), + match registration_keys_for(conn, platform, win_path, wsl_path) { + Some(keys) => find_registration_by_keys(conn, &keys), + None => Ok(None), } } +/// 登记入口对项目目录的要求。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RootRequirement { + /// 必须给出已存在目录的绝对路径(MCP `register_project`)。 + ExistingDir, + /// UI「添加项目」:不选目录(有效路径为空)时沿用既有的概念占位登记——只写 profile 与 workspace, + /// 不校验目录、不查重复目录;给了路径则与 [`RootRequirement::ExistingDir`] 相同。 + /// 是否改为显式的占位概念待用户决定(P8f 审查 M2)。 + ExistingDirOrPlaceholder, +} + /// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。 /// 只写数据库:不建目录、不执行 git init、不接入。 -/// 拒绝:空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记 -/// ([`ensure_path_not_registered`])、已存在的 ID。 +/// 拒绝:空 ID、空路径(UI 概念占位除外,见 [`RootRequirement`])、相对路径、不存在的目录、空名称、 +/// 同一目录已登记([`find_registration_by_keys`])、已存在的 ID。 +/// Windows 目录落库为规范形式([`canonical_dir`]):下游有按字面比较的消费者(scan_unregistered_projects +/// 等),正斜杠、尾部分隔符、8.3 短名、`..` 段原样落库会与它们对不上(P8f 审查 M1);WSL 路径原样落库。 +/// 返回落库的有效根目录(概念占位为 None)。 pub(crate) fn register_project_records( conn: &rusqlite::Connection, input: &CreateProjectInput, -) -> Result<(), String> { + requirement: RootRequirement, +) -> Result, String> { if input.id.trim().is_empty() { return Err("项目 ID 不能为空".to_string()); } - let root = registration_root( - input.platform.as_deref(), - input.win_path.as_deref(), - input.wsl_path.as_deref(), - &wsl_distro(conn), - )?; - if !root.is_dir() { - return Err(format!( - "项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)", - root.display() - )); + let is_wsl = input.platform.as_deref() == Some("wsl"); + let effective_path = if is_wsl { + input.wsl_path.as_deref() + } else { + input.win_path.as_deref() } + .map(str::trim) + .unwrap_or_default(); + let placeholder = + requirement == RootRequirement::ExistingDirOrPlaceholder && effective_path.is_empty(); + // 目录校验与比较键都在写事务外完成:要读磁盘(WSL 目录可能唤起发行版),不能占着写锁 + let checked_root = if placeholder { + None + } else { + let root = registration_root( + input.platform.as_deref(), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + &wsl_distro(conn), + )?; + if !root.is_dir() { + return Err(format!( + "项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)", + root.display() + )); + } + let keys = registration_keys(&root); + let stored = if is_wsl { + root + } else { + canonical_dir(&root).unwrap_or(root) + }; + Some((stored, keys)) + }; if input.name.trim().is_empty() { return Err("项目名称不能为空".to_string()); } + let win_path = match &checked_root { + Some((root, _)) if !is_wsl => Some(root.display().to_string()), + _ => input.win_path.clone(), + }; // 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) .map_err(|e| e.to_string())?; - ensure_path_not_registered(&tx, &root)?; + if let Some((_, keys)) = &checked_root { + if let Some((id, path)) = find_registration_by_keys(&tx, keys)? { + return Err(duplicate_registration_error(&id, &path)); + } + } let id_taken = tx .query_row( "SELECT 1 FROM project_workspaces WHERE id = ?1", @@ -396,7 +473,7 @@ pub(crate) fn register_project_records( profile_id, input.space_id, input.wsl_path, - input.win_path, + win_path, input.platform.as_deref().unwrap_or("windows"), input.recent_focus, input.startup_commands, @@ -409,21 +486,38 @@ pub(crate) fn register_project_records( e.to_string() } })?; - tx.commit().map_err(|e| e.to_string()) + tx.commit().map_err(|e| e.to_string())?; + Ok(checked_root.map(|(root, _)| root)) } -/// MCP `register_project` 的返回:新登记的 project_id 与目录。 +/// MCP `register_project` 的返回:新登记的 project_id 与落库的规范目录。 #[derive(Debug, Serialize)] pub(crate) struct RegisteredProject { pub project_id: String, pub name: String, pub path: String, pub space_id: Option, + /// 目录里遗留的 context.json 写着别的 project_id 时的提示(见 [`stale_context_warning`]) + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +/// 目录里已有 `.lian-jing/context.json` 且 project_id 与新登记不同(曾经接入过、登记已删除后重登): +/// 只读提示,不沿用旧 ID——目录里的 context.json 不作为身份授权依据。读不到或解析不了时不提示。 +fn stale_context_warning(root: &std::path::Path, project_id: &str) -> Option { + let text = std::fs::read_to_string(root.join(".lian-jing").join("context.json")).ok()?; + let context: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?; + let previous = context.get("project_id")?.as_str()?; + (previous != project_id).then(|| { + format!( + "目录里的 .lian-jing/context.json 仍写着旧 project_id={previous}(此前接入过、登记已删除);新登记的 project_id={project_id}。接入(onboard_project)时会把 context.json 改写为新 ID,子项目会出现一处待提交改动;在此之前子项目里的 agent 按旧 ID 调用会失败" + ) + }) } /// MCP `register_project` 的入参换算(连接由调用方注入):只登记 Windows 目录;project_id 由炼境生成 -/// (UUID,与 UI 选目录新建一致);名称缺省取目录名;归入当前空间(UI 首页按当前空间列项目)。 -/// 校验全部交给 [`register_project_records`]。 +/// (UUID,与 UI 选目录新建一致);名称缺省取目录名(按规范形式取真实大小写);归入当前空间 +/// (UI 首页按当前空间列项目)。校验与落库规范化全部交给 [`register_project_records`]。 pub(crate) fn register_project_at( conn: &rusqlite::Connection, path: &str, @@ -435,7 +529,15 @@ pub(crate) fn register_project_at( .filter(|name| !name.is_empty()) .map(str::to_string) .unwrap_or_else(|| { - std::path::Path::new(path) + // 相对路径不解析(否则会按炼境进程 cwd 换算),交给本体拒绝 + let literal = std::path::Path::new(path); + let canonical = literal + .is_absolute() + .then(|| canonical_dir(literal)) + .flatten(); + canonical + .as_deref() + .unwrap_or(literal) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()) @@ -456,19 +558,22 @@ pub(crate) fn register_project_at( platform: Some("windows".to_string()), ..CreateProjectInput::default() }; - register_project_records(conn, &input)?; + let root = register_project_records(conn, &input, RootRequirement::ExistingDir)? + .ok_or_else(|| "登记本体未返回项目目录".to_string())?; + let warning = stale_context_warning(&root, &input.id); Ok(RegisteredProject { project_id: input.id, name: input.name, - path: path.to_string(), + path: root.display().to_string(), space_id: input.space_id, + warning, }) } #[tauri::command] pub fn create_project(input: CreateProjectInput) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; - register_project_records(&conn, &input) + register_project_records(&conn, &input, RootRequirement::ExistingDirOrPlaceholder).map(|_| ()) } #[tauri::command] @@ -1006,9 +1111,25 @@ mod tests { ("missing", missing.display().to_string()), ]; for (label, path) in &cases { - let error = register_project_records(&conn, &windows_input(label, path)) - .expect_err(&format!("{label} 应被拒绝")); + let error = register_project_records( + &conn, + &windows_input(label, path), + RootRequirement::ExistingDir, + ) + .expect_err(&format!("{label} 应被拒绝")); assert!(!error.is_empty(), "{label}"); + // UI 入口只对「不选目录」放行概念占位;给了路径就与 MCP 同样校验 + if !path.trim().is_empty() { + assert!( + register_project_records( + &conn, + &windows_input(label, path), + RootRequirement::ExistingDirOrPlaceholder, + ) + .is_err(), + "UI 入口给了 {label} 路径同样应被拒绝" + ); + } } assert_eq!(workspace_count(&conn), 0, "被拒绝的登记不得写入任何行"); let profiles: i64 = conn @@ -1025,32 +1146,95 @@ mod tests { ..windows_input("wsl-empty", "") }; assert!( - register_project_records(&conn, &wsl_empty).is_err(), + register_project_records(&conn, &wsl_empty, RootRequirement::ExistingDir).is_err(), "WSL 项目以 wsl_path 为准,为空即拒绝" ); } + /// 审查 M2:UI「添加项目」不选目录的概念占位沿用既有行为(是否改为显式占位概念待用户决定); + /// 占位不校验目录、不参与重复目录检查,MCP 入口(ExistingDir)仍拒绝空路径。 + #[test] + fn ui_registration_keeps_pathless_placeholder() { + let conn = crate::db::conn_with_schema(); + for (id, path) in [("concept-a", ""), ("concept-b", " ")] { + let root = register_project_records( + &conn, + &windows_input(id, path), + RootRequirement::ExistingDirOrPlaceholder, + ) + .unwrap_or_else(|error| panic!("{id} 占位登记应成功: {error}")); + assert!(root.is_none(), "占位没有项目目录"); + } + let wsl_placeholder = CreateProjectInput { + platform: Some("wsl".to_string()), + ..windows_input("concept-wsl", "") + }; + register_project_records( + &conn, + &wsl_placeholder, + RootRequirement::ExistingDirOrPlaceholder, + ) + .unwrap(); + assert_eq!(workspace_count(&conn), 3, "多个占位互不拦截"); + let error = register_project_records( + &conn, + &windows_input("concept-a", ""), + RootRequirement::ExistingDirOrPlaceholder, + ) + .expect_err("占位同样不能复用已存在的 ID"); + assert!(error.contains("concept-a"), "{error}"); + assert!( + register_project_records( + &conn, + &windows_input("mcp-empty", ""), + RootRequirement::ExistingDir + ) + .is_err(), + "MCP 入口不接受占位" + ); + assert_eq!(workspace_count(&conn), 3); + } + #[test] fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() { let conn = crate::db::conn_with_schema(); let dir = temp_project_dir("dup"); - let canonical = dir.display().to_string(); - register_project_records(&conn, &windows_input("first", &canonical)).unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + // 本机 TEMP 是 8.3 短名(C:\Users\YANGZH~1\…):raw 与规范形式可能不同 + let raw = dir.display().to_string(); + let canonical = canonical_dir(&dir).unwrap().display().to_string(); + let stored = register_project_records( + &conn, + &windows_input("first", &raw), + RootRequirement::ExistingDir, + ) + .unwrap(); + assert_eq!( + stored.map(|root| root.display().to_string()).as_deref(), + Some(canonical.as_str()) + ); assert!(!dir.join(".git").exists(), "登记不执行 git init"); for (index, variant) in [ + raw.to_uppercase(), + raw.to_lowercase(), + raw.replace('\\', "/"), + format!("{raw}\\"), + format!("{}/", raw.replace('\\', "/")), canonical.to_uppercase(), - canonical.to_lowercase(), - canonical.replace('\\', "/"), - format!("{canonical}\\"), - format!("{}/", canonical.replace('\\', "/")), + // `..` 段只有 canonical 键能识别(审查 L2) + format!(r"{raw}\sub\.."), + format!(r"{canonical}\sub\..\."), ] .iter() .enumerate() { - let error = - register_project_records(&conn, &windows_input(&format!("dup-{index}"), variant)) - .expect_err(&format!("同一目录的写法 {variant} 应被拒绝")); + let error = register_project_records( + &conn, + &windows_input(&format!("dup-{index}"), variant), + RootRequirement::ExistingDir, + ) + .expect_err(&format!("同一目录的写法 {variant} 应被拒绝")); assert!( error.contains("project_id=first"), "错误里应给出已存在的 project_id: {error}" @@ -1061,14 +1245,52 @@ mod tests { // UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝 let via_wsl = CreateProjectInput { platform: Some("wsl".to_string()), - wsl_path: Some(canonical.to_uppercase()), + wsl_path: Some(raw.to_uppercase()), ..windows_input("dup-wsl", "") }; - assert!(register_project_records(&conn, &via_wsl).is_err()); + assert!(register_project_records(&conn, &via_wsl, RootRequirement::ExistingDir).is_err()); assert_eq!(workspace_count(&conn), 1); std::fs::remove_dir_all(&dir).unwrap(); } + /// 审查 L2:新目录以 `..` 段或 8.3 短名写法给出时,靠 canonical 键对上已有登记(已有行用 P8f 之前 + /// 常见的正斜杠 + 尾部分隔符写法落库,只算字面键、不读磁盘)。 + #[test] + fn duplicate_lookup_matches_dotted_or_short_input_by_canonical_key() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("legacy"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let canonical = canonical_dir(&dir).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-legacy', 'legacy')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path, platform) + VALUES ('legacy', 'prof-legacy', ?1, 'windows')", + params![canonical.display().to_string().replace('\\', "/") + "/"], + ) + .unwrap(); + let dotted = dir.join("sub").join(".."); + assert_ne!( + registration_path_key(&dotted.to_string_lossy()), + registration_path_key(&canonical.to_string_lossy()), + "前提:字面键对不上" + ); + // dir 本身在本机是 8.3 短名写法(TEMP 为 C:\Users\YANGZH~1\…),别的机器上与规范形式相同 + for input in [dotted, dir.clone()] { + let found = find_registration_by_path(&conn, &input).unwrap(); + assert_eq!( + found.map(|(id, _)| id).as_deref(), + Some("legacy"), + "{}", + input.display() + ); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() { let conn = crate::db::conn_with_schema(); @@ -1083,18 +1305,50 @@ mod tests { [], ) .unwrap(); - let found = find_registration_by_path( - &conn, - std::path::Path::new(r"\\wsl.localhost\UBUNTU\home\u\app\"), + // 只用字面键查:测试不读 \\wsl.localhost(会唤起本机 WSL 发行版) + let lookup = |path: &str| { + find_registration_by_keys(&conn, &[registration_path_key(path)]) + .unwrap() + .map(|(id, _)| id) + }; + assert_eq!( + lookup(r"\\wsl.localhost\UBUNTU\home\u\app\").as_deref(), + Some("wsl-app") + ); + assert!(lookup(r"\\wsl.localhost\Ubuntu\home\u\app2").is_none()); + } + + /// 审查 L1:已有登记只按其 platform 取有效根目录比较;切换平台后残留在另一列的旧路径不拦别人。 + #[test] + fn duplicate_lookup_ignores_stale_column_of_other_platform() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("stale"); + let raw = dir.display().to_string(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-moved', 'moved')", + [], ) .unwrap(); - assert_eq!(found.map(|(id, _)| id).as_deref(), Some("wsl-app")); - let other = find_registration_by_path( - &conn, - std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\u\app2"), + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path, wsl_path, platform) + VALUES ('moved', 'prof-moved', ?1, '/home/u/moved', 'wsl')", + params![raw], ) .unwrap(); - assert!(other.is_none()); + assert!( + find_registration_by_keys(&conn, &[registration_path_key(&raw)]) + .unwrap() + .is_none(), + "WSL 项目残留的 win_path 不算它的目录" + ); + register_project_records( + &conn, + &windows_input("now-here", &raw), + RootRequirement::ExistingDir, + ) + .expect("残留列不得挡住该目录的登记"); + assert_eq!(workspace_count(&conn), 2); + std::fs::remove_dir_all(&dir).unwrap(); } #[test] @@ -1105,11 +1359,13 @@ mod tests { register_project_records( &conn, &windows_input("same-id", &first.display().to_string()), + RootRequirement::ExistingDir, ) .unwrap(); let error = register_project_records( &conn, &windows_input("same-id", &second.display().to_string()), + RootRequirement::ExistingDir, ) .expect_err("ID 已存在应报错,不能静默返回成功"); assert!(error.contains("same-id"), "{error}"); @@ -1120,7 +1376,12 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(stored, first.display().to_string(), "原登记不被改写"); + // 落库为规范形式(审查 M1) + assert_eq!( + stored, + canonical_dir(&first).unwrap().display().to_string(), + "原登记不被改写" + ); std::fs::remove_dir_all(&first).unwrap(); std::fs::remove_dir_all(&second).unwrap(); } @@ -1134,8 +1395,10 @@ mod tests { ) .unwrap(); let dir = temp_project_dir("mcp"); - let registered = - register_project_at(&conn, &format!(" {} ", dir.display()), None).unwrap(); + let canonical = canonical_dir(&dir).unwrap().display().to_string(); + // agent 常见写法:正斜杠 + 尾部分隔符 + 首尾空白(本机 TEMP 还是 8.3 短名) + let spelled = format!(" {}/ ", dir.display().to_string().replace('\\', "/")); + let registered = register_project_at(&conn, &spelled, None).unwrap(); assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok()); assert_eq!( @@ -1144,6 +1407,15 @@ mod tests { "名称缺省取目录名" ); assert_eq!(registered.space_id.as_deref(), Some("home")); + assert_eq!(registered.path, canonical, "返回落库的规范目录"); + assert!(registered.warning.is_none()); + assert!( + serde_json::to_value(®istered) + .unwrap() + .get("warning") + .is_none(), + "没有提示时不输出 warning 字段" + ); let (win_path, platform, space): (String, String, Option) = conn .query_row( "SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1", @@ -1151,11 +1423,10 @@ mod tests { |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap(); - assert_eq!( - win_path, - dir.display().to_string(), - "存入去掉首尾空白的路径" - ); + // 审查 M1:原断言为「存入去掉首尾空白的路径」,改为存规范形式(canonicalize 去 \\?\ 前缀) + assert_eq!(win_path, canonical, "落库为规范形式"); + assert!(!win_path.contains('/') && !win_path.ends_with('\\')); + assert!(!win_path.starts_with(r"\\?\")); assert_eq!(platform, "windows"); assert_eq!(space.as_deref(), Some("home")); @@ -1163,16 +1434,44 @@ mod tests { .expect_err("同一目录再次登记应被拒绝"); assert!(again.contains(®istered.project_id), "{again}"); assert!(register_project_at(&conn, "", None).is_err()); + assert!( + register_project_at(&conn, "src", None).is_err(), + "相对路径不按炼境进程 cwd 解析" + ); assert_eq!(workspace_count(&conn), 1); std::fs::remove_dir_all(&dir).unwrap(); } + /// 审查 I3:目录里遗留旧 context.json(曾接入后删除登记)时,回执带 warning 说明接入会改写 ID。 + #[test] + fn register_project_at_warns_when_directory_carries_another_project_id() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("stale_ctx"); + std::fs::create_dir_all(dir.join(".lian-jing")).unwrap(); + std::fs::write( + dir.join(".lian-jing").join("context.json"), + r#"{"schema_version":1,"project_id":"old-id"}"#, + ) + .unwrap(); + let registered = register_project_at(&conn, &dir.display().to_string(), None).unwrap(); + let warning = registered.warning.as_deref().expect("应提示旧 project_id"); + assert!(warning.contains("old-id") && warning.contains(®istered.project_id)); + let context = std::fs::read_to_string(dir.join(".lian-jing").join("context.json")).unwrap(); + assert!(context.contains("old-id"), "登记只读 context.json,不改写"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn batch_registration_skips_directories_already_registered() { let conn = crate::db::conn_with_schema(); let dir = temp_project_dir("batch"); let path = dir.display().to_string(); - register_project_records(&conn, &windows_input("existing", &path)).unwrap(); + register_project_records( + &conn, + &windows_input("existing", &path), + RootRequirement::ExistingDir, + ) + .unwrap(); let item = |id: &str, win_path: String| BatchProjectInput { id: id.to_string(), name: id.to_string(), diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 13b8c69..bc6220d 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -938,7 +938,8 @@ fn ensure_dsh_session_plugin() -> Result { /// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐: /// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。 -fn normalize_dsh_root(path: &str) -> String { +/// 登记的同目录判定(projects::registration_path_key)复用本函数,两处口径不得分叉。 +pub(crate) fn normalize_dsh_root(path: &str) -> String { let unified = path.replace('/', "\\"); let (prefix, rest) = match unified.strip_prefix("\\\\") { Some(rest) => ("\\\\", rest), @@ -1011,7 +1012,7 @@ fn dsh_project_roots( } /// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。 -fn strip_verbatim_prefix(path: &str) -> String { +pub(crate) fn strip_verbatim_prefix(path: &str) -> String { match path.strip_prefix(r"\\?\UNC\") { Some(rest) => format!(r"\\{rest}"), None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(), diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index c988f5c..229f1cd 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -414,7 +414,7 @@ pub fn tools_list_result() -> Value { fn register_project_schema() -> Value { json!({ "name": "register_project", - "description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。project_id 由炼境生成并返回。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。", + "description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符、8.3 短名、.. 段归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。目录按规范形式(真实大小写、反斜杠)登记;project_id 由炼境生成,与规范目录一并返回;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。", "inputSchema": { "type": "object", "properties": { @@ -426,6 +426,15 @@ fn register_project_schema() -> Value { }) } +/// register_project 的分发(连接由调用方注入):参数名与 [`register_project_schema`] 一一对应, +/// 测试按 schema 的参数名构造入参走这里,schema 改名而分发未跟上时会失败。 +fn register_project_call(conn: &rusqlite::Connection, args: &Value) -> Result { + let path = args.get("path").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str); + let registered = crate::commands::projects::register_project_at(conn, path, name)?; + serde_json::to_string_pretty(®istered).map_err(|e| e.to_string()) +} + /// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限) fn envoy_tool_schemas() -> Value { json!([ @@ -763,12 +772,18 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .as_str() .unwrap_or("接入验收结构化结果已返回") .to_string(), - "register_project" => format!( - "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", - value["project_id"].as_str().unwrap_or("?"), - value["name"].as_str().unwrap_or("?"), - value["path"].as_str().unwrap_or("?") - ), + "register_project" => { + let summary = format!( + "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", + value["project_id"].as_str().unwrap_or("?"), + value["name"].as_str().unwrap_or("?"), + value["path"].as_str().unwrap_or("?") + ); + match value["warning"].as_str() { + Some(warning) => format!("{summary}。注意:{warning}"), + None => summary, + } + } _ => "结构化结果已返回".to_string(), } } @@ -1241,11 +1256,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), "register_project" => { - let path = args.get("path").and_then(Value::as_str).unwrap_or(""); - let name = args.get("name").and_then(Value::as_str); let conn = db::pool().get().map_err(|e| e.to_string())?; - let registered = crate::commands::projects::register_project_at(&conn, path, name)?; - serde_json::to_string_pretty(®istered).map_err(|e| e.to_string()) + register_project_call(&conn, &args) } "unregister_project" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); @@ -2171,24 +2183,42 @@ fn render_all_projects( pub fn scan_unregistered_projects() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; + scan_unregistered_projects_with(&conn) +} + +/// 扫描本体(连接由调用方注入)。已登记判定与父目录去重都按登记的同目录口径 +/// (projects::registration_path_key:大小写、斜杠方向、重复与尾部分隔符归一),与 register_project 的 +/// 重复检查一致——不会把换了写法的已登记目录列成未登记,再被登记拒绝(P8f 审查 M1)。 +fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result { + use crate::commands::projects::registration_path_key; // 取所有已登记的 win_path let mut stmt = conn .prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL") .map_err(|e| e.to_string())?; - let registered_paths: std::collections::HashSet = stmt + let registered_paths: Vec = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) - .map(|p| p.trim_end_matches(['/', '\\']).to_string()) + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + let registered_keys: std::collections::HashSet = registered_paths + .iter() + .map(|p| registration_path_key(p)) .collect(); - // 推导父目录(去重) - let mut parent_dirs: std::collections::HashSet = - std::collections::HashSet::new(); + // 推导父目录:按比较键去重(E:/x 与 E:\x 只扫一次),保留首个写法;BTreeMap 让输出顺序稳定 + let mut parent_dirs: std::collections::BTreeMap = + std::collections::BTreeMap::new(); for p in ®istered_paths { if let Some(parent) = std::path::Path::new(p).parent() { - parent_dirs.insert(parent.to_path_buf()); + if parent.as_os_str().is_empty() { + continue; + } + parent_dirs + .entry(registration_path_key(&parent.to_string_lossy())) + .or_insert_with(|| parent.to_path_buf()); } } @@ -2198,7 +2228,7 @@ pub fn scan_unregistered_projects() -> Result { let mut unregistered: Vec<(String, bool, Vec, bool)> = Vec::new(); - for parent in &parent_dirs { + for parent in parent_dirs.values() { let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?; for entry in entries.flatten() { let path = entry.path(); @@ -2209,7 +2239,7 @@ pub fn scan_unregistered_projects() -> Result { .to_string_lossy() .trim_end_matches(['/', '\\']) .to_string(); - if registered_paths.contains(&path_str) { + if registered_keys.contains(®istration_path_key(&path_str)) { continue; } // 隐藏目录跳过 @@ -2255,7 +2285,7 @@ pub fn scan_unregistered_projects() -> Result { "## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n", unregistered.len(), parent_dirs - .iter() + .values() .map(|p| p.to_string_lossy().to_string()) .collect::>() .join("、") @@ -2280,7 +2310,7 @@ pub fn scan_unregistered_projects() -> Result { } out.push_str( - "---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。", + "---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再调用 `apply_onboarding_pack(project_id)` 分发接入包。", ); Ok(out) } @@ -2629,6 +2659,99 @@ mod tests { let summary = response["content"][0]["text"].as_str().unwrap(); assert!(summary.contains("project_id=0b6c"), "{summary}"); assert!(summary.contains("尚未接入"), "{summary}"); + assert!(!summary.contains("注意"), "没有 warning 时摘要不带提示"); + + let with_warning = json!({ + "project_id": "0b6c", + "name": "my-app", + "path": "E:\\code\\my-app", + "warning": "目录里的 .lian-jing/context.json 仍写着旧 project_id=old-id" + }) + .to_string(); + let response = tool_success_response("register_project", with_warning); + let summary = response["content"][0]["text"].as_str().unwrap(); + assert!( + summary.contains("注意") && summary.contains("old-id"), + "{summary}" + ); + } + + /// P8f 审查 I4:按 schema 声明的参数名构造入参走分发函数——schema 改名而分发未跟上时失败。 + #[test] + fn register_project_dispatch_follows_schema_parameter_names() { + let schema = register_project_schema(); + let properties = schema["inputSchema"]["properties"].as_object().unwrap(); + let mut names: Vec<&str> = properties.keys().map(String::as_str).collect(); + names.sort_unstable(); + assert_eq!(names, ["name", "path"]); + let path_param = schema["inputSchema"]["required"][0].as_str().unwrap(); + + let conn = crate::db::conn_with_schema(); + let dir = std::env::temp_dir().join(format!("lj_p8f_dispatch_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let canonical = crate::mcp_inject::strip_verbatim_prefix( + &std::fs::canonicalize(&dir).unwrap().display().to_string(), + ); + let mut args = json!({ "name": "别名" }); + args[path_param] = json!(format!("{}/", dir.display().to_string().replace('\\', "/"))); + let text = register_project_call(&conn, &args).unwrap(); + let registered: Value = serde_json::from_str(&text).unwrap(); + assert!(!registered["project_id"].as_str().unwrap().is_empty()); + assert_eq!(registered["name"], "别名"); + assert_eq!( + registered["path"], + canonical.as_str(), + "回执带落库的规范目录" + ); + + let error = register_project_call(&conn, &json!({})).expect_err("缺 path 应被拒绝"); + assert!(error.contains("路径为空"), "{error}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// P8f 审查 M1:scan_unregistered_projects 与登记重复检查同一口径——已登记目录换了写法 + /// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。 + #[test] + fn scan_unregistered_matches_registrations_regardless_of_spelling() { + let conn = crate::db::conn_with_schema(); + let parent = std::env::temp_dir().join(format!("lj_p8f_scan_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&parent); + for child in ["alpha", "beta", "gamma"] { + std::fs::create_dir_all(parent.join(child)).unwrap(); + } + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-a', 'a'), ('prof-g', 'g')", + [], + ) + .unwrap(); + let alpha = format!( + "{}/", + parent + .join("alpha") + .display() + .to_string() + .replace('\\', "/") + .to_uppercase() + ); + let gamma = parent.join("gamma").display().to_string(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path, platform) + VALUES ('a', 'prof-a', ?1, 'windows'), ('g', 'prof-g', ?2, 'windows')", + rusqlite::params![alpha, gamma], + ) + .unwrap(); + + let out = scan_unregistered_projects_with(&conn).unwrap(); + assert!(out.contains("共 1 个"), "{out}"); + assert!(out.contains("**beta**"), "{out}"); + assert!(!out.contains("alpha") && !out.contains("gamma"), "{out}"); + let scope = out + .lines() + .find(|line| line.starts_with("扫描范围:")) + .unwrap(); + assert!(!scope.contains('、'), "两种写法的父目录只扫一次:{scope}"); + std::fs::remove_dir_all(&parent).unwrap(); } #[test] From 04470bd368aecfb7b024c89271ab5d9a6a758ed8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 13:15:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P8f?= =?UTF-8?q?=20=E8=A1=A5=E7=8B=AC=E7=AB=8B=E5=AE=A1=E6=9F=A5=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E3=80=81=E6=94=B9=E5=8A=A8=E6=96=AD=E8=A8=80=E6=8A=A5?= =?UTF-8?q?=E5=A4=87=E4=B8=8E=20known=5Flimits=EF=BC=8C=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=BB=8D=E5=BE=85=20P8e=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=EF=BC=88P8f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 5e87eaf..42a5360 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -287,19 +287,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ### 🔵 P8f — register_project 控制面投影 - status: in_progress - complexity: S -- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/templates.rs(实施扩展), src-tauri/src/commands/git_repos.rs(实施扩展), src/components/dashboard/BatchAddModal.tsx(实施扩展), docs/ai-context/project-context.yaml(T1) +- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/templates.rs(实施扩展), src-tauri/src/commands/git_repos.rs(实施扩展), src/components/dashboard/BatchAddModal.tsx(实施扩展), src-tauri/src/mcp_inject.rs(审查处置:路径键两函数改 pub(crate) 供复用), docs/ai-context/project-context.yaml(T1) - depends: P8-0 - acceptance: "agent_verify: [cargo test] 空路径、相对路径、不存在的路径、重复登记都被拒绝,不建目录、不执行 git init。[MCP 全局] 对临时 git 仓库依次调用 register_project、plan_onboarding、onboard_project,结果为 done;项目路由的 tools/list 不含 register_project。" - 为什么(Q7):create_project 只写 DB、MCP 里也没有登记项目的工具,agent 复刻不了人「新建 → 接入」的操作路径(违反同源同边界)。只开放在全局路由,写操作须用户确认;抽出 create_project 本体,连接由调用方注入。 -- 实现(2026-10-03,分支 feat/agent-client-parity-p8f):projects.rs 抽出登记本体 `register_project_records(conn, input)`(连接注入),UI `create_project` 只做取连接的薄包装,MCP `register_project` 经 `register_project_at(conn, path, name)` 换算入参后调同一本体——边界只写一处。本体只写数据库:不建目录、不执行 git init、不接入;依次拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;重复检查与 profile+workspace 插入放在同一 IMMEDIATE 事务(沿用 envoy 先查后插的做法),并发登记同一目录时后到者等前者提交后再查。workspace 插入由 `INSERT OR IGNORE` 改为 `INSERT`,ID 冲突不再静默返回成功。MCP `register_project`(schema 单独成函数,插在 scan_unregistered_projects 之后):入参 path(Windows 绝对路径,必填)与可选 name(缺省取目录名);project_id 由炼境生成 UUID(与 UI 选目录新建一致),归入当前空间(UI 首页按当前空间列项目);返回 JSON 并列入 STRUCTURED_TOOLS,structuredContent 带 project_id、name、path、space_id,摘要写明「只写登记、尚未接入,接入先 plan_onboarding 预览、经用户确认再 onboard_project」。is_project_scoped_tool 显式排除 register_project(与 unregister_project、setup_gitea_repo 并列),项目路由 tools/list 不含它,项目路由调用返回 scope_violation;annotations 为非只读、非破坏、非幂等。描述写明调用前须向用户列出目标目录与名称并取得确认。 -- 重复路径规范化(`projects::registration_path_key`,纯字面):去首尾空白 → `/` 统一为 `\` → 去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`)→ 合并重复分隔符(保留 UNC 开头的 `\\`)→ 去尾部分隔符 → 小写。比较对象是每条已有登记的 win_path 与 wsl_path 两列:Windows 路径原样;WSL 的 Linux 路径按 `\\wsl.localhost\\…` 换算(与 mcp_inject::project_root_with_conn 同口径),wsl_path 已是 Windows/UNC 形式的原样使用。命中即拒绝,错误写明「该目录已登记为项目 project_id=<已有 ID>(<登记路径>)……请直接使用该 project_id」。 -- 其他登记入口(UI 取舍):① 模板新建(create_project_from_template)要先建目录、再跑初始化命令、最后入库,过不了本体「目录须已存在」,所以保持原流程不变,只在建目录前复用 `ensure_path_not_registered`(目录已删但登记仍在时,不再新建出第二条同目录登记);② 批量添加(batch_create_projects)保持「跳过不报错」语义,同一目录已登记(含同批前项)计入 skipped,提示文案改为「ID 已存在或目录已登记」,不加存在性与绝对路径校验(扫描结果必为已存在目录);③ 挂载仓库(mount_repo)给出路径时同样拒绝同一目录。 -- 行为变化(报备):UI「添加项目」此前允许不选目录、允许相对或不存在的路径、ID 冲突时静默成功;现在与 MCP 共用本体,这些情况都报错(空路径登记曾让接入链路把文件写进炼境进程 cwd,见 mcp_inject::validate_registered_path 的 2026-07-15 回归)。WSL 平台以 wsl_path 为准校验,Linux 路径的存在性经 `\\wsl.localhost` 判定,settings.wsl_distro 与实际发行版不符时会被拒绝(此时 project_root 同样会解析错,提前暴露)。 -- verification: "2026-10-03:全量 cargo test 335 passed / 0 failed / 5 ignored(master 为 325,新增 10 条),编译 0 warning;pnpm typecheck 通过。projects 新增 7 条:registration_path_key 归并大小写、斜杠方向、尾部分隔符、重复分隔符、长路径前缀,UNC 保留双分隔符,不同目录不归并;空串、空白、相对路径(含 cwd 下真实存在的 src)、盘符相对路径、不存在的目录全部拒绝,零行写入、无孤儿 profile、不存在的目录未被创建;同一临时目录以大写、小写、正斜杠、尾部分隔符等 5 种写法及改走 WSL 平台再登记均被拒绝,错误含 project_id=first,表内仍 1 行,目录内没有 .git;WSL Linux 路径的登记能按 UNC 写法查到;ID 冲突报错且原登记不被改写;register_project_at 生成 UUID、名称取目录名、归入当前空间、存去掉首尾空白的路径,同目录再登记的错误含已有 project_id;批量添加跳过已登记目录与同批重复目录。mcp_tools 新增 3 条:全局 tools/list 含 register_project(required=[path]、非只读非破坏、描述含用户确认、不建目录、git init、project_id),项目路由 tools/list 不含,schema 加上 project_id 后 is_project_scoped_tool 仍为 false;项目路由调用 register_project 返回 scope_violation;成功回执 structuredContent 带 project_id、摘要写明尚未接入。变异验证(先暂存,改源码,跑 projects:: 与 mcp_tools::,再从暂存区还原)9 处全部被杀:去掉重复目录检查(改 IMMEDIATE 事务前后各验一次)、比较键不小写、不去尾部分隔符、不统一斜杠方向、去掉目录存在校验、去掉绝对路径校验、批量添加不跳过同目录、is_project_scoped_tool 不排除 register_project。" -- 没验什么:① acceptance 的 [MCP 全局] 链路 register_project → plan_onboarding → onboard_project 依赖 P8e 才有的两个 MCP 工具,本卡只实现与测试 register_project;**链路实测待 P8e 合并后由统筹会话完成**(要在运行中的炼境上登记一个临时 git 仓库,属写运行中 DB,按授权规则处理),卡片保持 🔵。② 没在运行中的炼境上调过 register_project(未重编译安装,且会写运行中 DB)。③ IMMEDIATE 事务防并发重复只有结构保证,没有并发测试。④ 模板新建与挂载仓库的重复目录检查没有单测(Tauri command 直接取连接池,模板路径还要建目录、跑命令),由共用的 find_registration_by_path 测试间接覆盖。⑤ UI 添加项目的报错只看了代码路径(ProjectModal 以 toast 显示错误),没有人工点过。⑥ 本卡为 S 级,未做独立审查。 -- known_limits: "① 规范化只做字面:`..`、符号链接、junction、8.3 短名与长名不归一;WSL 路径也按不区分大小写比较(Linux 上仅大小写不同的两个目录会被误判为同一目录,极少见)。② update_project 把路径改成一个已登记目录不拦(不在本卡范围);存量的同目录重复登记(如待用户处理的 beast-mode / 940dfbb4)不受影响,仍按统筹文档 §3 由用户执行数据合并。③ 批量添加的 wsl_convert 模式把 E:\x 存成 /mnt/e/x,与同目录的 Windows 登记比较键不同,查不出这类跨写法重复。④ scan_unregistered_projects 判定已登记仍是区分大小写的字面比较,可能把只差大小写的已登记目录列成未登记;对它调 register_project 会被本卡的重复检查拒绝并给出已有 project_id。" -- 有意偏差:① 未加 confirmed 参数——任务要求是「描述写明写操作须用户确认目标」;登记只写炼境 DB、可经 unregister_project 撤销,按 unregister_project 的先例只在描述里约束。② MCP 不收 project_id,统一由炼境生成 UUID,避免 agent 自拟 ID 与已有命名冲突。③ 重复目录检查扩到批量添加、模板新建、挂载仓库三个入口(files 标「实施扩展」):PR #34 审查 L1/L2 的根因是同一目录多条登记,只堵 create_project 和 MCP 两个入口治不了根。 -- T1:新 MCP 工具 register_project 已同步 docs/ai-context/project-context.yaml「MCP 工具接口」段(paths 补 projects.rs,doc 补路由、本体、规范化与入参语义);Tauri command 签名未变,src/lib/commands.ts 无需改动。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8f):projects.rs 抽出登记本体 `register_project_records(conn, input, requirement)`(连接注入),UI `create_project` 只做取连接的薄包装(requirement=ExistingDirOrPlaceholder),MCP `register_project` 经 `register_project_at(conn, path, name)` 换算入参后调同一本体(requirement=ExistingDir)——边界只写一处。本体只写数据库:不建目录、不执行 git init、不接入;依次拒绝空 ID、空路径(UI 不选目录的概念占位除外,见审查 M2)、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;目录校验与比较键在写事务外算(要读磁盘),重复检查与 profile+workspace 插入放在同一 IMMEDIATE 事务(沿用 envoy 先查后插的做法),并发登记同一目录时后到者等前者提交后再查。Windows 目录落库为 canonical 规范形式(审查 M1),本体返回落库的根目录。workspace 插入由 `INSERT OR IGNORE` 改为 `INSERT`,ID 冲突不再静默返回成功。MCP `register_project`(schema 单独成函数,插在 scan_unregistered_projects 之后):入参 path(Windows 绝对路径,必填)与可选 name(缺省取目录名);project_id 由炼境生成 UUID(与 UI 选目录新建一致),归入当前空间(UI 首页按当前空间列项目);返回 JSON 并列入 STRUCTURED_TOOLS,structuredContent 带 project_id、name、path、space_id,摘要写明「只写登记、尚未接入,接入先 plan_onboarding 预览、经用户确认再 onboard_project」。is_project_scoped_tool 显式排除 register_project(与 unregister_project、setup_gitea_repo 并列),项目路由 tools/list 不含它,项目路由调用返回 scope_violation;annotations 为非只读、非破坏、非幂等。描述写明调用前须向用户列出目标目录与名称并取得确认。 +- 同目录判定(审查后定稿):字面键 `projects::registration_path_key` = 去首尾空白 → `/` 统一为 `\` → 去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`)→ 交给 `mcp_inject::normalize_dsh_root`(合并重复分隔符、保留 UNC 开头的 `\\`、去尾部分隔符、小写)——与 DSH 白名单共用同一实现,口径不分叉(审查 L4 的部分处置)。已有登记一侧:每条只取按 platform 的有效根目录(Windows 取 win_path、WSL 取 wsl_path;取列与 Linux 路径换算同 mcp_inject::project_root_with_conn,wsl_path 已是 UNC 时原样使用——project_root_with_conn 对这种值会错误拼接,是已有缺陷,见审查 I2),切换平台残留在另一列的旧路径不参与(审查 L1),只算字面键、不读磁盘。新目录一侧:在写事务外算字面键与 canonical 键(`std::fs::canonicalize` 去 `\\?\` 前缀,消掉 8.3 短名、`.`/`..` 段,审查 L2),任一命中即拒绝,错误写明「该目录已登记为项目 project_id=<已有 ID>(<有效根目录>)……请直接使用该 project_id」。 +- 其他登记入口(UI 取舍):① 模板新建(create_project_from_template)要先建目录、再跑初始化命令、最后入库,过不了本体「目录须已存在」,所以保持原流程不变,只在建目录前复用 `ensure_path_not_registered`(目录已删但登记仍在时,不再新建出第二条同目录登记;此时目录不存在,只有字面键);② 批量添加(batch_create_projects)保持「跳过不报错」语义,同一目录已登记(含同批前项)计入 skipped,提示文案改为「ID 已存在或目录已登记」,不加存在性与绝对路径校验(扫描结果必为已存在目录);③ 挂载仓库抽出本体 `git_repos::mount_repo_records(conn, input)`:给出路径时在任何写入之前拒绝同一目录,profile 与 workspace 在同一 IMMEDIATE 事务写入,被拒绝或 workspace 冲突都不留孤儿 profile(审查 L3);④ scan_unregistered_projects 抽出 `scan_unregistered_projects_with(conn)`,已登记判定与父目录去重改用同一字面键(审查 M1 连带),末尾提示改为「经用户确认后 register_project 登记」。 +- 行为变化(报备):UI「添加项目」此前允许相对或不存在的路径、ID 冲突时静默成功;现在与 MCP 共用本体,这些情况都报错(UI 只能经文件夹选择器给路径,实际只影响「选过的目录后来被删」与 ID 冲突两种情形)。**不选目录的概念占位保持原行为**(审查 M2:首版曾一并拒绝,与 mcp-server 模块「2 个空路径概念占位按用户意图保留」冲突,已恢复;MCP 入口仍拒绝空路径)。Windows 目录落库由原样改为 canonical 规范形式(UI 选择器给的本就是规范长名,实际只影响 MCP 入参)。WSL 平台以 wsl_path 为准校验,Linux 路径的存在性经 `\\wsl.localhost` 判定,settings.wsl_distro 与实际发行版不符时会被拒绝(此时 project_root 同样会解析错,提前暴露)。 +- verification: "2026-10-03 审查处置后:全量 cargo test 342 passed / 0 failed / 5 ignored(首版 335,审查处置新增 7 条),编译 0 warning;pnpm typecheck 通过。审查处置新增:projects 4 条——ui_registration_keeps_pathless_placeholder(UI 入口空串/空白/WSL 空路径占位成功且互不拦截、占位不能复用已存在 ID、MCP 入口拒绝占位)、duplicate_lookup_matches_dotted_or_short_input_by_canonical_key(已有行以正斜杠+尾部分隔符落库,新目录以 `sub\\..` 与本机 8.3 短名 TEMP 写法给出,均靠 canonical 键命中)、duplicate_lookup_ignores_stale_column_of_other_platform(platform=wsl 的行残留 win_path 不挡该 Windows 目录登记)、register_project_at_warns_when_directory_carries_another_project_id(遗留 context.json 的旧 ID 进 warning,文件不被改写);git_repos 1 条 mount_rejections_leave_no_orphan_profile(同目录拒绝挂载与 workspace ID 冲突都不留 profile,正常挂载成功);mcp_tools 2 条——register_project_dispatch_follows_schema_parameter_names(按 schema 声明的参数名构造入参经分发函数登记,回执 path 为规范目录、缺 path 报「路径为空」)、scan_unregistered_matches_registrations_regardless_of_spelling(正斜杠大写带尾分隔符的登记与反斜杠登记都不被列为未登记,两种写法的父目录只扫一次)。改动的既有断言(报备):register_project_at 测试原断言「存入去掉首尾空白的路径」改为「存 canonical 规范形式」,入参改为正斜杠+尾部分隔符+首尾空白(审查 M1 的要求本身);ID 冲突测试的「原登记不被改写」改为与 canonical 形式比较;同目录写法测试补 canonical 大写、`sub\\..`、`sub\\..\\.` 三种写法并断言本体返回规范根目录;WSL Linux 路径查找测试改为直接用字面键查(find_registration_by_path 现会 canonicalize,测试不应访问 \\\\wsl.localhost 唤起本机 WSL);其余测试只补 requirement 参数。变异验证(先暂存,改源码,跑 projects::、git_repos::、mcp_tools::,再从暂存区还原)两轮 9 处全部被杀:第一轮 4 处——落库不规范化(taken_id、register_project_at、同目录写法、分发 4 条失败)、关闭 UI 占位(占位测试失败)、mount 事务改为出错也提交(mount 测试失败)、scan 改回字面比较(scan 测试失败),共 7 条失败;第二轮 5 处——去掉 canonical 键(`..`/8.3 查找、同目录写法、register_project_at 与批量添加的 8.3 再登记失败)、已有行不按 platform 取列(残留列与 WSL Linux 路径两条失败)、warning 恒为空(warning 测试失败)、分发读错参数名(分发测试失败)、字面键不去长路径前缀(键测试失败),共 9 条失败。首版(审查前):全量 cargo test 335 passed / 0 failed / 5 ignored(master 为 325,新增 10 条),编译 0 warning;pnpm typecheck 通过。projects 新增 7 条:registration_path_key 归并大小写、斜杠方向、尾部分隔符、重复分隔符、长路径前缀,UNC 保留双分隔符,不同目录不归并;空串、空白、相对路径(含 cwd 下真实存在的 src)、盘符相对路径、不存在的目录全部拒绝,零行写入、无孤儿 profile、不存在的目录未被创建;同一临时目录以大写、小写、正斜杠、尾部分隔符等 5 种写法及改走 WSL 平台再登记均被拒绝,错误含 project_id=first,表内仍 1 行,目录内没有 .git;WSL Linux 路径的登记能按 UNC 写法查到;ID 冲突报错且原登记不被改写;register_project_at 生成 UUID、名称取目录名、归入当前空间、存去掉首尾空白的路径,同目录再登记的错误含已有 project_id;批量添加跳过已登记目录与同批重复目录。mcp_tools 新增 3 条:全局 tools/list 含 register_project(required=[path]、非只读非破坏、描述含用户确认、不建目录、git init、project_id),项目路由 tools/list 不含,schema 加上 project_id 后 is_project_scoped_tool 仍为 false;项目路由调用 register_project 返回 scope_violation;成功回执 structuredContent 带 project_id、摘要写明尚未接入。变异验证(先暂存,改源码,跑 projects:: 与 mcp_tools::,再从暂存区还原)9 处全部被杀:去掉重复目录检查(改 IMMEDIATE 事务前后各验一次)、比较键不小写、不去尾部分隔符、不统一斜杠方向、去掉目录存在校验、去掉绝对路径校验、批量添加不跳过同目录、is_project_scoped_tool 不排除 register_project。" +- 没验什么:① acceptance 的 [MCP 全局] 链路 register_project → plan_onboarding → onboard_project 依赖 P8e 才有的两个 MCP 工具,本卡只实现与测试 register_project;**链路实测待 P8e 合并后由统筹会话完成**(要在运行中的炼境上登记一个临时 git 仓库,属写运行中 DB,按授权规则处理),卡片保持 🔵。② 没在运行中的炼境上调过 register_project(未重编译安装,且会写运行中 DB)。③ IMMEDIATE 事务防并发重复只有结构保证,没有并发测试。④ 模板新建的重复目录检查没有单测(Tauri command 直接取连接池,还要建目录、跑命令),由共用的 ensure_path_not_registered/find_registration_by_path 测试间接覆盖;挂载仓库已抽本体并补单测。⑤ UI 添加项目的报错与占位登记只看了代码路径(ProjectModal 以 toast 显示错误),没有人工点过。⑥ canonicalize 在 junction、符号链接、subst 盘、映射网络盘上的行为(解析到真实目标、映射盘变 UNC)没有实测,测试只覆盖本机 TEMP 的 8.3 短名与 `..` 段。⑦ 审查 M2 的产品决定(是否保留无目录的概念占位、是否改为显式占位概念)没有问过用户,本 PR 只恢复原行为。 +- known_limits: "① 已有登记一侧只算字面键:已有行以 8.3 短名或经 junction 的路径落库、新目录用长名或真实路径给出时查不出(UI 选择器给的是规范长名,P8f 之后 MCP 落库也是规范形式,新增登记不会再产生这种行);WSL 路径也按不区分大小写比较(Linux 上仅大小写不同的两个目录会被误判为同一目录,极少见);`\\\\wsl$` 与 `\\\\wsl.localhost` 两种写法不归一。② update_project 把路径改成一个已登记目录不拦(审查 I1,转后续小修);存量的同目录重复登记(如待用户处理的 beast-mode / 940dfbb4)不受影响,仍按统筹文档 §3 由用户执行数据合并。③ 批量添加的 wsl_convert 模式把 E:\\x 存成 /mnt/e/x,与同目录的 Windows 登记比较键不同,查不出这类跨写法重复。④ scan_unregistered_projects 只按 win_path 推导父目录、已有行只算字面键:8.3 短名登记的目录仍可能被列为未登记,对它调 register_project 会被 canonical 键拒绝并给出已有 project_id。⑤ Windows 目录落库为 canonical 形式,junction、符号链接、subst 盘解析到真实目标,映射网络盘变成 UNC:经 junction 路径打开的客户端会话,与按字面比较登记路径的消费者对不上(DSH 白名单同时写字面键与 canonical 键,不受影响)。⑥ 路径比较键仍有多份实现:normalize_trust_path(Codex 信任判断)未并入,WSL 发行版查询与 Linux 路径转 UNC 在 projects.rs、mcp_inject.rs、publish.rs 各有一份(审查 L4,转后续小修)。" +- 有意偏差:① 未加 confirmed 参数——任务要求是「描述写明写操作须用户确认目标」;登记只写炼境 DB、可经 unregister_project 撤销,按 unregister_project 的先例只在描述里约束。② MCP 不收 project_id,统一由炼境生成 UUID,避免 agent 自拟 ID 与已有命名冲突。③ 重复目录检查扩到批量添加、模板新建、挂载仓库三个新建入口(files 标「实施扩展」):PR #34 审查 L1/L2 的根因是同一目录多条登记,只堵 create_project 和 MCP 两个入口治不了根;编辑入口 update_project 仍未堵(审查 I1,转后续小修)。 +- T1:新 MCP 工具 register_project 已同步 docs/ai-context/project-context.yaml「MCP 工具接口」段(paths 补 projects.rs,doc 补路由、本体、同目录判定、落库规范化、UI 占位例外、warning 与入参语义,以及 scan_unregistered_projects 与 mount_repo 的同口径);Tauri command 签名未变,src/lib/commands.ts 无需改动。 +- review: "2026-10-03 独立只读审查 bd4ebaf,结论 fix_then_merge,10 条发现处置:M1 [中] MCP 入参原样落库(正斜杠、尾部分隔符、长路径前缀、8.3、`..`),scan_unregistered_projects 按字面比较会自相矛盾 → 已修:本体对 Windows 目录 canonicalize 后去长路径前缀落库,register_project_at 回执 path 为落库值、名称缺省按规范形式取目录名(相对路径不 canonicalize,交本体拒绝);scan 的已登记判定与父目录去重改用 registration_path_key。M2 [中] UI 不选目录的概念占位被取消,超出 P8f 范围且与 mcp-server 模块记录的用户用法冲突 → 已恢复原行为:本体新增 RootRequirement,UI 入口有效路径为空时只写 profile+workspace、不校验目录、不查重复,MCP 入口仍拒绝;是否改为显式占位概念交统筹会话问用户(需求澄清),本 PR 不预设答案。L1 [低] 已有登记两列都比,残留列误拦 → 已修:按 platform 只取有效根目录(registration_root),查询补 platform 列。L2 [低] 8.3、`..`、末尾点等漏判 → 已修:新目录在写事务外另算 canonical 键,两键比对已有行的字面键(已有行不读磁盘,写锁内无 I/O)。L3 [低] mount_repo 检查在插 profile 之后且无事务 → 已修:抽 mount_repo_records(连接注入),检查前移、整体 IMMEDIATE 事务;前端先 gitClone 再 mount 的白克隆属 UI 范围,未改。L4 [低] 路径比较键多份实现 → 部分修:registration_path_key 改为复用 mcp_inject::normalize_dsh_root + strip_verbatim_prefix(两函数改 pub(crate));normalize_trust_path、WSL 发行版查询与 UNC 换算的第三份拷贝统一抽 crate::path_key 转后续小修(会动 P4a/P8d 热点 mcp_inject.rs)。I1 [信息] update_project 仍可造同目录登记 → 转后续小修:UI 编辑整表提交,须「有效根目录实际变化才检查 + 排除自身」,否则存量重复登记(beast-mode)连改名都会被拦,单独做更稳。I2 [信息] project_root_with_conn 对 UNC 形式的 wsl_path 错误拼接(已有缺陷)→ 不在本 PR 改(mcp_inject.rs 热点),转后续小修;卡片与代码注释里「同口径」改为只指取列与 Linux 路径换算。I3 [信息] 重登目录遗留旧 context.json 无提示 → 已修:register_project_at 只读检查,project_id 不同时回执与摘要带 warning(不沿用旧 ID)。I4 [信息] 分发分支无测试 → 已修:分发抽成 register_project_call(conn, args),测试按 schema 声明的参数名构造入参;IMMEDIATE 并发仍无测试(见没验什么③)。" ### 📋 P8g — UI 一键接入 - status: todo