diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 1e5150d..8d6c69e 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -104,11 +104,12 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - complexity: M - files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs - depends: H4 -- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe(exe 在 AppData 下时指向入口目录里的副本);inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功,且在 Codex 包上下文(Invoke-CommandInDesktopPackage 运行 codex exec,等价 Codex Desktop)同样成功;换构建目录启动后无需重注入仍成功。" +- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe;inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功,且在 Codex 包上下文(Invoke-CommandInDesktopPackage 运行 codex exec,等价 Codex Desktop)同样成功;换构建目录启动后无需重注入仍成功。" - 注:即原 project-dev-variables V18;Claude headersHelper 与 DSH 共用同一 shim。 - verification: "2026-10-01:shim 写入 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd(CRLF,exe 路径 % 转义),Enterprise 的 .codex/config.toml 托管块 helper 为 %LOCALAPPDATA% 形式、不含本机路径与用户名;Codex 无头 F 项经 shim 认证通过。H4 实测证实 Codex 经 cmd 展开 %LOCALAPPDATA%(替身目录因果对照)。换构建目录启动后免重注入:shim 每次启动重写指向当前 exe(实测在 worktree 构建启动时自动改指向)。 - 返工(2026-10-01 人终验发现):Codex Desktop 会话里 lian-jing 完全未加载,日志为 headers helper exit 1。根因:Codex Desktop 是 MSIX 打包应用,看不见其安装后在 %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录(包上下文对照实测:lian-jing 与两个新建对照目录均不可见,与 ACL 无关;已有目录内新建文件、%USERPROFILE% 下新目录均可见;Codex 启动晚于目录创建,排除重启因素)。原无头验收用包外的 codex exec,未覆盖该条件。修复:入口迁至 %USERPROFILE%\.lian-jing\bin;炼境 exe 位于 AppData 下(安装版默认)时按大小+修改时间指纹复制到入口目录并清理旧副本;旧入口迁移期同步保留,verify_onboarding 把 %LOCALAPPDATA% 写法报 Legacy。复验:四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)均返回「开发需求未登记」,证实 USERPROFILE 在 Codex helper 环境白名单内;包内运行用户目录下的 exe 副本 exit 0。" -- known_limits: "旧 %LOCALAPPDATA% 入口仅为迁移期兼容,各子项目需重注入(会改动其受跟踪的 .codex/config.toml,需在子项目提交);Microsoft Store 版等其他 MSIX 打包客户端未逐一实测,规律同 Codex Desktop。" + 返工(2026-10-01 人终验发现):Codex Desktop 会话里 lian-jing 完全未加载,日志为 headers helper exit 1。修复:入口迁至 %USERPROFILE%\.lian-jing\bin;旧入口迁移期同步保留,verify_onboarding 把 %LOCALAPPDATA% 写法报 Legacy。复验:四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)均返回「开发需求未登记」,证实 USERPROFILE 在 Codex helper 环境白名单内。 + 根因更正(2026-10-02):首次判断「Codex Desktop(MSIX)看不见其安装后在 AppData 下新建的顶层目录」是错的——当时的对照目录本身也是在 Claude 桌面版的包环境里建的。真正原因:worktree 版炼境由 Claude 桌面版(MSIX,Claude_pzs8sxrjxfjjc)里的 agent 启动,继承了其包环境,写到 %LOCALAPPDATA%\lian-jing 的入口被 Windows 重定向进 %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\,只有 Claude 的进程树看得到。对照:经 WMI 启动的非包进程在 AppData 下新建的顶层目录,Codex 包内可见;%USERPROFILE% 根不做这种重定向。入口迁至 %USERPROFILE% 因此仍是正解(与炼境在什么环境里启动无关);据错误前提加的「exe 在 AppData 下就复制副本」逻辑已按「退出」原则撤销(exe 总在真实路径上)。" +- known_limits: "旧 %LOCALAPPDATA% 入口仅为迁移期兼容,各子项目需重注入(会改动其受跟踪的 .codex/config.toml,需在子项目提交)。炼境若在 MSIX 应用进程树里启动,它写的其他 AppData 内容(如安装目录外的缓存)同样只对该应用可见,凡给其他客户端读的文件都不应放在 AppData。" ### 📋 P4 — 三端对等探针固化为炼境能力 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index cb1b12a..d7c2d32 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因 MSIX 打包的 Codex Desktop 看不见其安装后在 AppData 下新建的顶层目录;炼境 exe 位于 AppData 下时复制一份到入口目录;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,仅 headless profile 由 !!js 从启动目录向上精确匹配根目录才选项目地址并取认证,web/acp/tui/桌面版一个进程可服务多个工作区,一律走全局以防跨项目带认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因炼境若在 MSIX 应用的进程树里启动(如由 Claude 桌面版里的 agent 启动),AppData 下新建的目录会被重定向到该应用的私有目录,其他客户端看不到,用户目录根不受此影响;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,仅 headless profile 由 !!js 从启动目录向上精确匹配根目录才选项目地址并取认证,web/acp/tui/桌面版一个进程可服务多个工作区,一律走全局以防跨项目带认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 794d7dc..9db5343 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -683,7 +683,7 @@ fn codex_helper_upgrade_hint(content: &str) -> Option<&'static str> { } if in_lian_jing && trimmed.starts_with("http_headers_helper") { if trimmed.contains("%LOCALAPPDATA%") { - return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(Codex Desktop 为 MSIX 应用,看不见该目录,取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(炼境若在 MSIX 应用的进程树里启动,例如由 Claude 桌面版里的 agent 启动,该目录会被重定向到那个应用的私有目录,Codex Desktop 等其他客户端取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); } if trimmed.contains("--lianjing-mcp-header") { return Some("helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); @@ -1538,7 +1538,7 @@ mod tests { assert_eq!(legacy.state, VerifyState::Legacy); assert!(legacy.detail.contains("本机 exe 绝对路径")); - // 2026-10-01:%LOCALAPPDATA% 入口在 Codex Desktop(MSIX)里不可见,由 Ready 改判 Legacy + // 2026-10-01:%LOCALAPPDATA% 入口可能被重定向进某个 MSIX 应用的私有目录,由 Ready 改判 Legacy let app_data = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index eabc6f1..5cd25b9 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -11,16 +11,15 @@ const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; const CONTEXT_SCHEMA_VERSION: u8 = 1; /// 稳定 helper 入口文件名:位于 %USERPROFILE%\.lian-jing\bin\,每次启动指向当前 exe。 const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd"; -/// 炼境装在 AppData 下时,复制到入口目录的 helper exe 副本的文件名前缀。 -const HELPER_EXE_PREFIX: &str = "lianjing-helper-"; const DSH_MANAGED_START: &str = "# LIANJING_DSH_MANAGED_START"; const DSH_MANAGED_END: &str = "# LIANJING_DSH_MANAGED_END"; // ── 稳定 helper 入口(三端共用)──────────────────────────────────────────────── // Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开环境变量;DSH 由 !!js 经 cmd 调用。 // 项目配置只引用用户目录下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 -// 入口放在 %USERPROFILE% 而非 AppData:Codex Desktop 是 MSIX 打包应用,看不见它安装之后才在 -// %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录,用户目录根不受此限(2026-10-01 在 Codex 包上下文实测)。 +// 入口放在 %USERPROFILE% 而非 AppData:炼境若是在 MSIX 打包应用的进程树里启动的(例如由 Claude 桌面版 +// 里的 agent 启动 dev 构建),它在 AppData 下新建的目录会被 Windows 重定向到该应用的私有目录, +// Codex Desktop 等其他客户端看不到;用户目录根不做这种重定向(2026-10-01/02 包上下文对照实测)。 fn helper_bin_dir() -> Result { std::env::var_os("USERPROFILE") @@ -30,8 +29,8 @@ fn helper_bin_dir() -> Result { .ok_or_else(|| "USERPROFILE 未设置,无法写入 MCP helper 入口".to_string()) } -/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(非 MSIX 客户端可用), -/// 在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 +/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(炼境不在包环境里启动时 +/// 各客户端都能看到),在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 fn legacy_shim_path() -> Option { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) @@ -43,78 +42,6 @@ fn legacy_shim_path() -> Option { }) } -/// 纯函数:exe 是否位于 MSIX 客户端可能看不见的 AppData 目录下。 -fn under_app_data(executable: &std::path::Path, app_data_roots: &[PathBuf]) -> bool { - let exe = executable.display().to_string(); - app_data_roots - .iter() - .any(|root| key_covers_root(&root.display().to_string(), &exe)) -} - -/// 纯函数:helper exe 副本名,以源文件大小与修改时间作指纹。换构建即换名,不覆盖正被客户端执行的旧副本。 -fn helper_copy_name(len: u64, modified_nanos: u128) -> String { - format!("{HELPER_EXE_PREFIX}{len:x}-{modified_nanos:x}.exe") -} - -fn app_data_roots() -> Vec { - ["LOCALAPPDATA", "APPDATA"] - .iter() - .filter_map(|name| std::env::var_os(name)) - .map(PathBuf::from) - .filter(|root| root.is_absolute()) - .collect() -} - -/// shim 要调用的 exe:炼境装在 AppData 下(安装版默认位置)时复制一份到入口目录,否则直接用当前 exe。 -/// 副本只在缺失时复制;刷盘后再改名,避免断电留下残缺副本却因同名被一直复用。 -fn helper_executable( - bin_dir: &std::path::Path, - executable: &std::path::Path, - app_data: &[PathBuf], -) -> Result { - if !under_app_data(executable, app_data) { - return Ok(executable.to_path_buf()); - } - let metadata = std::fs::metadata(executable) - .map_err(|error| format!("读取炼境可执行文件信息失败: {error}"))?; - let modified_nanos = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|elapsed| elapsed.as_nanos()) - .unwrap_or(0); - let target = bin_dir.join(helper_copy_name(metadata.len(), modified_nanos)); - if !target.is_file() { - let partial = target.with_extension("partial"); - std::fs::copy(executable, &partial) - .and_then(|_| { - std::fs::OpenOptions::new() - .write(true) - .open(&partial)? - .sync_all() - }) - .and_then(|_| std::fs::rename(&partial, &target)) - .map_err(|error| format!("复制 helper exe 失败: {error}"))?; - } - Ok(target) -} - -/// 入口已指向 current 之后再清理旧副本(被占用的留待下次启动);先删后写会让写入失败时入口悬空。 -fn remove_stale_helper_copies(bin_dir: &std::path::Path, current: &std::path::Path) { - let Ok(entries) = std::fs::read_dir(bin_dir) else { - return; - }; - for entry in entries.flatten() { - let is_copy = entry - .file_name() - .to_string_lossy() - .starts_with(HELPER_EXE_PREFIX); - if is_copy && entry.path() != current { - let _ = std::fs::remove_file(entry.path()); - } - } -} - /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 /// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 /// 调用与退出写在同一行:cmd 在命令返回后按字节偏移重读批处理文件,入口若恰在 helper 运行期间被改写, @@ -147,28 +74,14 @@ pub fn ensure_header_shim() -> Result { let bin_dir = helper_bin_dir()?; std::fs::create_dir_all(&bin_dir) .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + // exe 本身总在真实路径上(安装目录或构建目录),各客户端都能看到,直接引用即可 let executable = std::env::current_exe() .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; - // 复制失败时退回直接调用当前 exe:非 MSIX 客户端仍可用,只记录告警 - let target = - helper_executable(&bin_dir, &executable, &app_data_roots()).unwrap_or_else(|error| { - log_event( - "mcp_inject", - "warn", - &format!( - "{error};helper 入口改为直接调用当前 exe(Codex Desktop 可能无法取认证)" - ), - Some(r#"{"client":"shim"}"#), - ); - executable.clone() - }); - let content = render_header_shim(&target); + let content = render_header_shim(&executable); let path = bin_dir.join(MCP_HEADER_SHIM_NAME); write_if_changed(&path, &content)?; - let mut legacy_written = true; if let Some(legacy) = legacy_shim_path() { if let Err(error) = write_if_changed(&legacy, &content) { - legacy_written = false; log_event( "mcp_inject", "warn", @@ -177,10 +90,6 @@ pub fn ensure_header_shim() -> Result { ); } } - // 旧位置入口没更新成功时它可能仍指向旧副本,暂不清理 - if legacy_written { - remove_stale_helper_copies(&bin_dir, &target); - } Ok(path) } @@ -1592,74 +1501,7 @@ mod tests { assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); assert!( !helper.contains("%LOCALAPPDATA%"), - "MSIX 客户端看不见 AppData 下后建的目录" - ); - } - - #[test] - fn helper_exe_is_copied_out_of_app_data_once_per_build() { - let base = - std::env::temp_dir().join(format!("lianjing_helper_copy_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let app_data = base.join("AppData").join("Local"); - let install = app_data.join("dev-manager-tauri"); - let bin_dir = base.join(".lian-jing").join("bin"); - std::fs::create_dir_all(&install).unwrap(); - std::fs::create_dir_all(&bin_dir).unwrap(); - let exe = install.join("app.exe"); - std::fs::write(&exe, b"build-1").unwrap(); - std::fs::write( - bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")), - b"old", - ) - .unwrap(); - std::fs::write(bin_dir.join(MCP_HEADER_SHIM_NAME), b"keep").unwrap(); - - let outside = base.join("E-drive").join("app.exe"); - assert_eq!( - helper_executable(&bin_dir, &outside, &[app_data.clone()]).unwrap(), - outside, - "不在 AppData 下的 exe 直接使用" - ); - let copied = helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(); - assert!(copied.starts_with(&bin_dir) && copied.is_file()); - assert_eq!(std::fs::read(&copied).unwrap(), b"build-1"); - let stale = bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")); - assert!(stale.exists(), "入口写好之前不清理旧副本"); - remove_stale_helper_copies(&bin_dir, &copied); - assert!(!stale.exists(), "旧副本被清理"); - assert!(copied.exists(), "当前副本保留"); - assert!( - bin_dir.join(MCP_HEADER_SHIM_NAME).exists(), - "入口脚本不受清理影响" - ); - assert_eq!( - helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(), - copied, - "同一构建复用已有副本" - ); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn app_data_detection_is_case_insensitive_and_boundary_safe() { - let roots = [PathBuf::from("C:\\Users\\u\\AppData\\Local")]; - assert!(under_app_data( - std::path::Path::new("c:\\users\\U\\appdata\\local\\dev-manager-tauri\\a.exe"), - &roots - )); - assert!(!under_app_data( - std::path::Path::new("C:\\Users\\u\\AppData\\LocalLow\\a.exe"), - &roots - )); - assert!(!under_app_data( - std::path::Path::new("E:\\code\\a.exe"), - &roots - )); - assert_ne!( - helper_copy_name(1, 2), - helper_copy_name(1, 3), - "换构建即换名" + "炼境在 MSIX 应用进程树里启动时,AppData 下新建的目录会被重定向到该应用的私有目录" ); }