From ef2c7fd5939b7bfde6f50f53620fdafefa7ef4f6 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 09:35:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(onboard/parity):=20=E5=86=99=E5=85=A5?= =?UTF-8?q?=E6=B1=87=20WriteSink=20=E4=B8=8E=E5=86=99=E5=85=A5=E5=89=8D?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20plan=5Fonboarding=EF=BC=88P8c=EF=BC=8C?= =?UTF-8?q?=E5=BE=85=E7=8B=AC=E7=AB=8B=E5=AE=A1=E6=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一键接入改写子项目受版本管理的文件,人在确认前必须看得到改什么;计划与 apply 走同一段 分发代码、只换写入汇,所以「看到的」就是「将写的」。 - lib.rs:WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink - 接入包模板分发、蓝图规则同步、项目身份与 MCP 投影(context、settings 兼容条目、 liangjing、Codex 托管块)全部改经写入汇;原入口改为 DiskSink 薄包装,行为不变 - onboard/plan.rs:PlanSink 只记录不落盘、后续步骤读到前面步骤的待写内容; write_project_files 为 plan 与 apply 共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff、混合文件预警(P10 前移到写入前)、身份冲突、 手写条目与共享项目键阻断项,并对计划算 digest - mcp_inject:plan_onboarding 生产入口与本机侧预计动作(token、稳定入口、三端只读 状态、Codex 信任),只读、不起子进程 - 新增测试:空仓库全 create 且目录快照字节不变、apply 后两次计划全 noop 且 digest 相同、CRLF 判 noop、内容/本机状态变化 digest 变化、混合文件与身份冲突阻断、 手写 Codex 条目与共享键阻断、记录汇覆盖与撤销、diff 截断、Codex 信任解析; cargo test 307 passed Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/blueprint.rs | 65 ++- src-tauri/src/commands/onboarding.rs | 112 +++-- src-tauri/src/lib.rs | 35 ++ src-tauri/src/mcp_inject.rs | 394 ++++++++++++++---- src-tauri/src/onboard/mod.rs | 1 + src-tauri/src/onboard/plan.rs | 588 +++++++++++++++++++++++++++ 6 files changed, 1051 insertions(+), 144 deletions(-) create mode 100644 src-tauri/src/onboard/plan.rs diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 78a68a2..7c549ce 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1270,33 +1270,55 @@ pub(crate) const BLUEPRINT_MANAGED_END: &str = ""; /// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块: /// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。 /// 返回动作:created | updated | unchanged | appended +#[cfg(test)] fn upsert_managed_block( path: &Path, template: &str, header_when_created: &str, +) -> Result { + upsert_managed_block_with(path, template, header_when_created, &crate::DiskSink) +} + +fn upsert_managed_block_with( + path: &Path, + template: &str, + header_when_created: &str, + sink: &dyn crate::WriteSink, ) -> Result { let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); - if !path.exists() { + let Some(content) = sink + .read(path) + .map_err(|e| format!("读取 {label} 失败: {e}"))? + else { let content = format!("{header_when_created}\n\n{template}\n"); - std::fs::write(path, content).map_err(|e| format!("创建 {label} 失败: {e}"))?; + sink.write(path, &content) + .map_err(|e| format!("创建 {label} 失败: {e}"))?; return Ok("created".to_string()); - } - let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?; + }; if content.contains(BLUEPRINT_MANAGED_START) { let new_content = replace_managed_section(&content, template); - let written = crate::write_if_content_changed(path, &new_content) + let written = sink + .write(path, &new_content) .map_err(|e| format!("更新 {label} 失败: {e}"))?; Ok(if written { "updated" } else { "unchanged" }.to_string()) } else { let new_content = format!("{}\n\n{template}\n", content.trim_end()); - std::fs::write(path, new_content).map_err(|e| format!("追加 {label} 失败: {e}"))?; + sink.write(path, &new_content) + .map_err(|e| format!("追加 {label} 失败: {e}"))?; Ok("appended".to_string()) } } #[tauri::command] pub fn sync_blueprint_rules(project_path: String) -> Result { - let root = Path::new(&project_path); + sync_blueprint_rules_with(Path::new(&project_path), &crate::DiskSink) +} + +/// sync_blueprint_rules 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。 +pub(crate) fn sync_blueprint_rules_with( + root: &Path, + sink: &dyn crate::WriteSink, +) -> Result { let bp_dir = root.join(".blueprint"); let project_name = root .file_name() @@ -1304,28 +1326,29 @@ pub fn sync_blueprint_rules(project_path: String) -> Result .unwrap_or("Project") .to_string(); - // 确保 .blueprint/ 存在 - std::fs::create_dir_all(&bp_dir).map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?; - - // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写) - let conventions_written = crate::write_if_content_changed( - &bp_dir.join("CONVENTIONS.md"), - &effective_conventions_content(), - ) - .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?; + // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写; + // .blueprint/ 由写入汇按需创建) + let conventions_written = sink + .write( + &bp_dir.join("CONVENTIONS.md"), + &effective_conventions_content(), + ) + .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?; // 2. AGENTS.md:完整蓝图工作流(跨工具单一来源,Qwen/Cursor/Copilot 均读) - let agents_action = upsert_managed_block( + let agents_action = upsert_managed_block_with( &root.join("AGENTS.md"), &agents_managed_template(), &format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口(Claude / Qwen / Cursor / Copilot 等均读此文件)。"), + sink, )?; // 3. CLAUDE.md:薄引用(Claude 经 @AGENTS.md 接入单一来源) - let claude_action = upsert_managed_block( + let claude_action = upsert_managed_block_with( &root.join("CLAUDE.md"), &claude_managed_template(), &format!("# {project_name}"), + sink, )?; Ok(SyncResult { @@ -3064,7 +3087,11 @@ mod tests { let before = std::fs::read(&f).unwrap(); let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap(); assert_eq!(a, "unchanged"); - assert_eq!(std::fs::read(&f).unwrap(), before, "CRLF 原样保留,未被 LF 重写"); + assert_eq!( + std::fs::read(&f).unwrap(), + before, + "CRLF 原样保留,未被 LF 重写" + ); // 3. 存在但无 MANAGED 区 → appended(既有内容保留) let g = dir.join("CLAUDE.md"); diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 3deb070..871bf4f 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -55,17 +55,19 @@ impl TemplateResult { } /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 -fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { +fn dispatch_one( + root: &Path, + relative: &str, + content: &str, + sink: &dyn crate::WriteSink, +) -> TemplateResult { let target = root.join(relative); - if target.exists() { - return TemplateResult::new(relative, "skipped", "已存在,未覆盖"); + match sink.read(&target) { + Ok(Some(_)) => return TemplateResult::new(relative, "skipped", "已存在,未覆盖"), + Ok(None) => {} + Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), } - if let Some(parent) = target.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - return TemplateResult::new(relative, "failed", format!("建目录失败: {e}")); - } - } - match std::fs::write(&target, content) { + match sink.write(&target, content) { Ok(_) => TemplateResult::new(relative, "written", "已写入"), Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")), } @@ -73,20 +75,20 @@ fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { /// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。 /// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。 -/// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 -fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult { +/// 比较忽略行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 +fn dispatch_fully_managed( + root: &Path, + relative: &str, + content: &str, + sink: &dyn crate::WriteSink, +) -> TemplateResult { let target = root.join(relative); - if !target.exists() { - return dispatch_one(root, relative, content); + if !matches!(sink.read(&target), Ok(Some(_))) { + return dispatch_one(root, relative, content, sink); } - let normalize = |s: &str| s.replace("\r\n", "\n"); - if let Ok(existing) = std::fs::read_to_string(&target) { - if normalize(&existing) == normalize(content) { - return TemplateResult::new(relative, "skipped", "内容已是最新模板"); - } - } - match std::fs::write(&target, content) { - Ok(_) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"), + match sink.write(&target, content) { + Ok(false) => TemplateResult::new(relative, "skipped", "内容已是最新模板"), + Ok(true) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"), Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")), } } @@ -107,7 +109,7 @@ fn node_pnpm_contract_present(root: &Path) -> bool { .unwrap_or(false) } -fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult { +fn dispatch_toolchain_resolver(root: &Path, sink: &dyn crate::WriteSink) -> TemplateResult { if !node_pnpm_contract_present(root) { return TemplateResult::new( "mise.toml", @@ -115,7 +117,7 @@ fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult { "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包", ); } - dispatch_one(root, "mise.toml", TMPL_MISE) + dispatch_one(root, "mise.toml", TMPL_MISE, sink) } /// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。 @@ -135,19 +137,22 @@ fn block_version(text: &str) -> Option<&str> { } /// 版本化分发(三态):不存在→写全新;有标记→原位升级块;无标记→末尾追加块。 -fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateResult { +fn dispatch_versioned( + root: &Path, + relative: &str, + template: &str, + sink: &dyn crate::WriteSink, +) -> TemplateResult { let target = root.join(relative); let tpl_block = match extract_managed_block(template) { Some(b) => b, - None => return dispatch_one(root, relative, template), // 模板无标记,退化为旧行为 + None => return dispatch_one(root, relative, template, sink), // 模板无标记,退化为旧行为 }; let tpl_ver = block_version(tpl_block).unwrap_or("?"); - if !target.exists() { - return dispatch_one(root, relative, template); - } - let content = match std::fs::read_to_string(&target) { - Ok(c) => c, + let content = match sink.read(&target) { + Ok(Some(c)) => c, + Ok(None) => return dispatch_one(root, relative, template, sink), Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), }; @@ -174,7 +179,7 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe ) }; - match std::fs::write(&target, new_content) { + match sink.write(&target, &new_content) { Ok(_) => TemplateResult::new(relative, action, detail), Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")), } @@ -184,33 +189,50 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe /// project_path 为项目根(WSL 项目为 \\wsl.localhost\... UNC 路径,可直接写)。 pub fn dispatch_templates(project_path: &str) -> Vec { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); + dispatch_templates_with(root, &crate::DiskSink) +} + +/// dispatch_templates 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。 +pub(crate) fn dispatch_templates_with( + root: &Path, + sink: &dyn crate::WriteSink, +) -> Vec { vec![ - dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS), - dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), - dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), - dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), + dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink), + dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink), + dispatch_versioned( + root, + "docs/ai-context/git-workflow.md", + TMPL_GIT_WORKFLOW, + sink, + ), + dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY, sink), dispatch_fully_managed( root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE, + sink, ), dispatch_fully_managed( root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL, + sink, ), // Claude Code 不读 .agents/skills,同一模板投影一份(单一来源,双投影)。 dispatch_fully_managed( root, ".claude/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL, + sink, ), dispatch_fully_managed( root, ".lian-jing/capability-packs.json", TMPL_CAPABILITY_PACKS, + sink, ), - dispatch_toolchain_resolver(root), + dispatch_toolchain_resolver(root, sink), ] } @@ -222,7 +244,7 @@ pub fn dispatch_templates(project_path: &str) -> Vec { /// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED, /// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。 -const MANAGED_BLOCK_FILES: [&str; 3] = +pub(crate) const MANAGED_BLOCK_FILES: [&str; 3] = ["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"]; /// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。 @@ -266,7 +288,7 @@ fn outside_managed_differs(head: Option<&str>, worktree: &str) -> bool { /// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。 /// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。 -fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool { +pub(crate) fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool { // --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突 let status = crate::silent_cmd("git") .args([ @@ -298,7 +320,7 @@ fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) outside_managed_differs(head.as_deref(), worktree) } -fn mixed_file_warning(relative: &str) -> String { +pub(crate) fn mixed_file_warning(relative: &str) -> String { format!( "{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\ 提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。" @@ -2242,19 +2264,19 @@ mod tests { std::fs::create_dir_all(&base).unwrap(); // 不存在 → written - let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); assert_eq!(r.action, "written"); // 一致 → skipped(CRLF 检出也视为一致) - let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); assert_eq!(r.action, "skipped"); std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap(); - let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖"); // 不一致(旧版模板/被改动)→ 覆盖 updated std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap(); - let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); assert_eq!(r.action, "updated"); assert_eq!( std::fs::read_to_string(base.join("envoy.md")).unwrap(), @@ -2305,7 +2327,7 @@ mod tests { ) .unwrap(); - let first = dispatch_toolchain_resolver(&base); + let first = dispatch_toolchain_resolver(&base, &crate::DiskSink); assert_eq!(first.action, "written"); assert_eq!(check_toolchain_resolver(&base).state, VerifyState::Ready); @@ -2314,7 +2336,7 @@ mod tests { "[tasks.custom]\nrun = 'project-owned'\n", ) .unwrap(); - let second = dispatch_toolchain_resolver(&base); + let second = dispatch_toolchain_resolver(&base, &crate::DiskSink); assert_eq!(second.action, "skipped"); assert_eq!( std::fs::read_to_string(base.join("mise.toml")).unwrap(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 451377d..4186a38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,41 @@ pub(crate) fn write_if_content_changed( std::fs::write(path, content).map(|()| true) } +/// 接入写入汇(agent-client-parity P8c):接入包、蓝图规则、项目身份与 MCP 投影这些项目文件写入 +/// 只经它读写。`DiskSink` 真实写入;onboard::plan 的记录汇只记录不落盘,并让后续步骤读到前面 +/// 步骤的待写内容(同一文件被两步先后改写时,计划与实际写入算出同样的结果)。 +pub(crate) trait WriteSink { + /// 读取当前内容(含本汇里尚未落盘的待写内容);文件不存在返回 Ok(None)。 + fn read(&self, path: &std::path::Path) -> std::io::Result>; + /// 内容(忽略行尾)未变返回 Ok(false);否则写入(必要时先建父目录)并返回 Ok(true)。 + fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result; +} + +/// 真实写入:内容不变不重写(write_if_content_changed),父目录按需创建。 +pub(crate) struct DiskSink; + +impl WriteSink for DiskSink { + fn read(&self, path: &std::path::Path) -> std::io::Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result { + if let Some(existing) = self.read(path)? { + if same_text_ignoring_eol(&existing, content) { + return Ok(false); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content).map(|()| true) + } +} + /// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。 /// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。 pub(crate) fn silent_cmd(program: &str) -> std::process::Command { diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 573e753..781095b 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -168,20 +168,22 @@ fn settings_path(project_id: &str) -> Result { .join("settings.json")) } -fn liangjing_path(project_id: &str) -> Result { - Ok(project_root(project_id)? - .join(".claude") - .join("liangjing.json")) -} - fn codex_config_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".codex").join("config.toml")) } fn read_context_object(path: &PathBuf, label: &str) -> Result { - match std::fs::read_to_string(path) { - Ok(content) if content.trim().is_empty() => Ok(json!({})), - Ok(content) => serde_json::from_str::(&content) + read_context_object_with(path, label, &crate::DiskSink) +} + +fn read_context_object_with( + path: &std::path::Path, + label: &str, + sink: &dyn crate::WriteSink, +) -> Result { + match sink.read(path) { + Ok(Some(content)) if content.trim().is_empty() => Ok(json!({})), + Ok(Some(content)) => serde_json::from_str::(&content) .map_err(|error| format!("读取 {label} 失败({}): {error}", path.display())) .and_then(|value| { if value.is_object() { @@ -193,59 +195,135 @@ fn read_context_object(path: &PathBuf, label: &str) -> Result { )) } }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Ok(None) => Ok(json!({})), Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())), } } -/// 现有 JSON 文件与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写: +/// 现有 JSON 文本与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写: /// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。 -fn json_file_equals(path: &std::path::Path, value: &Value) -> bool { - std::fs::read_to_string(path) - .ok() +fn json_text_equals(existing: Option<&str>, value: &Value) -> bool { + existing .and_then(|text| serde_json::from_str::(text.trim_start_matches('\u{feff}')).ok()) .is_some_and(|existing| &existing == value) } -fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> { - if json_file_equals(path, value) { +/// 语义相同不写;否则以 pretty JSON 经写入汇写入(父目录由写入汇按需创建)。 +fn write_json_with( + path: &std::path::Path, + value: &Value, + label: &str, + sink: &dyn crate::WriteSink, +) -> Result<(), String> { + let existing = sink + .read(path) + .map_err(|error| format!("读取 {label} 失败: {error}"))?; + if json_text_equals(existing.as_deref(), value) { return Ok(()); } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("创建 {label} 目录失败: {error}"))?; - } let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; - crate::write_if_content_changed(path, &content) + sink.write(path, &content) .map(|_| ()) .map_err(|error| format!("写入 {label} 失败: {error}")) } /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 /// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。 +#[cfg(test)] fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> { + write_canonical_context_with(root, project_id, &crate::DiskSink) +} + +pub(crate) fn write_canonical_context_with( + root: &std::path::Path, + project_id: &str, + sink: &dyn crate::WriteSink, +) -> Result<(), String> { let path = root.join(".lian-jing").join("context.json"); - let mut ctx = read_context_object(&path, "canonical 项目上下文")?; + let mut ctx = read_context_object_with(&path, "canonical 项目上下文", sink)?; let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION)); obj.insert("project_id".into(), json!(project_id)); - write_context_object(&path, &ctx, "canonical 项目上下文") -} - -fn write_canonical_context(project_id: &str) -> Result<(), String> { - let root = project_root(project_id)?; - write_canonical_context_at(&root, project_id) + write_json_with(&path, &ctx, "canonical 项目上下文", sink) } /// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。 -fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { - let path = liangjing_path(project_id)?; - let mut ctx = read_context_object(&path, "Claude 兼容上下文")?; +pub(crate) fn write_liangjing_context_with( + root: &std::path::Path, + project_id: &str, + port: u16, + sink: &dyn crate::WriteSink, +) -> Result<(), String> { + let path = root.join(".claude").join("liangjing.json"); + let mut ctx = read_context_object_with(&path, "Claude 兼容上下文", sink)?; let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); obj.insert("project_id".into(), json!(project_id)); obj.insert("mcp_port".into(), json!(port)); obj.remove("group_id"); - write_context_object(&path, &ctx, "Claude 兼容上下文") + write_json_with(&path, &ctx, "Claude 兼容上下文", sink) +} + +/// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容): +/// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。 +pub(crate) fn write_settings_compat_with( + root: &std::path::Path, + server_url: &str, + sink: &dyn crate::WriteSink, +) -> Result<(), String> { + let path = root.join(".claude").join("settings.json"); + let mut settings = match sink.read(&path) { + Ok(None) => json!({}), + Ok(Some(content)) if content.trim().is_empty() => json!({}), + Ok(Some(content)) => serde_json::from_str(&content).map_err(|e| { + format!( + "settings.json 格式错误,请手动修复后重试({}): {}", + path.display(), + e + ) + })?, + Err(e) => return Err(format!("读取 settings.json 失败: {}", e)), + }; + + let obj = settings + .as_object_mut() + .ok_or("settings.json 顶层必须是 JSON 对象")?; + + // 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断) + obj.entry("permissions") + .or_insert(json!({"defaultMode": "bypassPermissions"})); + + let servers = obj.entry("mcpServers").or_insert(json!({})); + + if let Some(map) = servers.as_object_mut() { + map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); + } + servers[MCP_KEY] = json!({ + "type": "sse", + "url": server_url + }); + write_json_with(&path, &settings, "settings.json", sink) +} + +/// 项目侧 MCP 文件(canonical context、Claude 兼容投影、Codex 托管块)一次写齐; +/// inject 与 onboard::plan 共用同一序列,计划与实际写入的结果一致。返回 Codex 托管块结果。 +pub(crate) fn write_project_mcp_files_with( + root: &std::path::Path, + project_id: &str, + port: u16, + server_url: &str, + header_helper: &str, + sink: &dyn crate::WriteSink, +) -> Result { + write_canonical_context_with(root, project_id, sink)?; + write_settings_compat_with(root, server_url, sink)?; + write_liangjing_context_with(root, project_id, port, sink)?; + Ok(upsert_codex_mcp_config_with( + &root.join(".codex").join("config.toml"), + server_url, + header_helper, + sink, + ) + .unwrap_or_else(|reason| ClientOutcome::Failed { reason })) } /// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) @@ -1347,16 +1425,7 @@ fn read_json(path: &PathBuf) -> Result { } fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { - if json_file_equals(path, value) { - return Ok(()); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?; - } - let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; - crate::write_if_content_changed(path, &content) - .map(|_| ()) - .map_err(|e| format!("写入 settings.json 失败: {}", e)) + write_json_with(path, value, "settings.json", &crate::DiskSink) } fn render_codex_block(server_url: &str, header_helper: &str) -> Result { @@ -1417,13 +1486,21 @@ fn upsert_codex_mcp_config( path: &PathBuf, server_url: &str, header_helper: &str, +) -> Result { + upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink) +} + +fn upsert_codex_mcp_config_with( + path: &std::path::Path, + server_url: &str, + header_helper: &str, + sink: &dyn crate::WriteSink, ) -> Result { let block = render_codex_block(server_url, header_helper)?; - let current = match std::fs::read_to_string(path) { - Ok(value) => value, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")), - }; + let current = sink + .read(path) + .map_err(|error| format!("读取 Codex config.toml 失败: {error}"))? + .unwrap_or_default(); let (start, end) = ( current.find(CODEX_MCP_MANAGED_START), current.find(CODEX_MCP_MANAGED_END), @@ -1448,11 +1525,7 @@ fn upsert_codex_mcp_config( (None, None) => format!("{}\n{}", current.trim_end(), block), }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("创建 .codex 目录失败: {error}"))?; - } - crate::write_if_content_changed(path, &next) + sink.write(path, &next) .map(|written| { if written { ClientOutcome::Applied @@ -1538,34 +1611,12 @@ fn inject_project_mcp_locked( )?; ensure_header_shim()?; let header_helper = shim_helper_command(&digest); - write_canonical_context(project_id)?; - let path = settings_path(project_id)?; - let mut settings = read_json(&path)?; - - let obj = settings - .as_object_mut() - .ok_or("settings.json 顶层必须是 JSON 对象")?; - - // 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断) - obj.entry("permissions") - .or_insert(json!({"defaultMode": "bypassPermissions"})); - - let servers = obj.entry("mcpServers").or_insert(json!({})); - - if let Some(map) = servers.as_object_mut() { - map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); - } - servers[MCP_KEY] = json!({ - "type": "sse", - "url": server_url - }); - - write_json(&path, &settings)?; - - // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 - write_liangjing_context(project_id, port)?; - let root = project_root(project_id)?; + write_canonical_context_with(&root, project_id, &crate::DiskSink)?; + write_settings_compat_with(&root, &server_url, &crate::DiskSink)?; + // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 + write_liangjing_context_with(&root, project_id, port, &crate::DiskSink)?; + let facts = project_facts(project_id, &root); let desired = DesiredMcp { port, @@ -1636,6 +1687,166 @@ pub fn client_static_status(project_id: &str) -> Vec { .collect() } +// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ────────────────────────── + +/// 一键接入写入前计划(只读:不写文件、不起子进程)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 +#[allow(dead_code)] +pub(crate) fn plan_onboarding( + project_id: &str, +) -> Result { + let root = project_root(project_id)?; + let port = crate::mcp_server::read_port(); + let digest = crate::credential_store::mcp_auth_digest(project_id)?; + let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); + let header_helper = shim_helper_command(&digest); + let project = crate::onboard::plan::ProjectWrite { + project_id, + port, + server_url: &server_url, + header_helper: &header_helper, + }; + Ok(crate::onboard::plan::plan_onboarding( + &root, + &project, + plan_machine_steps(project_id, &root), + )) +} + +/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)、Codex 信任。 +#[allow(dead_code)] +fn plan_machine_steps( + project_id: &str, + root: &std::path::Path, +) -> Vec { + use crate::onboard::plan::MachineStep; + let step = |step: &str, status: &str, detail: String| MachineStep { + step: step.into(), + status: status.into(), + detail, + }; + let mut steps = vec![ + if is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) { + step( + "token", + "noop", + "项目 MCP 认证 token 已在本机受保护存储".into(), + ) + } else { + step( + "token", + "apply", + "将在本机受保护存储生成项目 MCP 认证 token".into(), + ) + }, + shim_step(), + ]; + for report in client_static_status(project_id) { + let name = match report.client { + ClientId::Claude => "claude", + ClientId::Codex => "codex", + ClientId::Dsh => "dsh", + }; + steps.push(step( + name, + outcome_status(&report.outcome), + report.outcome.describe(), + )); + } + steps.push(match codex_trusts(root) { + Some(true) => step("codex_trust", "noop", "Codex 已信任该目录".into()), + Some(false) => step( + "codex_trust", + "needs_human", + "Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)".into(), + ), + None => step("codex_trust", "not_installed", "未发现 Codex 配置".into()), + }); + steps +} + +#[allow(dead_code)] +fn shim_step() -> crate::onboard::plan::MachineStep { + let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) { + (Ok(dir), Ok(exe)) => { + let path = dir.join(MCP_HEADER_SHIM_NAME); + let expected = render_header_shim(&exe); + if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) { + ("noop", format!("稳定入口已是最新:{}", path.display())) + } else { + ("apply", format!("将写入稳定入口:{}", path.display())) + } + } + (Err(error), _) => ("unavailable", error), + (_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()), + }; + crate::onboard::plan::MachineStep { + step: "shim".into(), + status: status.into(), + detail, + } +} + +/// 只读判定结果 → 计划里的本机动作状态。 +#[allow(dead_code)] +fn outcome_status(outcome: &ClientOutcome) -> &'static str { + match outcome { + ClientOutcome::Converged => "noop", + ClientOutcome::Missing + | ClientOutcome::Applied + | ClientOutcome::Refreshed + | ClientOutcome::Removed + | ClientOutcome::Deferred => "apply", + ClientOutcome::NotInstalled => "not_installed", + ClientOutcome::NotApplicable { .. } => "not_applicable", + ClientOutcome::ManualKept => "manual_kept", + ClientOutcome::OtherProject { .. } => "other_project", + ClientOutcome::NeedsHuman { .. } => "needs_human", + ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable", + } +} + +/// Codex 用户配置里是否把该目录(或其祖先目录)标为 trusted;未发现 Codex 配置返回 None。 +/// 只读、只认 `[projects.'<路径>']` 段下的 `trust_level = "trusted"`(P4-0 H-P8-1:未信任目录不加载项目配置)。 +#[allow(dead_code)] +fn codex_trusts(root: &std::path::Path) -> Option { + let home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")) + })?; + let text = std::fs::read_to_string(home.join("config.toml")).ok()?; + Some(codex_config_trusts(&text, &root.display().to_string())) +} + +#[allow(dead_code)] +fn codex_config_trusts(config: &str, root: &str) -> bool { + let normalize = |path: &str| { + path.replace('/', "\\") + .trim_end_matches('\\') + .to_lowercase() + }; + let root = normalize(root); + let mut section: Option = None; + for line in config.lines().map(str::trim) { + if line.starts_with('[') { + section = line + .strip_prefix("[projects.") + .and_then(|rest| rest.strip_suffix(']')) + .map(|key| normalize(key.trim_matches(['\'', '"']))); + continue; + } + let Some(key) = §ion else { continue }; + let covers = &root == key || root.starts_with(&format!("{key}\\")); + let trusted = line.split_once('=').is_some_and(|(name, value)| { + name.trim() == "trust_level" && value.trim().trim_matches('"') == "trusted" + }); + if covers && trusted { + return true; + } + } + false +} + // ── 三端客户端适配器(注册表见 onboard::clients::registry)────────────────────── pub(crate) struct ClaudeAdapter; @@ -2092,7 +2303,7 @@ mod tests { std::fs::write(&path, client_written).unwrap(); let value: Value = serde_json::from_str(client_written).unwrap(); write_json(&path, &value).unwrap(); - write_context_object(&path, &value, "settings.json").unwrap(); + write_json_with(&path, &value, "settings.json", &crate::DiskSink).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), client_written, @@ -2656,6 +2867,29 @@ mod tests { )); } + #[test] + fn codex_trust_reads_projects_table_including_ancestors() { + let config = "model = \"x\"\n\n[projects.'e:\\code-project\\enterprise-system']\ntrust_level = \"trusted\"\n\n[projects.\"D:\\Work\"]\ntrust_level = \"trusted\"\n\n[projects.'e:\\other']\ntrust_level = \"untrusted\"\n"; + assert!(codex_config_trusts( + config, + "E:\\code-project\\enterprise-system" + )); + assert!(codex_config_trusts( + config, + "E:/code-project/enterprise-system/" + )); + assert!( + codex_config_trusts(config, "d:\\work\\sub\\proj"), + "祖先目录受信任" + ); + assert!(!codex_config_trusts(config, "E:\\other"), "untrusted 不算"); + assert!( + !codex_config_trusts(config, "E:\\code-project\\enterprise-system-wt"), + "前缀不是路径边界" + ); + assert!(!codex_config_trusts(config, "C:\\new")); + } + #[test] fn hard_failure_is_only_codex_failed() { let report = |client, outcome| ClientReport::new(client, outcome); diff --git a/src-tauri/src/onboard/mod.rs b/src-tauri/src/onboard/mod.rs index e273b6f..ce4eb9a 100644 --- a/src-tauri/src/onboard/mod.rs +++ b/src-tauri/src/onboard/mod.rs @@ -6,6 +6,7 @@ //! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。 pub mod clients; +pub mod plan; #[cfg(test)] mod fitness_tests { diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs new file mode 100644 index 0000000..ce4a916 --- /dev/null +++ b/src-tauri/src/onboard/plan.rs @@ -0,0 +1,588 @@ +//! 写入前计划(agent-client-parity P8c):用记录汇把一键接入会写的项目文件「演算」一遍, +//! 给出逐文件动作、有上限的 diff、混合文件预警与身份冲突,并对计划内容算 digest。 +//! +//! 为什么:一键接入改写的是子项目受版本管理的文件,人在确认前必须看得到改什么; +//! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。 +//! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。 + +use crate::WriteSink; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。 +#[derive(Default)] +pub(crate) struct PlanSink { + pending: RefCell>, + touched: RefCell>, +} + +impl WriteSink for PlanSink { + fn read(&self, path: &Path) -> std::io::Result> { + if let Some(content) = self.pending.borrow().get(path) { + return Ok(Some(content.clone())); + } + read_disk(path) + } + + fn write(&self, path: &Path, content: &str) -> std::io::Result { + { + let mut touched = self.touched.borrow_mut(); + if !touched.iter().any(|seen| seen == path) { + touched.push(path.to_path_buf()); + } + } + if let Some(current) = self.read(path)? { + if crate::same_text_ignoring_eol(¤t, content) { + return Ok(false); + } + } + // 写回与磁盘一致(先改后又改回)时撤销待写,避免把「无变化」报成 update + let unchanged_on_disk = + read_disk(path)?.is_some_and(|disk| crate::same_text_ignoring_eol(&disk, content)); + let mut pending = self.pending.borrow_mut(); + if unchanged_on_disk { + pending.remove(path); + } else { + pending.insert(path.to_path_buf(), content.to_string()); + } + Ok(true) + } +} + +fn read_disk(path: &Path) -> std::io::Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileAction { + Create, + Update, + Noop, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlannedFile { + /// 相对项目根,统一用 / + pub path: String, + pub action: FileAction, + /// update 时的行级差异(有上限,超出截断并标注);create 时为新文件概要 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, + /// 计划写入内容的 sha256(noop 为当前内容的 sha256),进入 digest + pub content_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockReason { + /// 托管块文件块外有未提交改动,本次又要改写托管块 + MixedFile, + /// .lian-jing/context.json 已属于别的 project_id + IdentityConflict, + /// 客户端已有手写同名配置(炼境不覆盖) + ManualEntry, + /// 生效的客户端条目属于别的项目(共享项目键) + SharedKey, + /// 需要人工处理或无法确认(读失败、标记错序等) + NeedsHuman, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockedItem { + pub target: String, + pub reason: BlockReason, + pub detail: String, +} + +/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置、Codex 信任等。 +/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | needs_human | unavailable。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MachineStep { + pub step: String, + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OnboardingPlan { + pub project_id: String, + pub files: Vec, + pub machine_changes: Vec, + pub blocked: Vec, + /// 覆盖项目文件新内容哈希、本机动作与阻断项;apply 须带回同一 digest + pub digest: String, +} + +/// 接入要写的项目侧连接参数(与 inject 同源)。 +pub struct ProjectWrite<'a> { + pub project_id: &'a str, + pub port: u16, + pub server_url: &'a str, + pub header_helper: &'a str, +} + +/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与 MCP 投影),plan 与 apply 共用。 +/// 返回分发过程中的失败与需人工项(不中断其余写入)。 +pub(crate) fn write_project_files( + root: &Path, + project: &ProjectWrite, + sink: &dyn WriteSink, +) -> Vec { + let mut issues = Vec::new(); + for result in crate::commands::onboarding::dispatch_templates_with(root, sink) { + if result.action == "failed" { + issues.push(BlockedItem { + target: result.name, + reason: BlockReason::NeedsHuman, + detail: result.detail, + }); + } + } + if let Err(error) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) { + issues.push(BlockedItem { + target: ".blueprint/CONVENTIONS.md".into(), + reason: BlockReason::NeedsHuman, + detail: error, + }); + } + use super::clients::ClientOutcome; + match crate::mcp_inject::write_project_mcp_files_with( + root, + project.project_id, + project.port, + project.server_url, + project.header_helper, + sink, + ) { + Ok(ClientOutcome::ManualKept) => issues.push(BlockedItem { + target: ".codex/config.toml".into(), + reason: BlockReason::ManualEntry, + detail: "已手工定义 [mcp_servers.lian-jing],炼境不会覆盖".into(), + }), + Ok(ClientOutcome::Failed { reason }) | Err(reason) => issues.push(BlockedItem { + target: ".lian-jing/context.json 等项目 MCP 文件".into(), + reason: BlockReason::NeedsHuman, + detail: reason, + }), + Ok(_) => {} + } + issues +} + +/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作,生成计划与 digest。 +/// 不写任何文件(目录快照前后字节一致)。 +pub(crate) fn plan_onboarding( + root: &Path, + project: &ProjectWrite, + machine_changes: Vec, +) -> OnboardingPlan { + let sink = PlanSink::default(); + let mut blocked = identity_conflict(root, project.project_id) + .into_iter() + .collect::>(); + blocked.extend(write_project_files(root, project, &sink)); + + let pending = sink.pending.borrow(); + let mut files = Vec::new(); + for path in sink.touched.borrow().iter() { + let relative = relative_path(root, path); + let disk = read_disk(path).ok().flatten(); + let (action, content, diff) = match (pending.get(path), &disk) { + (Some(new), None) => ( + FileAction::Create, + new.clone(), + Some(format!("新建,{} 行", new.lines().count())), + ), + (Some(new), Some(old)) => ( + FileAction::Update, + new.clone(), + Some(bounded_diff(old, new, DIFF_LINE_LIMIT)), + ), + (None, current) => (FileAction::Noop, current.clone().unwrap_or_default(), None), + }; + let mut warning = None; + if action == FileAction::Update + && crate::commands::onboarding::MANAGED_BLOCK_FILES.contains(&relative.as_str()) + && crate::commands::onboarding::has_uncommitted_outside_managed( + root, + &relative, + disk.as_deref().unwrap_or(""), + ) + { + let text = crate::commands::onboarding::mixed_file_warning(&relative); + blocked.push(BlockedItem { + target: relative.clone(), + reason: BlockReason::MixedFile, + detail: text.clone(), + }); + warning = Some(text); + } + files.push(PlannedFile { + path: relative, + action, + diff, + warning, + content_sha256: sha256_hex(&content.replace("\r\n", "\n")), + }); + } + for step in &machine_changes { + if let Some(reason) = match step.status.as_str() { + "manual_kept" => Some(BlockReason::ManualEntry), + "other_project" => Some(BlockReason::SharedKey), + "needs_human" => Some(BlockReason::NeedsHuman), + _ => None, + } { + blocked.push(BlockedItem { + target: step.step.clone(), + reason, + detail: step.detail.clone(), + }); + } + } + let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked); + OnboardingPlan { + project_id: project.project_id.to_string(), + files, + machine_changes, + blocked, + digest, + } +} + +/// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。 +fn identity_conflict(root: &Path, project_id: &str) -> Option { + let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??; + let existing = serde_json::from_str::(text.trim_start_matches('\u{feff}')) + .ok()? + .get("project_id")? + .as_str()? + .to_string(); + (existing != project_id).then(|| BlockedItem { + target: ".lian-jing/context.json".into(), + reason: BlockReason::IdentityConflict, + detail: format!("目录已属于项目 {existing},与本次接入的 {project_id} 不一致"), + }) +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn sha256_hex(text: &str) -> String { + use sha2::Digest; + sha2::Sha256::digest(text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// digest 覆盖项目 ID、逐文件(路径、动作、内容哈希)、本机动作与阻断项。 +fn plan_digest( + project_id: &str, + files: &[PlannedFile], + machine: &[MachineStep], + blocked: &[BlockedItem], +) -> String { + let canonical = serde_json::json!({ + "project_id": project_id, + "files": files.iter().map(|f| (f.path.as_str(), f.action, f.content_sha256.as_str())).collect::>(), + "machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::>(), + "blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::>(), + }); + sha256_hex(&canonical.to_string()) +} + +const DIFF_LINE_LIMIT: usize = 60; + +/// 行级差异(去掉公共前后缀后列出删除与新增行),超过 limit 行截断并注明总行数。 +fn bounded_diff(old: &str, new: &str, limit: usize) -> String { + let old = old.replace("\r\n", "\n"); + let new = new.replace("\r\n", "\n"); + let a: Vec<&str> = old.lines().collect(); + let b: Vec<&str> = new.lines().collect(); + let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + let max_suffix = a.len().min(b.len()) - prefix; + let suffix = a + .iter() + .rev() + .zip(b.iter().rev()) + .take(max_suffix) + .take_while(|(x, y)| x == y) + .count(); + let mut lines = vec![format!("@@ 第 {} 行起", prefix + 1)]; + lines.extend( + a[prefix..a.len() - suffix] + .iter() + .map(|line| format!("-{line}")), + ); + lines.extend( + b[prefix..b.len() - suffix] + .iter() + .map(|line| format!("+{line}")), + ); + let total = lines.len(); + if total > limit { + lines.truncate(limit); + lines.push(format!("…(diff 共 {total} 行,只显示前 {limit} 行)")); + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn git(dir: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git 可执行"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + /// 目录快照:相对路径 → 字节(含 .git,证明 plan 零写入)。 + fn snapshot(root: &Path) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else { + out.insert(relative_path(root, &path), std::fs::read(&path).unwrap()); + } + } + } + out + } + + const PROJECT: ProjectWrite<'static> = ProjectWrite { + project_id: "p8c-test", + port: 27190, + server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test", + header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc", + }; + + fn machine(status: &str) -> Vec { + vec![MachineStep { + step: "claude".into(), + status: status.into(), + detail: String::new(), + }] + } + + fn temp_repo(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("lj_p8c_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + git(&root, &["init", "-q"]); + std::fs::write(root.join("README.md"), "readme\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-q", "--no-verify", "-m", "init"]); + root + } + + #[test] + fn plan_on_empty_repo_creates_everything_and_writes_nothing() { + let root = temp_repo("empty"); + let before = snapshot(&root); + let plan = plan_onboarding(&root, &PROJECT, machine("apply")); + assert_eq!(snapshot(&root), before, "plan 不得写任何文件"); + assert!(plan.files.len() >= 12, "实际 {} 个文件", plan.files.len()); + assert!( + plan.files.iter().all(|f| f.action == FileAction::Create), + "{:?}", + plan.files + ); + for expected in [ + "AGENTS.md", + "CLAUDE.md", + ".blueprint/CONVENTIONS.md", + ".lian-jing/context.json", + ".claude/settings.json", + ".claude/liangjing.json", + ".codex/config.toml", + ".agents/skills/lian-jing/SKILL.md", + ".claude/skills/lian-jing/SKILL.md", + ] { + assert!( + plan.files.iter().any(|f| f.path == expected), + "缺 {expected}" + ); + } + assert!(plan.blocked.is_empty(), "{:?}", plan.blocked); + assert_eq!(plan.digest.len(), 64); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn plan_after_apply_is_noop_and_digest_tracks_changes() { + let root = temp_repo("applied"); + // apply:同一写入序列换成真实写入汇 + assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty()); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-q", "--no-verify", "-m", "onboard"]); + + let first = plan_onboarding(&root, &PROJECT, machine("noop")); + let second = plan_onboarding(&root, &PROJECT, machine("noop")); + assert!( + first.files.iter().all(|f| f.action == FileAction::Noop), + "{:?}", + first.files + ); + assert_eq!(first.digest, second.digest, "同一状态两次计划 digest 相同"); + + // 本机预计动作变化 → digest 变化 + assert_ne!( + plan_onboarding(&root, &PROJECT, machine("apply")).digest, + first.digest + ); + + // CRLF 检出的同内容文件仍是 noop + let packs = root.join(".lian-jing/capability-packs.json"); + // 模板在 Windows 检出时可能已是 CRLF:先归一成 LF 再整体转 CRLF,避免造出 \r\r\n + let lf = std::fs::read_to_string(&packs) + .unwrap() + .replace("\r\n", "\n"); + std::fs::write(&packs, lf.replace('\n', "\r\n")).unwrap(); + let crlf = plan_onboarding(&root, &PROJECT, machine("noop")); + let not_noop: Vec<_> = crlf + .files + .iter() + .filter(|f| f.action != FileAction::Noop) + .map(|f| (f.path.clone(), f.action, f.diff.clone())) + .collect(); + assert!(not_noop.is_empty(), "{not_noop:?}"); + + // 任一目标文件内容变化 → 该文件 update、digest 变化 + std::fs::write(&packs, "{}\n").unwrap(); + let changed = plan_onboarding(&root, &PROJECT, machine("noop")); + let entry = changed + .files + .iter() + .find(|f| f.path == ".lian-jing/capability-packs.json") + .unwrap(); + assert_eq!(entry.action, FileAction::Update); + assert!(entry.diff.as_deref().unwrap().contains("-{}")); + assert_ne!(changed.digest, first.digest); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn mixed_file_and_identity_conflict_are_blocked() { + let root = temp_repo("blocked"); + assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty()); + // 把 AGENTS.md 的接入包托管块版本改旧并提交,让计划要升级它 + let agents = root.join("AGENTS.md"); + let text = std::fs::read_to_string(&agents).unwrap(); + let start = text.find("ONBOARDING_MANAGED_START version=\"").unwrap() + + "ONBOARDING_MANAGED_START version=\"".len(); + let end = start + text[start..].find('"').unwrap(); + std::fs::write(&agents, format!("{}0.0.1{}", &text[..start], &text[end..])).unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-q", "--no-verify", "-m", "old block"]); + // 块外未提交改动 + let dirty = std::fs::read_to_string(&agents).unwrap() + "\n项目自己的未提交段落\n"; + std::fs::write(&agents, dirty).unwrap(); + // 身份锚点属于别的项目 + std::fs::write( + root.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"someone-else"}"#, + ) + .unwrap(); + + let plan = plan_onboarding(&root, &PROJECT, machine("noop")); + let agents_entry = plan.files.iter().find(|f| f.path == "AGENTS.md").unwrap(); + assert_eq!(agents_entry.action, FileAction::Update); + assert!(agents_entry.warning.is_some(), "混合文件须带预警"); + assert!(plan + .blocked + .iter() + .any(|b| b.reason == BlockReason::MixedFile && b.target == "AGENTS.md")); + assert!(plan.blocked.iter().any( + |b| b.reason == BlockReason::IdentityConflict && b.detail.contains("someone-else") + )); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn manual_codex_entry_and_machine_manual_states_block() { + let root = temp_repo("manual"); + std::fs::create_dir_all(root.join(".codex")).unwrap(); + std::fs::write( + root.join(".codex/config.toml"), + "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n", + ) + .unwrap(); + let plan = plan_onboarding(&root, &PROJECT, machine("other_project")); + assert!(plan + .blocked + .iter() + .any(|b| b.reason == BlockReason::ManualEntry && b.target == ".codex/config.toml")); + assert!(plan + .blocked + .iter() + .any(|b| b.reason == BlockReason::SharedKey && b.target == "claude")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn plan_sink_overlays_pending_writes_and_reverts_to_noop() { + let root = std::env::temp_dir().join(format!("lj_p8c_sink_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let file = root.join("a.md"); + std::fs::write(&file, "v1\n").unwrap(); + let sink = PlanSink::default(); + assert!(sink.write(&file, "v2\n").unwrap()); + assert_eq!( + sink.read(&file).unwrap().as_deref(), + Some("v2\n"), + "后续步骤读到待写内容" + ); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "v1\n", "磁盘未动"); + assert!(sink.write(&file, "v1\n").unwrap(), "改回磁盘原样"); + assert!(sink.pending.borrow().is_empty(), "改回原样即撤销待写"); + assert!(!sink.write(&file, "v1\r\n").unwrap(), "仅行尾不同视为未变"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn bounded_diff_trims_common_lines_and_truncates() { + let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60); + assert_eq!(diff, "@@ 第 2 行起\n-b\n+B"); + let old: String = (0..200).map(|i| format!("o{i}\n")).collect(); + let new: String = (0..200).map(|i| format!("n{i}\n")).collect(); + let long = bounded_diff(&old, &new, 60); + assert_eq!(long.lines().count(), 61); + assert!(long.ends_with("…(diff 共 401 行,只显示前 60 行)")); + } +} -- 2.45.2 From a3d1cc169b09590db22af01c1ccc017d484744d8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 10:08:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test(onboard/parity):=20=E8=A1=A5=E3=80=8C?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=8D=B3=E5=AE=9E=E9=99=85=E5=86=99=E5=85=A5?= =?UTF-8?q?=E3=80=8D=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20plan=5Fonboarding=20=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=88P8c=20=E6=94=B6=E5=B0=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan_predicts_exactly_what_apply_writes:空仓库先 plan 再经 DiskSink apply,逐文件比对计划内容哈希与 实际写入,并核对 git status 的文件集合与计划一致。P8d 的 digest 复核依赖这条不变式; 变异 PlanSink::read 忽略待写内容时,原有 plan 端到端测试仍通过,只有本测试与记录汇单元测试失败。 - mcp_inject::plan_onboarding 注释由「不起子进程」改为「不起客户端 CLI,混合文件预警会起只读 git」, 与 has_uncommitted_outside_managed 的实际行为一致。 cargo test 311 passed / 0 failed / 5 ignored。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/mcp_inject.rs | 3 ++- src-tauri/src/onboard/plan.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 0a6e7f4..cef6253 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -1701,7 +1701,8 @@ pub fn client_static_status(project_id: &str) -> Vec { // ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ────────────────────────── -/// 一键接入写入前计划(只读:不写文件、不起子进程)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 +/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 +/// git status --no-optional-locks 与 git show)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 #[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs index ce4a916..79f6608 100644 --- a/src-tauri/src/onboard/plan.rs +++ b/src-tauri/src/onboard/plan.rs @@ -443,6 +443,38 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// 「看到的就是将写的」:计划列出的文件集合与内容哈希,和随后 apply 实际写入的完全一致 + /// (P8d 的 digest 复核依赖这条不变式)。 + #[test] + fn plan_predicts_exactly_what_apply_writes() { + let root = temp_repo("predict"); + let plan = plan_onboarding(&root, &PROJECT, machine("apply")); + assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty()); + for file in &plan.files { + let written = std::fs::read_to_string(root.join(&file.path)).unwrap(); + assert_eq!( + sha256_hex(&written.replace("\r\n", "\n")), + file.content_sha256, + "{} 计划内容与实际写入不一致", + file.path + ); + } + let status = std::process::Command::new("git") + .args(["status", "--porcelain", "-uall"]) + .current_dir(&root) + .output() + .expect("git 可执行"); + let mut changed: Vec = String::from_utf8_lossy(&status.stdout) + .lines() + .map(|line| line[3..].trim_matches('"').to_string()) + .collect(); + changed.sort(); + let mut planned: Vec = plan.files.iter().map(|f| f.path.clone()).collect(); + planned.sort(); + assert_eq!(changed, planned, "apply 写入的文件集合与计划一致"); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn plan_after_apply_is_noop_and_digest_tracks_changes() { let root = temp_repo("applied"); -- 2.45.2 From 04a006936367b6bbabb14885efb6ae908704ca0b Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 10:09:04 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P8c?= =?UTF-8?q?=20=E6=A0=87=20=F0=9F=94=B5=EF=BC=8C=E8=A1=A5=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E3=80=81=E9=AA=8C=E8=AF=81=E3=80=81=E8=87=AA=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E6=9C=89=E6=84=8F=E5=81=8F=E5=B7=AE=EF=BC=88P8c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现:WriteSink/DiskSink、三处分发改经写入汇、onboard/plan.rs 记录汇与计划/digest、 mcp_inject 本机侧预计动作;合并 master(PR #34)自动合并无冲突。 - 验证:合并后 cargo test 310 passed,补测试后 311 passed / 0 failed / 5 ignored;变异记录。 - 自查:acceptance 逐条对照;有意偏差为计划阶段完全不起 claude CLI(比「假 CLI 只收到 mcp get」更严)、 文件动作只有 create/update/noop、DiskSink 读失败报错不覆盖。 - 自查发现留待独立审查:非 UTF-8 已存在文件由 skipped 变 failed;bypassPermissions 未在计划单列; Codex 信任解析不识别基本字符串转义段头。T1:plan_onboarding 尚无投影,project-context.yaml 由 P8e 补。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 1dcfc6d..6bc9a39 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -249,13 +249,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - mcp_verify: "2026-10-03 PR #33 合并(ddd729c)、运行中的炼境重编译生效后:verify_onboarding(enterprise) 的 clients 三端均 converged;连续两次 inject_mcp 回执三端均「已一致(未改动)」,耗时 26 ms、112 ms,.lian-jing/context.json、.codex/config.toml、.claude/settings.json、.claude/liangjing.json 四个文件 mtime 不变,git status 前后均干净。" - known_limits: "WSL 项目已有的 Claude local 托管条目在端口变更后不再刷新(重注入对 WSL 记 not_applicable),而 remove_project_mcp 与 on_project_root_changed 对 WSL 根仍会起 CLI 清理——口径不完全一致,WSL 路径项目已按 2026-07-04 裁决放弃,暂不处理。Codex 托管块已存在时 upsert 不检查块外手写同名表(存量问题),只读判定会报 needs_human 提醒人工清理。注册表与适配器的端到端组合(缺 CLI 时其余两端照常)只在单元层分别验证,未做真实客户端的组合实测。" -### 📋 P8c — 写入前计划:WriteSink + plan_onboarding -- status: todo +### 🔵 P8c — 写入前计划:WriteSink + plan_onboarding +- status: in_progress - complexity: M - files: src-tauri/src/onboard/plan.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/mcp_inject.rs - depends: P8b - acceptance: "agent_verify: [cargo test onboard::plan] 临时 git 仓库:空仓库做 plan 全部是 create、目录快照前后字节一致;已接入仓库连续两次 plan 全部 noop、digest 相同,任一目标文件变化 digest 就变;块外有未提交改动时该文件带预警且 plan.blocked 非空;内容相同仅行尾为 CRLF 判 noop;超长 diff 截断并标注;context.json 是别的 project_id 时记 blocked(identity_conflict);machine_changes 列出各本机步骤的预计动作,计划阶段假 claude CLI 只收到 mcp get。[cargo test] 原有 onboarding、blueprint、mcp_inject 测试全部通过、行为不变。" - 说明:WriteSink 以 write_if_content_changed 作为真实写入汇;P10 的混合文件检测前移到写入之前;与 CI 的 plan/apply 同一模式(Q2)。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8c,提交 ef2c7fd):lib.rs 新增 WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink(真实写入,父目录按需创建)。接入包模板分发(dispatch_templates_with)、蓝图规则同步(sync_blueprint_rules_with)、项目身份与 MCP 投影(write_project_mcp_files_with:context.json、settings.json 兼容条目、liangjing.json、Codex 托管块)全部改经写入汇,原入口改为 DiskSink 薄包装。新增 onboard/plan.rs:PlanSink 只记录不落盘,后续步骤读到前面步骤的待写内容(AGENTS.md 先后被模板分发与蓝图规则改写,计划与实际写入算出同一结果);write_project_files 为 plan 与 apply(P8d)共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff(60 行)、混合文件预警(P10 前移到写入前,记 blocked(mixed_file))、身份冲突、手写 Codex 条目与本机 manual_kept/other_project/needs_human 阻断项,并对(项目 ID、逐文件路径/动作/内容哈希、本机步骤/状态、阻断项)算 sha256 digest。mcp_inject::plan_onboarding 为生产入口:本机侧预计动作 token(读受保护存储判断有无)、shim(比对稳定入口内容)、claude/codex/dsh(复用 P8b 的只读 client_static_status)、codex_trust(只读解析 ~/.codex/config.toml 的 [projects.'<路径>'] trust_level,含祖先目录)。2026-10-03 合并 master(PR #34 重复登记修复 U1/U2,同改 mcp_inject.rs、commands/onboarding.rs、mcp_tools.rs)自动合并无冲突。收尾补测试 plan_predicts_exactly_what_apply_writes(计划的文件集合与内容哈希 = 随后 DiskSink 实际写入,git status 文件集合一致),修正 mcp_inject::plan_onboarding 注释「不起子进程」为「不起客户端 CLI,混合文件预警会起只读 git」。 +- verification: "2026-10-03:合并 master 后全量 cargo test 310 passed / 0 failed / 5 ignored(ef2c7fd 时 307,PR #34 带入 3 条);补测试后 311 passed / 0 failed / 5 ignored,编译 0 warning。onboard::plan 7 条:空仓库全 create 且目录快照(含 .git)字节不变;apply 后连续两次计划全 noop、digest 相同,本机动作变化与目标文件变化 digest 都变;CRLF 检出同内容判 noop;混合文件带 warning 且 blocked(mixed_file)、context.json 属别的项目记 blocked(identity_conflict);手写 Codex 条目记 manual_entry、本机 other_project 记 shared_key;PlanSink 覆盖读与改回撤销;diff 截断与标注;计划=实际写入。mcp_inject 新增 codex_trust_reads_projects_table_including_ancestors。变异:PlanSink::read 忽略待写内容 → plan_predicts_exactly_what_apply_writes(AGENTS.md 哈希不符)与 plan_sink_overlays_pending_writes_and_reverts_to_noop 失败,原有 5 条 plan 测试仍通过(补测试前此变异只被单元测试捕获)。" +- 自查(对照 acceptance 逐条):空仓库全 create + 快照字节一致 ✅;两次 plan 全 noop、digest 相同 ✅;任一目标文件变化 digest 变——测试以 capability-packs.json 一个文件为代表,并另测本机动作变化 ✅;块外未提交改动带预警且 blocked 非空 ✅;CRLF 判 noop ✅;超长 diff 截断并标注——在 bounded_diff 单元层测试,未经 plan 端到端构造超长差异 ✅;identity_conflict ✅;machine_changes 列出各本机步骤 ✅(plan_machine_steps 依赖真实凭据存储与用户目录,无注入点,未做单元测试,只经 codex_config_trusts 与 P8b 的只读判定测试间接覆盖);原有 onboarding、blueprint、mcp_inject 测试全部通过 ✅。 +- 有意偏差:① 计划阶段完全不起 claude CLI(Claude 只读 ~/.claude.json 判定,沿用 P8b 的 static_status),比 acceptance「假 claude CLI 只收到 mcp get」更严;因此没有假 CLI 断言,由结构保证(static_status 路径无 CLI 调用)。② 文件动作只有 create/update/noop,设计文档 §1 的 append/skip 合并为 update/noop(acceptance 只要求 create/noop)。③ DiskSink 与 write_if_content_changed 同语义但未直接复用:另加按需建父目录,且读失败(非「不存在」)时报错而不是覆盖写。 +- 自查发现(留待独立审查处置,本次未改代码):① 行为变化——dispatch_one 原以 target.exists() 判「已存在跳过」,现经 sink.read 判定,已存在但非 UTF-8(如 UTF-16/GBK 的 lefthook.yml、mise.toml)由 skipped 变 failed,plan 中成为 needs_human 阻断;全托管文件读失败由覆盖写变 failed(更安全)。② 设计 §1 要求计划单列 bypassPermissions 默认值(P8 决策 Q4、P8g 视图需要),当前只体现在 .claude/settings.json 的 create 概要里,未单列;本机变更清单也还没有产品组与 buff、.git/hooks(lefthook install)两步——宜在 P8d 编排时补进 machine_changes。③ codex_config_trusts 只认 [projects.'…'] 段头,TOML 基本字符串里的转义反斜杠(如 `[projects."D:\\Work"]`)与内联表写法不识别(本机 Codex 实际写的是字面量字符串小写路径,已核对 13 个段头均为此格式)。 +- T1:plan_onboarding 目前无 MCP / Tauri 投影(P8e 才加),输出尚不是外部契约,docs/ai-context/project-context.yaml 未改;P8e 的 acceptance 已含「project-context.yaml 补 plan、digest 与状态语义」。 +- 待办:独立只读审查与修复、开 PR(M 级,由统筹会话安排);合并后标 ✅。 ### 📋 P8d — onboard_project 编排本体 - status: todo -- 2.45.2 From 479f4ac2cda553575fd91c0e38e323e4d5468f9f Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 10:53:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(onboard/parity):=20=E5=A4=84=E7=BD=AE?= =?UTF-8?q?=20P8c=20=E7=8B=AC=E7=AB=8B=E5=AE=A1=E6=9F=A5=E2=80=94=E2=80=94?= =?UTF-8?q?Codex=20=E4=BF=A1=E4=BB=BB=E6=94=B9=E8=BF=9B=20next=5Factions?= =?UTF-8?q?=E3=80=81=E5=AD=98=E5=9C=A8=E5=8D=B3=E8=B7=B3=E8=BF=87=E5=8F=AA?= =?UTF-8?q?=E5=88=A4=E5=AD=98=E5=9C=A8=E3=80=81token=20=E6=AD=A5=E5=8C=BA?= =?UTF-8?q?=E5=88=86=20needs=5Frepair=EF=BC=88P8c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex 未信任不再并入 blocked:计划新增 codex_trust 与 next_actions 字段,未信任或读不出 Codex 配置只进 next_actions,不阻断、不进 digest;「本机步骤 → 阻断项」抽成纯函数 machine_step_blocker。 - WriteSink 新增 exists():dispatch_one 只判存在,已存在的非 UTF-8 文件(UTF-16 lefthook.yml 等) 恢复为 skipped(修复 apply_onboarding_pack 回归);全托管文件读不出仍记 failed。 - token 步改用只读 mcp_auth_token_state 区分 Present / Missing / NeedsRepair,needs_repair 记 needs_human, 与 get_or_create_mcp_auth_token 的分支一一对应;is_project_injected 仍只作「已接入」判据。 - Codex 信任去掉未验证的祖先目录规则,候选为项目根 + git 主仓库根(worktree 继承主仓库信任); 键解析按 TOML 规则处理字面量键、基本字符串转义、[projects] 内联表与点号键;删掉非法 TOML 的假阳性用例。 - 写入序列只留一份:抽出 write_project_identity_files_with,inject 与 plan 共用;删除 write_project_mcp_files_with。 - digest 绑定 update 文件的写入前哈希(before_sha256),模块文档写明准确语义。 - 新增 WriteFailure,阻断项指向真正出错的文件;本机 codex 步指向 .codex/config.toml,blocked 按(target, reason)去重。 cargo test 318 passed / 0 failed / 5 ignored(新增 7 条),cargo check 0 warning;两轮变异验证均被对应用例捕获。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/blueprint.rs | 29 +- src-tauri/src/commands/onboarding.rs | 14 +- src-tauri/src/credential_store.rs | 64 ++++ src-tauri/src/lib.rs | 30 ++ src-tauri/src/mcp_inject.rs | 465 ++++++++++++++++++++------- src-tauri/src/onboard/plan.rs | 402 +++++++++++++++++++---- 6 files changed, 817 insertions(+), 187 deletions(-) diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 7c549ce..3db65d8 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1311,14 +1311,18 @@ fn upsert_managed_block_with( #[tauri::command] pub fn sync_blueprint_rules(project_path: String) -> Result { - sync_blueprint_rules_with(Path::new(&project_path), &crate::DiskSink) + Ok(sync_blueprint_rules_with( + Path::new(&project_path), + &crate::DiskSink, + )?) } /// sync_blueprint_rules 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。 +/// 失败带出真正出错的文件(CONVENTIONS.md、AGENTS.md 或 CLAUDE.md),计划的阻断项据此指向它。 pub(crate) fn sync_blueprint_rules_with( root: &Path, sink: &dyn crate::WriteSink, -) -> Result { +) -> Result { let bp_dir = root.join(".blueprint"); let project_name = root .file_name() @@ -1328,28 +1332,31 @@ pub(crate) fn sync_blueprint_rules_with( // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写; // .blueprint/ 由写入汇按需创建) + let conventions_path = bp_dir.join("CONVENTIONS.md"); let conventions_written = sink - .write( - &bp_dir.join("CONVENTIONS.md"), - &effective_conventions_content(), - ) - .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?; + .write(&conventions_path, &effective_conventions_content()) + .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}")) + .map_err(crate::WriteFailure::at(&conventions_path))?; // 2. AGENTS.md:完整蓝图工作流(跨工具单一来源,Qwen/Cursor/Copilot 均读) + let agents_path = root.join("AGENTS.md"); let agents_action = upsert_managed_block_with( - &root.join("AGENTS.md"), + &agents_path, &agents_managed_template(), &format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口(Claude / Qwen / Cursor / Copilot 等均读此文件)。"), sink, - )?; + ) + .map_err(crate::WriteFailure::at(&agents_path))?; // 3. CLAUDE.md:薄引用(Claude 经 @AGENTS.md 接入单一来源) + let claude_path = root.join("CLAUDE.md"); let claude_action = upsert_managed_block_with( - &root.join("CLAUDE.md"), + &claude_path, &claude_managed_template(), &format!("# {project_name}"), sink, - )?; + ) + .map_err(crate::WriteFailure::at(&claude_path))?; Ok(SyncResult { success: true, diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index dcc75a9..8f322f8 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -55,6 +55,7 @@ impl TemplateResult { } /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 +/// 只判存在、不读内容:已存在的非 UTF-8 文件(UTF-16、GBK 等项目自有文件)照旧跳过。 fn dispatch_one( root: &Path, relative: &str, @@ -62,10 +63,8 @@ fn dispatch_one( sink: &dyn crate::WriteSink, ) -> TemplateResult { let target = root.join(relative); - match sink.read(&target) { - Ok(Some(_)) => return TemplateResult::new(relative, "skipped", "已存在,未覆盖"), - Ok(None) => {} - Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), + if sink.exists(&target) { + return TemplateResult::new(relative, "skipped", "已存在,未覆盖"); } match sink.write(&target, content) { Ok(_) => TemplateResult::new(relative, "written", "已写入"), @@ -76,6 +75,7 @@ fn dispatch_one( /// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。 /// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。 /// 比较忽略行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 +/// 已存在但读不出(非 UTF-8、无权限)记 failed,不盲目覆盖(P8c 起;原为直接覆盖)。 fn dispatch_fully_managed( root: &Path, relative: &str, @@ -83,8 +83,10 @@ fn dispatch_fully_managed( sink: &dyn crate::WriteSink, ) -> TemplateResult { let target = root.join(relative); - if !matches!(sink.read(&target), Ok(Some(_))) { - return dispatch_one(root, relative, content, sink); + match sink.read(&target) { + Ok(Some(_)) => {} + Ok(None) => return dispatch_one(root, relative, content, sink), + Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), } match sink.write(&target, content) { Ok(false) => TemplateResult::new(relative, "skipped", "内容已是最新模板"), diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs index 7db4597..b718e69 100644 --- a/src-tauri/src/credential_store.rs +++ b/src-tauri/src/credential_store.rs @@ -112,6 +112,34 @@ pub fn read_mcp_auth_token_for_digest( decode_mcp_auth_token(bytes) } +/// 项目 MCP token 在受保护存储中的状态(只读,不生成)。分支与 get_or_create_mcp_auth_token 一一对应: +/// 读存储失败或 token 损坏时后者返回 needs_repair(不会重新生成),只有「未配置」才会生成。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpAuthTokenState { + Present, + Missing, + NeedsRepair(String), +} + +pub fn mcp_auth_token_state(store: &impl CredentialStore, project_id: &str) -> McpAuthTokenState { + let reference = match mcp_auth_digest(project_id) + .and_then(|digest| mcp_auth_reference_for_digest(&digest)) + { + Ok(reference) => reference, + Err(reason) => return McpAuthTokenState::NeedsRepair(reason), + }; + match store.get(&reference) { + Err(_) => { + McpAuthTokenState::NeedsRepair("MCP 认证材料 needs_repair:受保护存储不可用".into()) + } + Ok(None) => McpAuthTokenState::Missing, + Ok(Some(bytes)) => match decode_mcp_auth_token(bytes) { + Ok(_) => McpAuthTokenState::Present, + Err(reason) => McpAuthTokenState::NeedsRepair(reason), + }, + } +} + pub fn delete_mcp_auth_token_for_project( store: &impl CredentialStore, project_id: &str, @@ -629,6 +657,42 @@ mod tests { ); } + /// 一键接入计划的 token 步据此预告 apply:只有 Missing 会生成,NeedsRepair 时 apply 报错、不重新生成。 + #[test] + fn mcp_auth_token_state_matches_get_or_create_branches() { + use super::{mcp_auth_reference_for_digest, mcp_auth_token_state, McpAuthTokenState}; + let store = MemoryCredentialStore::new(); + assert_eq!( + mcp_auth_token_state(&store, "p1"), + McpAuthTokenState::Missing + ); + get_or_create_mcp_auth_token(&store, "p1").unwrap(); + assert_eq!( + mcp_auth_token_state(&store, "p1"), + McpAuthTokenState::Present + ); + + let corrupt = mcp_auth_reference_for_digest(&mcp_auth_digest("p2").unwrap()).unwrap(); + store.put(&corrupt, b"not-a-64-hex-token").unwrap(); + assert!(matches!( + mcp_auth_token_state(&store, "p2"), + McpAuthTokenState::NeedsRepair(reason) if reason.contains("needs_repair") + )); + assert!( + get_or_create_mcp_auth_token(&store, "p2") + .unwrap_err() + .contains("needs_repair"), + "token 损坏时 apply 不会重新生成" + ); + + let failing = MemoryCredentialStore::failing(); + assert!(matches!( + mcp_auth_token_state(&failing, "p1"), + McpAuthTokenState::NeedsRepair(reason) if reason.contains("受保护存储不可用") + )); + assert!(get_or_create_mcp_auth_token(&failing, "p1").is_err()); + } + #[test] fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() { let conn = migration_db(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4186a38..db4e3bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,12 +49,42 @@ pub(crate) trait WriteSink { fn read(&self, path: &std::path::Path) -> std::io::Result>; /// 内容(忽略行尾)未变返回 Ok(false);否则写入(必要时先建父目录)并返回 Ok(true)。 fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result; + /// 是否存在(含本汇里的待写内容)。「存在即跳过」的分发只需判存在,不读内容—— + /// 子项目自有的非 UTF-8 文件(如 PowerShell 5 重定向生成的 UTF-16 lefthook.yml)照旧跳过,不报读取失败。 + fn exists(&self, path: &std::path::Path) -> bool; +} + +/// 写入序列中某个文件读写失败:`path` 指向真正出错的文件(计划的阻断项据此给出目标), +/// `reason` 为原有错误文案;转成 String 时只取文案,原有返回 String 的入口行为不变。 +#[derive(Debug)] +pub(crate) struct WriteFailure { + pub(crate) path: std::path::PathBuf, + pub(crate) reason: String, +} + +impl WriteFailure { + pub(crate) fn at(path: &std::path::Path) -> impl FnOnce(String) -> WriteFailure + '_ { + move |reason| WriteFailure { + path: path.to_path_buf(), + reason, + } + } +} + +impl From for String { + fn from(failure: WriteFailure) -> String { + failure.reason + } } /// 真实写入:内容不变不重写(write_if_content_changed),父目录按需创建。 pub(crate) struct DiskSink; impl WriteSink for DiskSink { + fn exists(&self, path: &std::path::Path) -> bool { + path.exists() + } + fn read(&self, path: &std::path::Path) -> std::io::Result> { match std::fs::read_to_string(path) { Ok(text) => Ok(Some(text)), diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index cef6253..13b8c69 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -304,26 +304,26 @@ pub(crate) fn write_settings_compat_with( write_json_with(&path, &settings, "settings.json", sink) } -/// 项目侧 MCP 文件(canonical context、Claude 兼容投影、Codex 托管块)一次写齐; -/// inject 与 onboard::plan 共用同一序列,计划与实际写入的结果一致。返回 Codex 托管块结果。 -pub(crate) fn write_project_mcp_files_with( +/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。 +/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份; +/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。 +/// 失败时带出真正出错的文件,供计划的阻断项指向它。 +pub(crate) fn write_project_identity_files_with( root: &std::path::Path, project_id: &str, port: u16, server_url: &str, - header_helper: &str, sink: &dyn crate::WriteSink, -) -> Result { - write_canonical_context_with(root, project_id, sink)?; - write_settings_compat_with(root, server_url, sink)?; - write_liangjing_context_with(root, project_id, port, sink)?; - Ok(upsert_codex_mcp_config_with( - &root.join(".codex").join("config.toml"), - server_url, - header_helper, - sink, - ) - .unwrap_or_else(|reason| ClientOutcome::Failed { reason })) +) -> Result<(), crate::WriteFailure> { + let context = root.join(".lian-jing").join("context.json"); + write_canonical_context_with(root, project_id, sink) + .map_err(crate::WriteFailure::at(&context))?; + let settings = root.join(".claude").join("settings.json"); + write_settings_compat_with(root, server_url, sink) + .map_err(crate::WriteFailure::at(&settings))?; + let liangjing = root.join(".claude").join("liangjing.json"); + write_liangjing_context_with(root, project_id, port, sink) + .map_err(crate::WriteFailure::at(&liangjing)) } /// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) @@ -1490,7 +1490,7 @@ fn upsert_codex_mcp_config( upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink) } -fn upsert_codex_mcp_config_with( +pub(crate) fn upsert_codex_mcp_config_with( path: &std::path::Path, server_url: &str, header_helper: &str, @@ -1624,10 +1624,8 @@ fn inject_project_mcp_locked( ensure_header_shim()?; let header_helper = shim_helper_command(&digest); let root = project_root(project_id)?; - write_canonical_context_with(&root, project_id, &crate::DiskSink)?; - write_settings_compat_with(&root, &server_url, &crate::DiskSink)?; // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 - write_liangjing_context_with(&root, project_id, port, &crate::DiskSink)?; + write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?; let facts = project_facts(project_id, &root); let desired = DesiredMcp { @@ -1701,8 +1699,8 @@ pub fn client_static_status(project_id: &str) -> Vec { // ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ────────────────────────── -/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 -/// git status --no-optional-locks 与 git show)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 +/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status +/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 #[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, @@ -1721,36 +1719,20 @@ pub(crate) fn plan_onboarding( Ok(crate::onboard::plan::plan_onboarding( &root, &project, - plan_machine_steps(project_id, &root), + plan_machine_steps(project_id), + codex_trust(&root), )) } -/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)、Codex 信任。 +/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。 +/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。 #[allow(dead_code)] -fn plan_machine_steps( - project_id: &str, - root: &std::path::Path, -) -> Vec { - use crate::onboard::plan::MachineStep; - let step = |step: &str, status: &str, detail: String| MachineStep { - step: step.into(), - status: status.into(), - detail, - }; +fn plan_machine_steps(project_id: &str) -> Vec { let mut steps = vec![ - if is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) { - step( - "token", - "noop", - "项目 MCP 认证 token 已在本机受保护存储".into(), - ) - } else { - step( - "token", - "apply", - "将在本机受保护存储生成项目 MCP 认证 token".into(), - ) - }, + token_step(crate::credential_store::mcp_auth_token_state( + &crate::credential_store::WindowsCredentialStore, + project_id, + )), shim_step(), ]; for report in client_static_status(project_id) { @@ -1759,24 +1741,41 @@ fn plan_machine_steps( ClientId::Codex => "codex", ClientId::Dsh => "dsh", }; - steps.push(step( - name, - outcome_status(&report.outcome), - report.outcome.describe(), - )); + steps.push(crate::onboard::plan::MachineStep { + step: name.into(), + status: outcome_status(&report.outcome).into(), + detail: report.outcome.describe(), + }); } - steps.push(match codex_trusts(root) { - Some(true) => step("codex_trust", "noop", "Codex 已信任该目录".into()), - Some(false) => step( - "codex_trust", - "needs_human", - "Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)".into(), - ), - None => step("codex_trust", "not_installed", "未发现 Codex 配置".into()), - }); steps } +/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成; +/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。 +/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。 +#[allow(dead_code)] +fn token_step( + state: crate::credential_store::McpAuthTokenState, +) -> crate::onboard::plan::MachineStep { + use crate::credential_store::McpAuthTokenState; + let (status, detail) = match state { + McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()), + McpAuthTokenState::Missing => ( + "apply", + "将在本机受保护存储生成项目 MCP 认证 token".to_string(), + ), + McpAuthTokenState::NeedsRepair(reason) => ( + "needs_human", + format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"), + ), + }; + crate::onboard::plan::MachineStep { + step: "token".into(), + status: status.into(), + detail, + } +} + #[allow(dead_code)] fn shim_step() -> crate::onboard::plan::MachineStep { let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) { @@ -1818,46 +1817,185 @@ fn outcome_status(outcome: &ClientOutcome) -> &'static str { } } -/// Codex 用户配置里是否把该目录(或其祖先目录)标为 trusted;未发现 Codex 配置返回 None。 -/// 只读、只认 `[projects.'<路径>']` 段下的 `trust_level = "trusted"`(P4-0 H-P8-1:未信任目录不加载项目配置)。 -#[allow(dead_code)] -fn codex_trusts(root: &std::path::Path) -> Option { - let home = std::env::var_os("CODEX_HOME") +/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。 +fn codex_home() -> Option { + std::env::var_os("CODEX_HOME") .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")) - })?; - let text = std::fs::read_to_string(home.join("config.toml")).ok()?; - Some(codex_config_trusts(&text, &root.display().to_string())) + .or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex"))) +} + +/// Codex 是否信任项目目录(只读预判,P4-0 H-P8-1:未信任目录不加载项目 .codex/config.toml)。 +/// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任, +/// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。 +/// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。 +#[allow(dead_code)] +fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust { + use crate::onboard::plan::CodexTrust; + let Some(home) = codex_home().filter(|dir| dir.is_dir()) else { + return CodexTrust::NotInstalled; + }; + let config = match std::fs::read_to_string(home.join("config.toml")) { + Ok(text) => text, + // 装了 Codex 但还没有用户配置:没有任何目录受信任 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(_) => return CodexTrust::Unknown, + }; + let mut candidates = vec![root.to_path_buf()]; + candidates.extend(git_main_worktree_root(root)); + if codex_config_trusts(&config, &candidates) { + CodexTrust::Trusted + } else { + CodexTrust::Untrusted + } +} + +/// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根, +/// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。 +#[allow(dead_code)] +fn git_main_worktree_root(root: &std::path::Path) -> Option { + let output = crate::silent_cmd("git") + .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let common = String::from_utf8(output.stdout).ok()?; + PathBuf::from(common.trim()).parent().map(PathBuf::from) } #[allow(dead_code)] -fn codex_config_trusts(config: &str, root: &str) -> bool { - let normalize = |path: &str| { - path.replace('/', "\\") - .trim_end_matches('\\') - .to_lowercase() - }; - let root = normalize(root); - let mut section: Option = None; +fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool { + let trusted = codex_trusted_projects(config); + candidates + .iter() + .any(|candidate| trusted.contains(&normalize_trust_path(&candidate.to_string_lossy()))) +} + +/// Windows 路径比较口径:去 \\?\ 前缀、统一反斜杠、去尾部分隔符、不区分大小写。 +fn normalize_trust_path(path: &str) -> String { + path.strip_prefix(r"\\?\") + .unwrap_or(path) + .replace('/', "\\") + .trim_end_matches('\\') + .to_lowercase() +} + +/// ~/.codex/config.toml 中 trust_level = "trusted" 的项目路径(已归一化)。不引入 TOML 依赖的最小解析: +/// 认 `[projects.'<路径>']`(字面量字符串)与 `[projects."<路径>"]`(基本字符串,按 TOML 规则反转义)段, +/// 以及 `[projects]` 段内的内联表 `'<路径>' = { trust_level = "trusted" }` 与点号键写法。 +/// 键里有非法转义(TOML 本身不合法,Codex 也加载不了)时忽略该项。 +fn codex_trusted_projects(config: &str) -> Vec { + // None:不在 projects 段;Some(None):[projects];Some(Some(key)):[projects.] + let mut section: Option> = None; + let mut trusted = Vec::new(); for line in config.lines().map(str::trim) { if line.starts_with('[') { - section = line - .strip_prefix("[projects.") - .and_then(|rest| rest.strip_suffix(']')) - .map(|key| normalize(key.trim_matches(['\'', '"']))); + section = parse_projects_header(line); continue; } - let Some(key) = §ion else { continue }; - let covers = &root == key || root.starts_with(&format!("{key}\\")); - let trusted = line.split_once('=').is_some_and(|(name, value)| { - name.trim() == "trust_level" && value.trim().trim_matches('"') == "trusted" - }); - if covers && trusted { - return true; + match §ion { + Some(Some(key)) if is_trusted_assignment(line) => { + trusted.push(normalize_trust_path(key)); + } + Some(None) => { + let Some((key, rest)) = parse_toml_key(line) else { + continue; + }; + let rest = rest.trim_start(); + let here = if let Some(dotted) = rest.strip_prefix('.') { + is_trusted_assignment(dotted.trim_start()) + } else if let Some(value) = rest.strip_prefix('=') { + value + .trim() + .strip_prefix('{') + .and_then(|inner| inner.split('}').next()) + .is_some_and(|inner| { + inner + .split(',') + .any(|pair| is_trusted_assignment(pair.trim())) + }) + } else { + false + }; + if here { + trusted.push(normalize_trust_path(&key)); + } + } + _ => {} } } - false + trusted +} + +/// `[projects]` → Some(None);`[projects.<键>]` → Some(Some(键));其他段(含数组表)→ None。 +fn parse_projects_header(line: &str) -> Option> { + let inner = line.strip_prefix('[')?.trim_start(); + if inner.starts_with('[') { + return None; + } + let rest = inner.strip_prefix("projects")?.trim_start(); + if rest.starts_with(']') { + return Some(None); + } + let (key, after) = parse_toml_key(rest.strip_prefix('.')?.trim_start())?; + after.trim_start().starts_with(']').then_some(Some(key)) +} + +/// `trust_level = "trusted"`(允许单引号与行尾注释)。 +fn is_trusted_assignment(text: &str) -> bool { + text.split_once('=').is_some_and(|(name, value)| { + let value = value.trim_start(); + name.trim() == "trust_level" + && (value.starts_with("\"trusted\"") || value.starts_with("'trusted'")) + }) +} + +/// 解析行首的一个 TOML 键(字面量字符串、基本字符串或裸键),返回(键,剩余文本)。 +/// 基本字符串按 TOML 规则反转义;非法转义或未闭合返回 None。 +fn parse_toml_key(text: &str) -> Option<(String, &str)> { + if let Some(rest) = text.strip_prefix('\'') { + let end = rest.find('\'')?; + return Some((rest[..end].to_string(), &rest[end + 1..])); + } + if let Some(rest) = text.strip_prefix('"') { + let mut key = String::new(); + let mut chars = rest.char_indices(); + while let Some((index, ch)) = chars.next() { + match ch { + '"' => return Some((key, &rest[index + 1..])), + '\\' => { + let (_, escaped) = chars.next()?; + let decoded = match escaped { + '\\' => '\\', + '"' => '"', + 'b' => '\u{8}', + 't' => '\t', + 'n' => '\n', + 'f' => '\u{c}', + 'r' => '\r', + 'e' => '\u{1b}', + 'u' | 'U' => { + let len = if escaped == 'u' { 4 } else { 8 }; + let hex = (0..len) + .map(|_| chars.next().map(|(_, c)| c)) + .collect::>()?; + char::from_u32(u32::from_str_radix(&hex, 16).ok()?)? + } + _ => return None, + }; + key.push(decoded); + } + _ => key.push(ch), + } + } + return None; + } + let end = text + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) + .unwrap_or(text.len()); + (end > 0).then(|| (text[..end].to_string(), &text[end..])) } // ── 三端客户端适配器(注册表见 onboard::clients::registry)────────────────────── @@ -1960,12 +2098,7 @@ impl ClientAdapter for CodexAdapter { /// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。 fn detect(&self) -> bool { - std::env::var_os("CODEX_HOME") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")) - }) - .is_some_and(|dir| dir.is_dir()) + codex_home().is_some_and(|dir| dir.is_dir()) } fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { @@ -2965,27 +3098,135 @@ mod tests { )); } + /// Codex 信任表解析:字面量键、按 TOML 规则转义的基本字符串键、[projects] 段内的内联表与点号键; + /// 只认精确目录(及调用方给出的 git 主仓库根候选),不逐级查祖先目录。 #[test] - fn codex_trust_reads_projects_table_including_ancestors() { - let config = "model = \"x\"\n\n[projects.'e:\\code-project\\enterprise-system']\ntrust_level = \"trusted\"\n\n[projects.\"D:\\Work\"]\ntrust_level = \"trusted\"\n\n[projects.'e:\\other']\ntrust_level = \"untrusted\"\n"; - assert!(codex_config_trusts( - config, - "E:\\code-project\\enterprise-system" - )); - assert!(codex_config_trusts( - config, - "E:/code-project/enterprise-system/" - )); + fn codex_trust_matches_exact_dirs_with_real_toml_keys() { + let config = r#"model = "x" + +[projects.'e:\code-project\enterprise-system'] +trust_level = "trusted" + +[projects."D:\\Work"] +trust_level = "trusted" # 基本字符串里的反斜杠必须转义 + +[projects."D:\Bad"] +trust_level = "trusted" + +[projects.'e:\other'] +trust_level = "untrusted" + +[projects] +'f:\inline' = { trust_level = "trusted" } +"f:\\dotted".trust_level = 'trusted' +'f:\inline-untrusted' = { trust_level = "untrusted" } + +[[profiles]] +trust_level = "trusted" +"#; + let trusts = |paths: &[&str]| { + codex_config_trusts(config, &paths.iter().map(PathBuf::from).collect::>()) + }; + assert!(trusts(&[r"E:\code-project\enterprise-system"])); + assert!(trusts(&["E:/code-project/enterprise-system/"])); + assert!(trusts(&[r"\\?\E:\code-project\enterprise-system"])); + assert!(trusts(&[r"D:\Work"]), "转义的基本字符串键按反转义后匹配"); assert!( - codex_config_trusts(config, "d:\\work\\sub\\proj"), - "祖先目录受信任" + !trusts(&[r"D:\Work\sub\proj"]), + "受信任的祖先目录不覆盖子目录(只认精确目录与 git 主仓库根)" ); - assert!(!codex_config_trusts(config, "E:\\other"), "untrusted 不算"); + assert!(!trusts(&[r"D:\Bad"]), "非法转义的键忽略"); + assert!(!trusts(&[r"E:\other"]), "untrusted 不算"); assert!( - !codex_config_trusts(config, "E:\\code-project\\enterprise-system-wt"), - "前缀不是路径边界" + !trusts(&[r"E:\code-project\enterprise-system-wt"]), + "前缀不是同一目录" + ); + assert!(trusts(&[r"F:\inline"]), "[projects] 内联表"); + assert!(trusts(&[r"F:\dotted"]), "[projects] 点号键"); + assert!(!trusts(&[r"F:\inline-untrusted"])); + assert!(!trusts(&[r"C:\new"])); + assert!( + trusts(&[r"C:\wt", r"E:\code-project\enterprise-system"]), + "任一候选(git 主仓库根)受信任即可" + ); + assert!(!codex_config_trusts( + "", + &[PathBuf::from(r"E:\code-project\enterprise-system")] + )); + } + + /// git 主仓库根:普通仓库为仓库根,worktree 为主仓库根(继承信任),仓库子目录为所在仓库根。 + #[test] + fn git_main_worktree_root_resolves_worktree_and_subdir() { + let base = std::env::temp_dir().join(format!("lj_p8c_gitroot_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let main = base.join("main"); + std::fs::create_dir_all(main.join("sub")).unwrap(); + let git = |dir: &std::path::Path, args: &[&str]| { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git 可执行"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&main, &["init", "-q"]); + std::fs::write(main.join("sub").join("a.txt"), "a\n").unwrap(); + git(&main, &["add", "-A"]); + git(&main, &["commit", "-q", "--no-verify", "-m", "init"]); + git(&main, &["worktree", "add", "-q", "../wt", "-b", "wt"]); + + let canonical = |path: PathBuf| std::fs::canonicalize(path).unwrap(); + let expected = canonical(main.clone()); + assert_eq!(canonical(git_main_worktree_root(&main).unwrap()), expected); + assert_eq!( + canonical(git_main_worktree_root(&base.join("wt")).unwrap()), + expected, + "worktree 取主仓库根" + ); + assert_eq!( + canonical(git_main_worktree_root(&main.join("sub")).unwrap()), + expected, + "仓库子目录取所在仓库根" + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn token_step_tells_missing_from_needs_repair() { + use crate::credential_store::McpAuthTokenState; + use crate::onboard::plan::{machine_step_blocker, BlockReason}; + let present = token_step(McpAuthTokenState::Present); + assert_eq!( + (present.step.as_str(), present.status.as_str()), + ("token", "noop") + ); + assert!(machine_step_blocker(&present).is_none()); + let missing = token_step(McpAuthTokenState::Missing); + assert_eq!(missing.status, "apply", "只有未配置才会生成"); + assert!(machine_step_blocker(&missing).is_none()); + let repair = token_step(McpAuthTokenState::NeedsRepair( + "MCP 认证材料 needs_repair".into(), + )); + assert_eq!(repair.status, "needs_human"); + assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成")); + let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认"); + assert_eq!( + (blocked.target.as_str(), blocked.reason), + ("token", BlockReason::NeedsHuman) ); - assert!(!codex_config_trusts(config, "C:\\new")); } #[test] diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs index 79f6608..e5148d0 100644 --- a/src-tauri/src/onboard/plan.rs +++ b/src-tauri/src/onboard/plan.rs @@ -4,6 +4,11 @@ //! 为什么:一键接入改写的是子项目受版本管理的文件,人在确认前必须看得到改什么; //! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。 //! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。 +//! +//! digest 的准确语义:绑定计划**会写入或核对过**的每个文件的(路径、动作、写入后内容哈希,update 另加 +//! 写入前内容哈希)、本机步骤与状态、阻断项。计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、 +//! mise.toml)、语义相同的 JSON、托管块版本相同的文件——不列出、不进 digest:它们怎么变,apply 都不写。 +//! Codex 信任与 next_actions 不影响写入内容,也不进 digest。 use crate::WriteSink; use serde::{Deserialize, Serialize}; @@ -11,6 +16,9 @@ use std::cell::RefCell; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +/// 项目 Codex 配置(文件级托管块与本机 codex 步的只读判定都来自它)。 +const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml"; + /// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。 #[derive(Default)] pub(crate) struct PlanSink { @@ -19,6 +27,10 @@ pub(crate) struct PlanSink { } impl WriteSink for PlanSink { + fn exists(&self, path: &Path) -> bool { + self.pending.borrow().contains_key(path) || path.exists() + } + fn read(&self, path: &Path) -> std::io::Result> { if let Some(content) = self.pending.borrow().get(path) { return Ok(Some(content.clone())); @@ -79,9 +91,13 @@ pub struct PlannedFile { pub warning: Option, /// 计划写入内容的 sha256(noop 为当前内容的 sha256),进入 digest pub content_sha256: String, + /// update 时写入前(当前磁盘)内容的 sha256,进入 digest:人确认之后、apply 之前文件又被改过, + /// 即使写入后内容不变也会因 digest 不符被拒(否则会覆盖掉人没在 diff 里见过的改动)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_sha256: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BlockReason { /// 托管块文件块外有未提交改动,本次又要改写托管块 @@ -103,8 +119,9 @@ pub struct BlockedItem { pub detail: String, } -/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置、Codex 信任等。 -/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | needs_human | unavailable。 +/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。 +/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable +/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MachineStep { pub step: String, @@ -112,13 +129,34 @@ pub struct MachineStep { pub detail: String, } +/// Codex 是否信任项目目录(设计 §4 第 1 步单列、决策 Q5)。只决定 Codex 是否加载项目 .codex/config.toml, +/// 不影响炼境写入什么:不阻断、不进 digest;未信任或无法判定时列入 next_actions,由人在 Codex 里信任。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CodexTrust { + Trusted, + Untrusted, + NotInstalled, + /// Codex 用户配置读不出 + Unknown, +} + +/// 机器只列出、由人在别处完成的事项(设计 §4「人确认 C4」);不阻断、不进 digest。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NextAction { + pub action: String, + pub detail: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OnboardingPlan { pub project_id: String, pub files: Vec, pub machine_changes: Vec, pub blocked: Vec, - /// 覆盖项目文件新内容哈希、本机动作与阻断项;apply 须带回同一 digest + pub codex_trust: CodexTrust, + pub next_actions: Vec, + /// 语义见模块文档;apply 须带回同一 digest pub digest: String, } @@ -130,13 +168,19 @@ pub struct ProjectWrite<'a> { pub header_helper: &'a str, } -/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与 MCP 投影),plan 与 apply 共用。 -/// 返回分发过程中的失败与需人工项(不中断其余写入)。 +/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块), +/// plan 与 apply 共用。返回分发过程中的失败与需人工项(不中断其余写入),target 为真正出错的文件。 +/// 身份与兼容投影、Codex 托管块与 inject_project_mcp 调的是同一组函数(写入序列只有一份)。 pub(crate) fn write_project_files( root: &Path, project: &ProjectWrite, sink: &dyn WriteSink, ) -> Vec { + let needs_human = |failure: crate::WriteFailure| BlockedItem { + target: relative_path(root, &failure.path), + reason: BlockReason::NeedsHuman, + detail: failure.reason, + }; let mut issues = Vec::new(); for result in crate::commands::onboarding::dispatch_templates_with(root, sink) { if result.action == "failed" { @@ -147,29 +191,32 @@ pub(crate) fn write_project_files( }); } } - if let Err(error) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) { - issues.push(BlockedItem { - target: ".blueprint/CONVENTIONS.md".into(), - reason: BlockReason::NeedsHuman, - detail: error, - }); + if let Err(failure) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) { + issues.push(needs_human(failure)); } - use super::clients::ClientOutcome; - match crate::mcp_inject::write_project_mcp_files_with( + if let Err(failure) = crate::mcp_inject::write_project_identity_files_with( root, project.project_id, project.port, project.server_url, + sink, + ) { + issues.push(needs_human(failure)); + } + use super::clients::ClientOutcome; + match crate::mcp_inject::upsert_codex_mcp_config_with( + &root.join(CODEX_PROJECT_CONFIG), + project.server_url, project.header_helper, sink, ) { Ok(ClientOutcome::ManualKept) => issues.push(BlockedItem { - target: ".codex/config.toml".into(), + target: CODEX_PROJECT_CONFIG.into(), reason: BlockReason::ManualEntry, detail: "已手工定义 [mcp_servers.lian-jing],炼境不会覆盖".into(), }), - Ok(ClientOutcome::Failed { reason }) | Err(reason) => issues.push(BlockedItem { - target: ".lian-jing/context.json 等项目 MCP 文件".into(), + Err(reason) => issues.push(BlockedItem { + target: CODEX_PROJECT_CONFIG.into(), reason: BlockReason::NeedsHuman, detail: reason, }), @@ -178,12 +225,47 @@ pub(crate) fn write_project_files( issues } -/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作,生成计划与 digest。 +/// 本机步骤 → 阻断项(纯函数):手写同名条目、共享项目键与需人工处理的状态阻断确认; +/// noop / apply / not_installed / not_applicable / unavailable 只列出。 +/// codex 步的只读判定完全来自项目 .codex/config.toml,阻断项指向该文件,与文件级同因阻断项合并。 +pub(crate) fn machine_step_blocker(step: &MachineStep) -> Option { + let reason = match step.status.as_str() { + "manual_kept" => BlockReason::ManualEntry, + "other_project" => BlockReason::SharedKey, + "needs_human" => BlockReason::NeedsHuman, + _ => return None, + }; + Some(BlockedItem { + target: if step.step == "codex" { + CODEX_PROJECT_CONFIG.into() + } else { + step.step.clone() + }, + reason, + detail: step.detail.clone(), + }) +} + +/// Codex 信任 → next_actions(纯函数):未信任或无法判定时提示人在 Codex 里信任该目录。 +fn codex_trust_next_action(trust: CodexTrust) -> Option { + let detail = match trust { + CodexTrust::Untrusted => "Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)", + CodexTrust::Unknown => "读不出 Codex 用户配置,无法判断该目录是否受信任:请在 Codex 里确认已信任该目录(未信任时不加载项目 .codex/config.toml)", + CodexTrust::Trusted | CodexTrust::NotInstalled => return None, + }; + Some(NextAction { + action: "codex_trust".into(), + detail: detail.into(), + }) +} + +/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作与 Codex 信任,生成计划与 digest。 /// 不写任何文件(目录快照前后字节一致)。 pub(crate) fn plan_onboarding( root: &Path, project: &ProjectWrite, machine_changes: Vec, + codex_trust: CodexTrust, ) -> OnboardingPlan { let sink = PlanSink::default(); let mut blocked = identity_conflict(root, project.project_id) @@ -196,18 +278,25 @@ pub(crate) fn plan_onboarding( for path in sink.touched.borrow().iter() { let relative = relative_path(root, path); let disk = read_disk(path).ok().flatten(); - let (action, content, diff) = match (pending.get(path), &disk) { + let (action, content, diff, before_sha256) = match (pending.get(path), &disk) { (Some(new), None) => ( FileAction::Create, new.clone(), Some(format!("新建,{} 行", new.lines().count())), + None, ), (Some(new), Some(old)) => ( FileAction::Update, new.clone(), Some(bounded_diff(old, new, DIFF_LINE_LIMIT)), + Some(sha256_hex(&old.replace("\r\n", "\n"))), + ), + (None, current) => ( + FileAction::Noop, + current.clone().unwrap_or_default(), + None, + None, ), - (None, current) => (FileAction::Noop, current.clone().unwrap_or_default(), None), }; let mut warning = None; if action == FileAction::Update @@ -232,28 +321,21 @@ pub(crate) fn plan_onboarding( diff, warning, content_sha256: sha256_hex(&content.replace("\r\n", "\n")), + before_sha256, }); } - for step in &machine_changes { - if let Some(reason) = match step.status.as_str() { - "manual_kept" => Some(BlockReason::ManualEntry), - "other_project" => Some(BlockReason::SharedKey), - "needs_human" => Some(BlockReason::NeedsHuman), - _ => None, - } { - blocked.push(BlockedItem { - target: step.step.clone(), - reason, - detail: step.detail.clone(), - }); - } - } + blocked.extend(machine_changes.iter().filter_map(machine_step_blocker)); + // 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」 + let mut seen = std::collections::HashSet::new(); + blocked.retain(|item| seen.insert((item.target.clone(), item.reason))); let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked); OnboardingPlan { project_id: project.project_id.to_string(), files, machine_changes, blocked, + codex_trust, + next_actions: codex_trust_next_action(codex_trust).into_iter().collect(), digest, } } @@ -288,7 +370,8 @@ fn sha256_hex(text: &str) -> String { .collect() } -/// digest 覆盖项目 ID、逐文件(路径、动作、内容哈希)、本机动作与阻断项。 +/// digest 覆盖项目 ID、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作与阻断项; +/// Codex 信任与 next_actions 不影响写入内容,不进 digest(用户去 Codex 信任目录后不必重新确认)。 fn plan_digest( project_id: &str, files: &[PlannedFile], @@ -297,7 +380,7 @@ fn plan_digest( ) -> String { let canonical = serde_json::json!({ "project_id": project_id, - "files": files.iter().map(|f| (f.path.as_str(), f.action, f.content_sha256.as_str())).collect::>(), + "files": files.iter().map(|f| (f.path.as_str(), f.action, f.before_sha256.as_deref(), f.content_sha256.as_str())).collect::>(), "machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::>(), "blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::>(), }); @@ -399,6 +482,18 @@ mod tests { }] } + /// 本机只有 claude 一步、Codex 已信任时的计划。 + fn plan_for(root: &Path, status: &str) -> OnboardingPlan { + plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted) + } + + fn blocked_pairs(plan: &OnboardingPlan) -> Vec<(String, BlockReason)> { + plan.blocked + .iter() + .map(|item| (item.target.clone(), item.reason)) + .collect() + } + fn temp_repo(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("lj_p8c_{name}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); @@ -414,7 +509,7 @@ mod tests { fn plan_on_empty_repo_creates_everything_and_writes_nothing() { let root = temp_repo("empty"); let before = snapshot(&root); - let plan = plan_onboarding(&root, &PROJECT, machine("apply")); + let plan = plan_for(&root, "apply"); assert_eq!(snapshot(&root), before, "plan 不得写任何文件"); assert!(plan.files.len() >= 12, "实际 {} 个文件", plan.files.len()); assert!( @@ -448,7 +543,7 @@ mod tests { #[test] fn plan_predicts_exactly_what_apply_writes() { let root = temp_repo("predict"); - let plan = plan_onboarding(&root, &PROJECT, machine("apply")); + let plan = plan_for(&root, "apply"); assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty()); for file in &plan.files { let written = std::fs::read_to_string(root.join(&file.path)).unwrap(); @@ -483,8 +578,8 @@ mod tests { git(&root, &["add", "-A"]); git(&root, &["commit", "-q", "--no-verify", "-m", "onboard"]); - let first = plan_onboarding(&root, &PROJECT, machine("noop")); - let second = plan_onboarding(&root, &PROJECT, machine("noop")); + let first = plan_for(&root, "noop"); + let second = plan_for(&root, "noop"); assert!( first.files.iter().all(|f| f.action == FileAction::Noop), "{:?}", @@ -493,10 +588,7 @@ mod tests { assert_eq!(first.digest, second.digest, "同一状态两次计划 digest 相同"); // 本机预计动作变化 → digest 变化 - assert_ne!( - plan_onboarding(&root, &PROJECT, machine("apply")).digest, - first.digest - ); + assert_ne!(plan_for(&root, "apply").digest, first.digest); // CRLF 检出的同内容文件仍是 noop let packs = root.join(".lian-jing/capability-packs.json"); @@ -505,7 +597,7 @@ mod tests { .unwrap() .replace("\r\n", "\n"); std::fs::write(&packs, lf.replace('\n', "\r\n")).unwrap(); - let crlf = plan_onboarding(&root, &PROJECT, machine("noop")); + let crlf = plan_for(&root, "noop"); let not_noop: Vec<_> = crlf .files .iter() @@ -514,17 +606,200 @@ mod tests { .collect(); assert!(not_noop.is_empty(), "{not_noop:?}"); - // 任一目标文件内容变化 → 该文件 update、digest 变化 + // 计划会写入或核对的文件内容变化 → 该文件 update、digest 变化 std::fs::write(&packs, "{}\n").unwrap(); - let changed = plan_onboarding(&root, &PROJECT, machine("noop")); - let entry = changed - .files - .iter() - .find(|f| f.path == ".lian-jing/capability-packs.json") - .unwrap(); + let changed = plan_for(&root, "noop"); + let find_packs = |plan: &OnboardingPlan| { + plan.files + .iter() + .find(|f| f.path == ".lian-jing/capability-packs.json") + .cloned() + .unwrap() + }; + let entry = find_packs(&changed); assert_eq!(entry.action, FileAction::Update); assert!(entry.diff.as_deref().unwrap().contains("-{}")); + assert_eq!( + entry.before_sha256.as_deref(), + Some(sha256_hex("{}\n").as_str()) + ); assert_ne!(changed.digest, first.digest); + + // 写入前内容进 digest:确认后文件又被改、写入后内容不变 → digest 仍变(apply 会拒绝) + std::fs::write(&packs, "{\"edited\":true}\n").unwrap(); + let edited = plan_for(&root, "noop"); + let edited_entry = find_packs(&edited); + assert_eq!(edited_entry.action, FileAction::Update); + assert_eq!( + edited_entry.content_sha256, entry.content_sha256, + "写入后内容相同" + ); + assert_ne!( + edited.digest, changed.digest, + "写入前内容不同 → digest 不同" + ); + + // 计划不会触碰的目标(已存在的只建不改文件)不列出、不进 digest:怎么改 apply 都不写 + std::fs::write(root.join("lefthook.yml"), "# 项目自己的 hook\n").unwrap(); + let untouched = plan_for(&root, "noop"); + assert!(untouched.files.iter().all(|f| f.path != "lefthook.yml")); + assert_eq!(untouched.digest, edited.digest); + let _ = std::fs::remove_dir_all(&root); + } + + /// Codex 未信任是 next_action,不是阻断项(设计 §4 blocked 只有四类,Q5 / C4): + /// 新项目几乎都未信任,若阻断则一键接入对每个新项目都走不通;信任与否也不改变 digest。 + #[test] + fn codex_trust_is_next_action_not_blocker_and_outside_digest() { + let root = temp_repo("trust"); + let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted); + let untrusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Untrusted); + assert!(untrusted.blocked.is_empty(), "{:?}", untrusted.blocked); + assert_eq!(untrusted.codex_trust, CodexTrust::Untrusted); + assert_eq!( + untrusted + .next_actions + .iter() + .map(|a| a.action.as_str()) + .collect::>(), + ["codex_trust"] + ); + assert!(trusted.next_actions.is_empty()); + assert_eq!( + trusted.digest, untrusted.digest, + "用户去 Codex 信任目录后无需重新确认" + ); + let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown); + assert!(unknown.blocked.is_empty() && unknown.next_actions.len() == 1); + let absent = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::NotInstalled); + assert!(absent.next_actions.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn machine_step_blocker_maps_statuses() { + let step = |name: &str, status: &str| MachineStep { + step: name.into(), + status: status.into(), + detail: format!("{name}:{status}"), + }; + for status in [ + "noop", + "apply", + "not_installed", + "not_applicable", + "unavailable", + ] { + assert!( + machine_step_blocker(&step("claude", status)).is_none(), + "{status}" + ); + } + let pair = |name: &str, status: &str| { + machine_step_blocker(&step(name, status)).map(|b| (b.target, b.reason)) + }; + assert_eq!( + pair("claude", "manual_kept"), + Some(("claude".into(), BlockReason::ManualEntry)) + ); + assert_eq!( + pair("claude", "other_project"), + Some(("claude".into(), BlockReason::SharedKey)) + ); + assert_eq!( + pair("token", "needs_human"), + Some(("token".into(), BlockReason::NeedsHuman)) + ); + assert_eq!( + pair("codex", "manual_kept"), + Some((CODEX_PROJECT_CONFIG.into(), BlockReason::ManualEntry)), + "codex 步的判定来自项目 .codex/config.toml,阻断项指向该文件" + ); + } + + /// 回归(P8c 审查):「存在即跳过」只判存在。项目自有的非 UTF-8 文件(PowerShell 5 重定向生成的 + /// UTF-16LE lefthook.yml)照旧 skipped,不变成 failed、不阻断计划;全托管文件读不出则 failed(不盲目覆盖)。 + #[test] + fn existing_non_utf8_files_skip_or_fail_by_ownership() { + let root = temp_repo("utf16"); + let utf16: Vec = [0xFF, 0xFE] + .into_iter() + .chain( + "pre-commit:\r\n commands: {}\r\n" + .encode_utf16() + .flat_map(u16::to_le_bytes), + ) + .collect(); + std::fs::write(root.join("lefthook.yml"), &utf16).unwrap(); + let results = + crate::commands::onboarding::dispatch_templates_with(&root, &PlanSink::default()); + let lefthook = results.iter().find(|r| r.name == "lefthook.yml").unwrap(); + assert_eq!(lefthook.action, "skipped", "{}", lefthook.detail); + let plan = plan_for(&root, "apply"); + assert!(plan.blocked.is_empty(), "{:?}", plan.blocked); + assert!(plan.files.iter().all(|f| f.path != "lefthook.yml")); + + let envoy = root.join(".claude/commands/envoy.md"); + std::fs::create_dir_all(envoy.parent().unwrap()).unwrap(); + std::fs::write(&envoy, &utf16).unwrap(); + let plan = plan_for(&root, "apply"); + assert_eq!( + blocked_pairs(&plan), + [( + ".claude/commands/envoy.md".to_string(), + BlockReason::NeedsHuman + )] + ); + + // 真实写入同样:lefthook.yml 原样保留,envoy.md 不被覆盖 + let results = crate::commands::onboarding::dispatch_templates_with(&root, &crate::DiskSink); + let action = |name: &str| { + results + .iter() + .find(|r| r.name == name) + .map(|r| r.action.clone()) + .unwrap() + }; + assert_eq!(action("lefthook.yml"), "skipped"); + assert_eq!(action(".claude/commands/envoy.md"), "failed"); + assert_eq!(std::fs::read(root.join("lefthook.yml")).unwrap(), utf16); + assert_eq!(std::fs::read(&envoy).unwrap(), utf16); + let _ = std::fs::remove_dir_all(&root); + } + + /// 阻断项指向真正出错的文件,同一文件同一原因只留一条(P8c 审查)。 + #[test] + fn blocked_targets_point_at_failing_files_without_duplicates() { + let root = temp_repo("targets"); + // AGENTS.md 读不出:模板分发与蓝图规则同步各报一次,合并为一条 + std::fs::write(root.join("AGENTS.md"), [0xFF, 0xFE, 0x00, 0xD8]).unwrap(); + // settings.json 不是合法 JSON:身份与兼容投影在此失败 + std::fs::create_dir_all(root.join(".claude")).unwrap(); + std::fs::write(root.join(".claude/settings.json"), "{ not json").unwrap(); + // Codex 托管块标记错序:文件级与本机 codex 步各报一次,合并为一条 + std::fs::create_dir_all(root.join(".codex")).unwrap(); + std::fs::write( + root.join(".codex/config.toml"), + "# LIANJING_MCP_MANAGED_END\n# LIANJING_MCP_MANAGED_START\n", + ) + .unwrap(); + let mut steps = machine("noop"); + steps.push(MachineStep { + step: "codex".into(), + status: "needs_human".into(), + detail: "炼境 Codex 管理块不完整或顺序错误".into(), + }); + let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted); + let mut pairs = blocked_pairs(&plan); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + pairs, + [ + (".claude/settings.json".to_string(), BlockReason::NeedsHuman), + (CODEX_PROJECT_CONFIG.to_string(), BlockReason::NeedsHuman), + ("AGENTS.md".to_string(), BlockReason::NeedsHuman), + ] + ); let _ = std::fs::remove_dir_all(&root); } @@ -551,7 +826,7 @@ mod tests { ) .unwrap(); - let plan = plan_onboarding(&root, &PROJECT, machine("noop")); + let plan = plan_for(&root, "noop"); let agents_entry = plan.files.iter().find(|f| f.path == "AGENTS.md").unwrap(); assert_eq!(agents_entry.action, FileAction::Update); assert!(agents_entry.warning.is_some(), "混合文件须带预警"); @@ -574,11 +849,22 @@ mod tests { "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n", ) .unwrap(); - let plan = plan_onboarding(&root, &PROJECT, machine("other_project")); - assert!(plan - .blocked - .iter() - .any(|b| b.reason == BlockReason::ManualEntry && b.target == ".codex/config.toml")); + let mut steps = machine("other_project"); + // 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条 + steps.push(MachineStep { + step: "codex".into(), + status: "manual_kept".into(), + detail: "手写条目".into(), + }); + let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted); + assert_eq!( + plan.blocked + .iter() + .filter(|b| b.target == ".codex/config.toml") + .map(|b| b.reason) + .collect::>(), + [BlockReason::ManualEntry] + ); assert!(plan .blocked .iter() -- 2.45.2 From 7ef924127af4711d0a8394cc8c7ec62f176679b3 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 10:54:04 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P8c?= =?UTF-8?q?=20=E8=A1=A5=E5=AE=A1=E6=9F=A5=E5=A4=84=E7=BD=AE=E4=B8=8E=20kno?= =?UTF-8?q?wn=5Flimits=EF=BC=8CP8d=20=E8=AE=B0=E5=AE=A1=E6=9F=A5=E8=BD=AC?= =?UTF-8?q?=E5=85=A5=E9=A1=B9=EF=BC=88P8c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - review:独立审查 9 条发现逐条处置(7 条已修并补测试,2 条信息项转入 known_limits / P8d)。 - 自查条目「任一目标文件变化 digest 就变 ✅」改为准确语义(只绑定计划会写入或核对的文件, update 另绑定写入前哈希);按纪律报备:改的是验收表述,断言只增不减。 - known_limits:onboard 反向依赖投影层(过渡态)、Codex 信任静态预判的边界、shim unavailable 不阻断、 bypassPermissions 未单列、plan_machine_steps 组装未单测。 - P8d 卡新增「审查转入」:bypass_permissions 单列、machine_changes 补产品组与 buff、.git/hooks,待统筹决定是否并入 acceptance。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 6bc9a39..1624286 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -252,17 +252,19 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ### 🔵 P8c — 写入前计划:WriteSink + plan_onboarding - status: in_progress - complexity: M -- files: src-tauri/src/onboard/plan.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/mcp_inject.rs +- files: src-tauri/src/onboard/plan.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/credential_store.rs(审查修复新增) - depends: P8b - acceptance: "agent_verify: [cargo test onboard::plan] 临时 git 仓库:空仓库做 plan 全部是 create、目录快照前后字节一致;已接入仓库连续两次 plan 全部 noop、digest 相同,任一目标文件变化 digest 就变;块外有未提交改动时该文件带预警且 plan.blocked 非空;内容相同仅行尾为 CRLF 判 noop;超长 diff 截断并标注;context.json 是别的 project_id 时记 blocked(identity_conflict);machine_changes 列出各本机步骤的预计动作,计划阶段假 claude CLI 只收到 mcp get。[cargo test] 原有 onboarding、blueprint、mcp_inject 测试全部通过、行为不变。" - 说明:WriteSink 以 write_if_content_changed 作为真实写入汇;P10 的混合文件检测前移到写入之前;与 CI 的 plan/apply 同一模式(Q2)。 -- 实现(2026-10-03,分支 feat/agent-client-parity-p8c,提交 ef2c7fd):lib.rs 新增 WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink(真实写入,父目录按需创建)。接入包模板分发(dispatch_templates_with)、蓝图规则同步(sync_blueprint_rules_with)、项目身份与 MCP 投影(write_project_mcp_files_with:context.json、settings.json 兼容条目、liangjing.json、Codex 托管块)全部改经写入汇,原入口改为 DiskSink 薄包装。新增 onboard/plan.rs:PlanSink 只记录不落盘,后续步骤读到前面步骤的待写内容(AGENTS.md 先后被模板分发与蓝图规则改写,计划与实际写入算出同一结果);write_project_files 为 plan 与 apply(P8d)共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff(60 行)、混合文件预警(P10 前移到写入前,记 blocked(mixed_file))、身份冲突、手写 Codex 条目与本机 manual_kept/other_project/needs_human 阻断项,并对(项目 ID、逐文件路径/动作/内容哈希、本机步骤/状态、阻断项)算 sha256 digest。mcp_inject::plan_onboarding 为生产入口:本机侧预计动作 token(读受保护存储判断有无)、shim(比对稳定入口内容)、claude/codex/dsh(复用 P8b 的只读 client_static_status)、codex_trust(只读解析 ~/.codex/config.toml 的 [projects.'<路径>'] trust_level,含祖先目录)。2026-10-03 合并 master(PR #34 重复登记修复 U1/U2,同改 mcp_inject.rs、commands/onboarding.rs、mcp_tools.rs)自动合并无冲突。收尾补测试 plan_predicts_exactly_what_apply_writes(计划的文件集合与内容哈希 = 随后 DiskSink 实际写入,git status 文件集合一致),修正 mcp_inject::plan_onboarding 注释「不起子进程」为「不起客户端 CLI,混合文件预警会起只读 git」。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8c,提交 ef2c7fd):lib.rs 新增 WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink(真实写入,父目录按需创建)。接入包模板分发(dispatch_templates_with)、蓝图规则同步(sync_blueprint_rules_with)、项目身份与 MCP 投影(write_project_mcp_files_with:context.json、settings.json 兼容条目、liangjing.json、Codex 托管块)全部改经写入汇,原入口改为 DiskSink 薄包装。新增 onboard/plan.rs:PlanSink 只记录不落盘,后续步骤读到前面步骤的待写内容(AGENTS.md 先后被模板分发与蓝图规则改写,计划与实际写入算出同一结果);write_project_files 为 plan 与 apply(P8d)共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff(60 行)、混合文件预警(P10 前移到写入前,记 blocked(mixed_file))、身份冲突、手写 Codex 条目与本机 manual_kept/other_project/needs_human 阻断项,并对(项目 ID、逐文件路径/动作/内容哈希、本机步骤/状态、阻断项)算 sha256 digest。mcp_inject::plan_onboarding 为生产入口:本机侧预计动作 token(读受保护存储判断有无)、shim(比对稳定入口内容)、claude/codex/dsh(复用 P8b 的只读 client_static_status)、codex_trust(只读解析 ~/.codex/config.toml 的 [projects.'<路径>'] trust_level,含祖先目录;审查后改为精确目录 + git 主仓库根、移出本机步骤单列,见 review)。2026-10-03 合并 master(PR #34 重复登记修复 U1/U2,同改 mcp_inject.rs、commands/onboarding.rs、mcp_tools.rs)自动合并无冲突。收尾补测试 plan_predicts_exactly_what_apply_writes(计划的文件集合与内容哈希 = 随后 DiskSink 实际写入,git status 文件集合一致),修正 mcp_inject::plan_onboarding 注释「不起子进程」为「不起客户端 CLI,混合文件预警会起只读 git」。 - verification: "2026-10-03:合并 master 后全量 cargo test 310 passed / 0 failed / 5 ignored(ef2c7fd 时 307,PR #34 带入 3 条);补测试后 311 passed / 0 failed / 5 ignored,编译 0 warning。onboard::plan 7 条:空仓库全 create 且目录快照(含 .git)字节不变;apply 后连续两次计划全 noop、digest 相同,本机动作变化与目标文件变化 digest 都变;CRLF 检出同内容判 noop;混合文件带 warning 且 blocked(mixed_file)、context.json 属别的项目记 blocked(identity_conflict);手写 Codex 条目记 manual_entry、本机 other_project 记 shared_key;PlanSink 覆盖读与改回撤销;diff 截断与标注;计划=实际写入。mcp_inject 新增 codex_trust_reads_projects_table_including_ancestors。变异:PlanSink::read 忽略待写内容 → plan_predicts_exactly_what_apply_writes(AGENTS.md 哈希不符)与 plan_sink_overlays_pending_writes_and_reverts_to_noop 失败,原有 5 条 plan 测试仍通过(补测试前此变异只被单元测试捕获)。" -- 自查(对照 acceptance 逐条):空仓库全 create + 快照字节一致 ✅;两次 plan 全 noop、digest 相同 ✅;任一目标文件变化 digest 变——测试以 capability-packs.json 一个文件为代表,并另测本机动作变化 ✅;块外未提交改动带预警且 blocked 非空 ✅;CRLF 判 noop ✅;超长 diff 截断并标注——在 bounded_diff 单元层测试,未经 plan 端到端构造超长差异 ✅;identity_conflict ✅;machine_changes 列出各本机步骤 ✅(plan_machine_steps 依赖真实凭据存储与用户目录,无注入点,未做单元测试,只经 codex_config_trusts 与 P8b 的只读判定测试间接覆盖);原有 onboarding、blueprint、mcp_inject 测试全部通过 ✅。 +- 自查(对照 acceptance 逐条):空仓库全 create + 快照字节一致 ✅;两次 plan 全 noop、digest 相同 ✅;任一目标文件变化 digest 变——原标 ✅ 说过头(审查 ⑥):只对计划会写入或核对的文件成立,已存在的只建不改文件、语义相同的 JSON、同版本托管块不列出、不进 digest(它们怎么变 apply 都不写);审查修复后 update 另绑定写入前哈希,准确语义写在 plan.rs 模块文档,测试补「写入前内容变 → digest 变」「改 lefthook.yml → digest 不变」(按纪律报备:改的是验收表述以符合实际语义,断言只增不减);块外未提交改动带预警且 blocked 非空 ✅;CRLF 判 noop ✅;超长 diff 截断并标注——在 bounded_diff 单元层测试,未经 plan 端到端构造超长差异 ✅;identity_conflict ✅;machine_changes 列出各本机步骤 ✅(plan_machine_steps 依赖真实凭据存储与用户目录,无注入点,未做单元测试;审查修复后其中的映射抽成纯函数 token_step、machine_step_blocker、codex_trust_next_action 分别单测,组装本身仍未单测);原有 onboarding、blueprint、mcp_inject 测试全部通过 ✅。 - 有意偏差:① 计划阶段完全不起 claude CLI(Claude 只读 ~/.claude.json 判定,沿用 P8b 的 static_status),比 acceptance「假 claude CLI 只收到 mcp get」更严;因此没有假 CLI 断言,由结构保证(static_status 路径无 CLI 调用)。② 文件动作只有 create/update/noop,设计文档 §1 的 append/skip 合并为 update/noop(acceptance 只要求 create/noop)。③ DiskSink 与 write_if_content_changed 同语义但未直接复用:另加按需建父目录,且读失败(非「不存在」)时报错而不是覆盖写。 - 自查发现(留待独立审查处置,本次未改代码):① 行为变化——dispatch_one 原以 target.exists() 判「已存在跳过」,现经 sink.read 判定,已存在但非 UTF-8(如 UTF-16/GBK 的 lefthook.yml、mise.toml)由 skipped 变 failed,plan 中成为 needs_human 阻断;全托管文件读失败由覆盖写变 failed(更安全)。② 设计 §1 要求计划单列 bypassPermissions 默认值(P8 决策 Q4、P8g 视图需要),当前只体现在 .claude/settings.json 的 create 概要里,未单列;本机变更清单也还没有产品组与 buff、.git/hooks(lefthook install)两步——宜在 P8d 编排时补进 machine_changes。③ codex_config_trusts 只认 [projects.'…'] 段头,TOML 基本字符串里的转义反斜杠(如 `[projects."D:\\Work"]`)与内联表写法不识别(本机 Codex 实际写的是字面量字符串小写路径,已核对 13 个段头均为此格式)。 - T1:plan_onboarding 目前无 MCP / Tauri 投影(P8e 才加),输出尚不是外部契约,docs/ai-context/project-context.yaml 未改;P8e 的 acceptance 已含「project-context.yaml 补 plan、digest 与状态语义」。 -- 待办:独立只读审查与修复、开 PR(M 级,由统筹会话安排);合并后标 ✅。 +- review: "2026-10-03 独立只读审查 04a0069,结论 fix_then_merge,9 条发现处置:① [中] Codex 未信任记 needs_human 被并入 blocked(设计 §4 blocked 只有四类,Q5/C4 应进 next_actions;新项目几乎都未信任,一键接入会全部卡住)→ 已修:codex_trust 移出本机步骤,计划新增 codex_trust(trusted/untrusted/not_installed/unknown)与 next_actions 两个字段,未信任或读不出 Codex 配置只进 next_actions,不阻断、不进 digest(去 Codex 信任后无需重新确认);「本机步骤 → 阻断项」抽成纯函数 machine_step_blocker。补 codex_trust_is_next_action_not_blocker_and_outside_digest、machine_step_blocker_maps_statuses。② [中] dispatch_one 改用 sink.read 判存在,已存在的非 UTF-8 文件由 skipped 变 failed(生产路径 apply_onboarding_pack 回归)→ 已修:WriteSink 新增 exists()(PlanSink 为待写或磁盘存在),dispatch_one 只判存在;全托管文件读不出保持 failed(改为显式分支,不再借道 dispatch_one)。补回归 existing_non_utf8_files_skip_or_fail_by_ownership:UTF-16LE 的 lefthook.yml 在计划与真实写入中都是 skipped、字节不变、计划无阻断;UTF-16 的 envoy.md 记 failed,blocked 只有它一条。③ [中] token 步把「未配置」与 needs_repair 合成 apply → 已修:credential_store 新增只读 mcp_auth_token_state(Present/Missing/NeedsRepair,分支与 get_or_create_mcp_auth_token 一一对应),token_step 映射为 noop/apply/needs_human(阻断);is_project_injected 仍只作「已接入」判据。补 mcp_auth_token_state_matches_get_or_create_branches、token_step_tells_missing_from_needs_repair。④ [低] 祖先目录信任未经验证、测试里的 TOML 非法(假阳性)→ 已修:去掉祖先规则,候选改为项目根 + git 主仓库根(git rev-parse --path-format=absolute --git-common-dir 的上级;worktree 继承主仓库信任、仓库子目录取仓库根——按审查所述的 Codex 判定方式,未核对 Codex 源码);键解析按 TOML 规则:字面量键、基本字符串反转义(非法转义的键忽略)、[projects] 段内联表与点号键。删除非法 TOML 用例,改为 codex_trust_matches_exact_dirs_with_real_toml_keys(含真实转义写法 \"D:\\\\Work\"、祖先目录不覆盖子目录、\\\\?\\ 前缀)与 git_main_worktree_root_resolves_worktree_and_subdir(真实 git worktree)。装了 Codex 但没有 config.toml 记 untrusted(原记 not_installed)。⑤ [低] 写入序列维护两份、注释不实 → 已修:抽出 write_project_identity_files_with(context.json → settings.json → liangjing.json),inject_project_mcp_locked 与 write_project_files 都只调它;Codex 托管块两边都经 upsert_codex_mcp_config_with(inject 由 CodexAdapter 调);删除 write_project_mcp_files_with 及其注释。⑥ [低] digest「任一文件变化就变」说过头,且不绑定写入前内容 → 已修并改口径:PlannedFile 新增 before_sha256(update 的写入前哈希)并进 digest——人确认后文件又被改、写入后内容不变也会 digest 不符;plan.rs 模块文档写明准确语义,自查条目同步改写(见上)。P8e 写 project-context.yaml 时沿用此语义。⑦ [低] 阻断项重复、target 误导 → 已修:lib.rs 新增 WriteFailure(path + reason;转 String 只取文案,sync_blueprint_rules 等原入口返回值不变),蓝图规则同步、身份与兼容投影失败都指向真正出错的文件;Codex 块失败指向 .codex/config.toml;本机 codex 步的阻断项指向 .codex/config.toml,blocked 按(target, reason)去重、文件级在前。补 blocked_targets_point_at_failing_files_without_duplicates;manual_codex 用例加入本机 codex manual_kept,断言 .codex/config.toml 只留一条 manual_entry。⑧ [信息] onboard 本体反向依赖 commands::* 与 mcp_inject → 本卡不改,记入 known_limits,建议 P8d 起下沉。⑨ [信息] P8d/P8g 的 acceptance 缺 bypassPermissions、Codex 信任单列与产品组/buff、.git/hooks 两步 → 本卡已单列 codex_trust 字段;其余写在 P8d 卡「审查转入」一行,待统筹合并时并入 acceptance。修复后全量 cargo test 318 passed / 0 failed / 5 ignored(较修复前 311 新增 7 条),编译 0 warning。变异验证(先暂存、后从暂存区还原):第一轮同时注入 6 处——dispatch_one 读失败记 failed、digest 混入 codex_trust、digest 去掉写入前哈希、needs_repair 记 apply、基本字符串不反转义、取消去重——恰好 7 条对应用例失败、其余 311 条通过;第二轮注入 codex 步阻断项不改指向、未信任重新并入 blocked,machine_step_blocker_maps_statuses、blocked_targets_point_at_failing_files_without_duplicates、codex_trust_is_next_action_not_blocker_and_outside_digest 失败。" +- known_limits: "① onboard/plan.rs 直接调 commands::onboarding、commands::blueprint、mcp_inject 的写入函数,适应度测试 onboard_module_is_projection_free 只做文本匹配、抓不到这种依赖方向——绞杀者过渡态,P8d 起把被调用的写入函数下沉到 onboard/ 或中立模块,commands 层做薄包装。② Codex 信任是计划阶段不起客户端的静态预判(只起只读 git rev-parse):候选只有项目根与 git 主仓库根(依审查所述 Codex 判定方式,未核对 Codex 源码);路径比较不区分大小写、统一分隔符;只解析 [projects.<键>] 段与 [projects] 段的内联表/点号键,顶层点号键 projects.'x'.trust_level 不识别;权威结论以 P4a 的 codex mcp get --json 自省为准。③ shim 步在 USERPROFILE 缺失或找不到炼境可执行文件时记 unavailable、不阻断,apply 时 ensure_header_shim 会失败(与 token 步不同口径,极少见,未改)。④ bypassPermissions 默认值仍未单列(P8d)。⑤ plan_machine_steps 的组装依赖真实凭据存储与用户目录,未单测;其中的映射函数已单测。" +- 待办:开 PR(M 级);合并后标 ✅。 ### 📋 P8d — onboard_project 编排本体 - status: todo @@ -271,6 +273,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - depends: P8c, P4a - acceptance: "agent_verify: [cargo test onboard::apply] 临时 git 仓库、in-memory DB,假 CredentialStore、ClientAdapter、ProcessRunner:① plan 后 apply(expected_digest, confirmed=true) → status=done,HEAD 不变,repo_changes 与 git status --porcelain 一致且都未提交;② 立即再次 plan 全部 noop,再次 apply 全部 converged、mtime 不变;③ digest 不符或 confirmed=false → refused、零写入;④ Claude 适配器失败 → partial,Codex 和 DSH 照常完成,L1 的 Claude 列记 blocked(dep=claude_local),修复后重跑得 done;⑤ 未登记的 project_id、空路径或相对路径 → failed、零写入;⑥ 非 git 目录 → repo_changes 为 unavailable、status 不是 done;⑦ 新项目没有 origin、CI、飞轮数据仍可 done,三项进 next_actions,Gitea 一项注明「默认公开」;⑧ 同一项目并发 apply,第二个返回 busy。" - 说明:存量 apply_onboarding_pack 一并改为 #[tauri::command(async)](现为同步,内部可能起 claude CLI,会卡界面)。某步失败只把依赖它的步骤标 blocked,其他照常,整体记 partial;不做自动回滚——项目文件靠 git 撤销,本机配置由下次收敛修复,整体撤销走 remove_project_mcp。 +- 审查转入(P8c 独立审查 ⑧⑨,待统筹合并时决定是否并入 acceptance):P8c 已在计划里单列 codex_trust 与 next_actions;P8d 还需单列 bypass_permissions(项目 .claude/settings.json 将写入 permissions.defaultMode=bypassPermissions 时),machine_changes 补产品组与 buff、.git/hooks(lefthook install)两步,否则 P8g 视图拿不到数据。apply 只走 onboard::plan::write_project_files(与计划同一写入序列),并把它调用的 commands::* / mcp_inject 写入函数逐步下沉到 onboard/ 或中立模块。 ### 📋 P8e — 投影收口与契约同步 - status: todo -- 2.45.2