diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 68f73fe..ccb28ff 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -68,7 +68,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - **H4 ✅ 成立(第二轮零凭据实测)**:首轮仅 cmd 层模拟、被复核判证据不足;第二轮用哑 helper + 本地请求记录器直接观测 Codex 0.155 自身行为:`"%LOCALAPPDATA%\...\hdr.cmd"` 被执行且输出成为请求头(记录器收到 `codex-mcp-client` 携带的标记头);`%NOT_A_REAL_VAR%` 不执行;父进程导出的自定义变量 `%LJPROBE_BASE%` 不执行(证实 env_clear 白名单);把 `LOCALAPPDATA` 指向替身目录后执行的是替身目录里的脚本(因果对照);cmd 独有的 `%LOCALAPPDATA:~0%` 子串语法也生效(展开由 cmd.exe 完成);helper 非零退出时连接不建立。 - **H2 ✅ 成立(第二轮零凭据实测)**:Claude Code 2.1.173 在 Windows 经 **cmd.exe** 执行 headersHelper——带引号反斜杠路径、正斜杠路径、`%TEMP%`、显式 `bash x.sh` 均执行且输出成为请求头;`$TEMP/...` 不执行,`%USERNAME%` 展开而 `$USERNAME` 保持字面。**local scope 覆盖 user scope**:init 事件中 `lian-jing` 工具数 35(全局)→ 加 local 条目后 32(项目路由)→ 删除后回到 35。经 `--mcp-config` 传入的 helper 在未信任目录也执行(文档所述 trust 限制针对项目 `.mcp.json` 来源)。`-p` 需设 `MCP_CONNECTION_NONBLOCKING=false` 才会等 MCP 连上再发 init。 - 第二轮两项的对抗复核 agent 未能运行(见下条),结论由主会话直接核对原始日志(记录器日志由独立进程写入)得出,未经第二个 agent 复现。 -- **结论:Codex 与 Claude 都经 cmd.exe 执行 helper,可共用同一个 `%LOCALAPPDATA%\lian-jing\bin\` shim 写法**;DSH 经 `!!js` + `execFileSync` 直接调用同一 shim(在 JS 里用 `process.env.LOCALAPPDATA` 拼路径)。 +- **结论:Codex 与 Claude 都经 cmd.exe 执行 helper,可共用同一个 shim 写法**;DSH 经 `!!js` + `execFileSync` 直接调用同一 shim。(入口位置 2026-10-01 由 `%LOCALAPPDATA%\lian-jing\bin\` 迁至 `%USERPROFILE%\.lian-jing\bin\`,原因见 P3 返工记录。) ### 实验附带发现 @@ -104,9 +104,11 @@ 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] 炼境启动写/更新 %LOCALAPPDATA% 下 shim 指向当前 exe;inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功;换构建目录启动后无需重注入仍成功。" +- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe(exe 在 AppData 下时指向入口目录里的副本);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 构建启动时自动改指向)。" +- 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。" ### 📋 P4 — 三端对等探针固化为炼境能力 - status: todo diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md index f0c7904..c200456 100644 --- a/.blueprint/modules/project-dev-variables.md +++ b/.blueprint/modules/project-dev-variables.md @@ -231,7 +231,7 @@ progress: 91 ### 方案 -1. **稳定 helper 入口(解 V18 根因)**:炼境每次启动(debug 或安装版)重写 `%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd` 指向当前 exe。Codex 项目配置改为 `http_headers_helper = '"%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd" '`——digest = sha256(project_id) 非秘密,整行在任何机器、任何构建下相同,可安全提交;换构建无需逐项目重注入。 +1. **稳定 helper 入口(解 V18 根因)**(2026-10-01 起入口位置改为 `%USERPROFILE%\.lian-jing\bin\`,以 agent-client-parity P3 为准):炼境每次启动(debug 或安装版)重写 `%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd` 指向当前 exe。Codex 项目配置改为 `http_headers_helper = '"%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd" '`——digest = sha256(project_id) 非秘密,整行在任何机器、任何构建下相同,可安全提交;换构建无需逐项目重注入。 2. **Claude(V17 主体)**:inject_mcp 在项目目录以 local scope 写入 `lian-jing`(scoped url + headersHelper 指向同一 shim;local 条目不进仓库,可用绝对路径);保留 user scope 全局注册作兜底。 3. **DSH:不写静态 token**。静态 header 意味着 bearer 明文落在用户目录配置文件,且 DSH 配置无法按项目拆分;DSH 取值改走 V14–V16 落地路径(写入被忽略文件、不返回值、无需认证),待 dsh-mcp-client 支持动态 header 再评估。 4. **推论:V14–V16 是唯一全客户端通用的路径**(落地不返回值,无需认证);读取明文的有界例外只服务支持动态 header 的客户端。 diff --git a/AGENTS.md b/AGENTS.md index 60e869a..aa3c38c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%LOCALAPPDATA% 稳定 helper 入口) +src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%.lian-jing 稳定 helper 入口) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index ac511c5..21b3f08 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(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 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 打包的 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」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 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 f230bc0..794d7dc 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -670,9 +670,10 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> { None } -/// 旧注入把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错);新注入只引用 -/// %LOCALAPPDATA% 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 -fn codex_helper_uses_machine_path(content: &str) -> bool { +/// helper 的旧写法需要重注入升级:最早把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错); +/// 之后改为 %LOCALAPPDATA% 下的入口,但 Codex Desktop(MSIX)看不见该目录。新注入只引用 +/// %USERPROFILE%\.lian-jing 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 +fn codex_helper_upgrade_hint(content: &str) -> Option<&'static str> { let mut in_lian_jing = false; for line in content.lines() { let trimmed = line.trim(); @@ -681,10 +682,16 @@ fn codex_helper_uses_machine_path(content: &str) -> bool { continue; } if in_lian_jing && trimmed.starts_with("http_headers_helper") { - return trimmed.contains("--lianjing-mcp-header") && !trimmed.contains("%LOCALAPPDATA%"); + if trimmed.contains("%LOCALAPPDATA%") { + return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(Codex Desktop 为 MSIX 应用,看不见该目录,取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + } + if trimmed.contains("--lianjing-mcp-header") { + return Some("helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + } + return None; } } - false + None } pub(crate) fn check_codex_mcp( @@ -701,13 +708,10 @@ pub(crate) fn check_codex_mcp( }; let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); let configured_url = codex_mcp_server_url(content); - if configured_url == Some(expected_url.as_str()) && codex_helper_uses_machine_path(content) { - VerifyCheck::with_state( - "Codex MCP 已配置", - VerifyState::Legacy, - "helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %LOCALAPPDATA% 稳定入口", - ) - } else if configured_url == Some(expected_url.as_str()) { + let url_matches = configured_url == Some(expected_url.as_str()); + if let Some(hint) = codex_helper_upgrade_hint(content).filter(|_| url_matches) { + VerifyCheck::with_state("Codex MCP 已配置", VerifyState::Legacy, hint) + } else if url_matches { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Ready, @@ -1532,10 +1536,18 @@ mod tests { ); let legacy = check_codex_mcp(Some(&machine), "p1", 27190); assert_eq!(legacy.state, VerifyState::Legacy); - assert!(legacy.detail.contains("%LOCALAPPDATA%")); + assert!(legacy.detail.contains("本机 exe 绝对路径")); + + // 2026-10-01:%LOCALAPPDATA% 入口在 Codex Desktop(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" + ); + let app_data_check = check_codex_mcp(Some(&app_data), "p1", 27190); + assert_eq!(app_data_check.state, VerifyState::Legacy); + assert!(app_data_check.detail.contains("MSIX")); let stable = format!( - "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 988fe5b..182cba4 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -9,22 +9,97 @@ const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; const CONTEXT_SCHEMA_VERSION: u8 = 1; -/// 稳定 helper 入口文件名:位于 %LOCALAPPDATA%\lian-jing\bin\,每次启动指向当前 exe。 +/// 稳定 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 并展开 %LOCALAPPDATA%;DSH 由 !!js 经 cmd 调用。 -// 项目配置只引用 %LOCALAPPDATA% 下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 +// Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开环境变量;DSH 由 !!js 经 cmd 调用。 +// 项目配置只引用用户目录下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 +// 入口放在 %USERPROFILE% 而非 AppData:Codex Desktop 是 MSIX 打包应用,看不见它安装之后才在 +// %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录,用户目录根不受此限(2026-10-01 在 Codex 包上下文实测)。 -fn header_shim_path() -> Result { - let base = std::env::var("LOCALAPPDATA") - .map_err(|_| "LOCALAPPDATA 未设置,无法写入 MCP helper 入口".to_string())?; - Ok(PathBuf::from(base) - .join("lian-jing") - .join("bin") - .join(MCP_HEADER_SHIM_NAME)) +fn helper_bin_dir() -> Result { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .filter(|home| home.is_absolute()) + .map(|home| home.join(".lian-jing").join("bin")) + .ok_or_else(|| "USERPROFILE 未设置,无法写入 MCP helper 入口".to_string()) +} + +/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(非 MSIX 客户端可用), +/// 在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 +fn legacy_shim_path() -> Option { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|base| base.is_absolute()) + .map(|base| { + base.join("lian-jing") + .join("bin") + .join(MCP_HEADER_SHIM_NAME) + }) +} + +/// 纯函数: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 name = helper_copy_name(metadata.len(), modified_nanos); + let target = bin_dir.join(&name); + if !target.is_file() { + let partial = bin_dir.join(format!("{name}.partial")); + std::fs::copy(executable, &partial) + .and_then(|_| std::fs::rename(&partial, &target)) + .map_err(|error| format!("复制 helper exe 失败: {error}"))?; + } + if let Ok(entries) = std::fs::read_dir(bin_dir) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if file_name.starts_with(HELPER_EXE_PREFIX) && file_name != name { + let _ = std::fs::remove_file(entry.path()); + } + } + } + Ok(target) } /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 @@ -39,26 +114,56 @@ fn render_header_shim(executable: &std::path::Path) -> String { format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n") } +fn write_if_changed(path: &std::path::Path, content: &str) -> Result<(), String> { + if std::fs::read_to_string(path).ok().as_deref() == Some(content) { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + } + std::fs::write(path, content).map_err(|error| format!("写入 MCP helper 入口失败: {error}")) +} + /// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。 pub fn ensure_header_shim() -> Result { - let path = header_shim_path()?; + let bin_dir = helper_bin_dir()?; + std::fs::create_dir_all(&bin_dir) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; let executable = std::env::current_exe() .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; - let content = render_header_shim(&executable); - if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + // 复制失败时退回直接调用当前 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 path = bin_dir.join(MCP_HEADER_SHIM_NAME); + write_if_changed(&path, &content)?; + if let Some(legacy) = legacy_shim_path() { + if let Err(error) = write_if_changed(&legacy, &content) { + log_event( + "mcp_inject", + "warn", + &format!("旧位置 helper 入口更新失败(仅影响尚未重注入的项目): {error}"), + Some(r#"{"client":"shim"}"#), + ); } - std::fs::write(&path, content) - .map_err(|error| format!("写入 MCP helper 入口失败: {error}"))?; } Ok(path) } -/// 客户端配置中的 helper 命令:只含 %LOCALAPPDATA% 与非秘密 digest,可安全提交进项目仓库。 +/// 客户端配置中的 helper 命令:只含 %USERPROFILE% 与非秘密 digest,可安全提交进项目仓库。 fn shim_helper_command(digest: &str) -> String { - format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") + format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") } // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -753,10 +858,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { let url_return = format!("return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"); let shim_line = - format!("const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); + format!("const shim = path.join(base, '.lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); let header_body = [ "if (!id) return {};", - "const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;", + "const base = process.env.USERPROFILE, sys = process.env.SystemRoot;", "if (!base || !path.isAbsolute(base) || !sys || !path.isAbsolute(sys)) return {};", shim_line.as_str(), "const cmd = path.join(sys, 'System32', 'cmd.exe');", @@ -1452,10 +1557,82 @@ mod tests { let helper = shim_helper_command("qa-digest"); assert_eq!( helper, - "\"%LOCALAPPDATA%\\lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" + "\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" ); assert!(!helper.contains(":\\"), "不得含盘符绝对路径"); 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"); + assert!( + !bin_dir + .join(format!("{HELPER_EXE_PREFIX}stale.exe")) + .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), + "换构建即换名" + ); } #[test] @@ -1550,7 +1727,7 @@ mod tests { assert!(entry["headersHelper"] .as_str() .unwrap() - .contains("%LOCALAPPDATA%")); + .contains("%USERPROFILE%\\.lian-jing\\bin\\")); } #[test] @@ -1713,6 +1890,10 @@ mod tests { "未命中回退全局地址" ); assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); + assert!( + block.contains("process.env.USERPROFILE") && block.contains("'.lian-jing', 'bin'"), + "与 Codex/Claude 共用用户目录下的入口" + ); assert!( block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")