dev-manager-tauri/src-tauri/src/mcp_inject.rs
lanrtop a79bcb7fca
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
Push & PR Check / check (pull_request) Successful in 1m2s
chore(merge): 合并 master(含 P8f register_project)到 P4a,解决 mcp_tools.rs 与 project-context.yaml 相邻插入冲突
两边各自新增全局路由工具(probe_client_parity / register_project):清单插入、
is_project_scoped_tool 排除、STRUCTURED_TOOLS 与摘要分支均保留双方;契约文档合并两段说明。
合并结果 cargo test 377 passed / 0 failed / 6 ignored,pnpm typecheck 通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:48:28 +09:00

4268 lines
177 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::onboard::clients::{
ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason,
ProjectFacts,
};
use crate::onboard::probe_l1::{
self, HelperCommand, L1ClientFacts, L1Source, Located, StaticFinding,
};
use crate::{db, db::log_event};
use serde_json::{json, Value};
use std::path::PathBuf;
/// 单一全局服务器的 settings.json key / 全局注册名
const MCP_KEY: &str = "lian-jing";
/// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删
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 入口文件名:位于 %USERPROFILE%\.lian-jing\bin\,每次启动指向当前 exe。
const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd";
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:炼境若是在 MSIX 打包应用的进程树里启动的(例如由 Claude 桌面版
// 里的 agent 启动 dev 构建),它在 AppData 下新建的目录会被 Windows 重定向到该应用的私有目录,
// Codex Desktop 等其他客户端看不到;用户目录根不做这种重定向(2026-10-01/02 包上下文对照实测)。
fn helper_bin_dir() -> Result<PathBuf, String> {
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\(炼境不在包环境里启动时
/// 各客户端都能看到),在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。
fn legacy_shim_path() -> Option<PathBuf> {
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)
})
}
/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。
/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。
/// 调用与退出写在同一行:cmd 在命令返回后按字节偏移重读批处理文件,入口若恰在 helper 运行期间被改写,
/// 分行写法会执行到错位片段(实测 exit 9009)。`call exit /b %%ERRORLEVEL%%` 在执行时才展开,能传递 exe 退出码。
fn render_header_shim(executable: &std::path::Path) -> String {
let exe = executable.display().to_string().replace('%', "%%");
let codepage = if exe.is_ascii() {
""
} else {
"chcp 65001 >nul\r\n"
};
format!(
"@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1 & call exit /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<PathBuf, String> {
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())?;
let content = render_header_shim(&executable);
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"}"#),
);
}
}
Ok(path)
}
/// 客户端配置中的 helper 命令:只含 %USERPROFILE% 与非秘密 digest,可安全提交进项目仓库。
fn shim_helper_command(digest: &str) -> String {
format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}")
}
/// 迁移期旧写法(%LOCALAPPDATA% 入口,ensure_header_shim 仍同步写入):L1 视同托管写法、照常执行。
fn legacy_shim_helper_command(digest: &str) -> String {
format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}")
}
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
/// 空路径经 PathBuf::from("") 会解析到炼境进程 cwd——2026-07-15 空路径登记
/// 曾把 settings.json/liangjing.json 写进 src-tauri/(apply 链路同日已拦,
/// inject 链路本处单点补防)。
fn validate_registered_path(raw: &str, field: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{field} 为空,拒绝解析(会写进炼境进程 cwd)"));
}
let path = PathBuf::from(trimmed);
if !path.is_absolute() {
return Err(format!("{field} 非绝对路径,拒绝解析: {trimmed:?}"));
}
Ok(path)
}
/// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径)
fn project_root(project_id: &str) -> Result<PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
project_root_with_conn(&conn, project_id)
}
/// project_root 的依赖注入版:连接由调用方传入(业务层复用 + in-memory 测试)
pub(crate) fn project_root_with_conn(
conn: &rusqlite::Connection,
project_id: &str,
) -> Result<PathBuf, String> {
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
if wsl.trim().is_empty() {
return Err("wsl_path 为空,拒绝解析(会指向 WSL 发行版根目录)".to_string());
}
let distro: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
validate_registered_path(&win, "win_path")
}
}
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?
.join(".claude")
.join("settings.json"))
}
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
}
fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
read_context_object_with(path, label, &crate::DiskSink)
}
fn read_context_object_with(
path: &std::path::Path,
label: &str,
sink: &dyn crate::WriteSink,
) -> Result<Value, String> {
match sink.read(path) {
Ok(Some(content)) if content.trim().is_empty() => Ok(json!({})),
Ok(Some(content)) => serde_json::from_str::<Value>(&content)
.map_err(|error| format!("读取 {label} 失败({}): {error}", path.display()))
.and_then(|value| {
if value.is_object() {
Ok(value)
} else {
Err(format!(
"{label} 顶层必须是 JSON 对象({})",
path.display()
))
}
}),
Ok(None) => Ok(json!({})),
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
}
}
/// 现有 JSON 文本与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
fn json_text_equals(existing: Option<&str>, value: &Value) -> bool {
existing
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
.is_some_and(|existing| &existing == 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(());
}
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
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_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_json_with(&path, &ctx, "canonical 项目上下文", sink)
}
/// 写旧 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_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)
}
/// 项目身份与兼容投影(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,
sink: &dyn crate::WriteSink,
) -> 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 命令)
fn find_claude_bin() -> Option<String> {
if let Ok(path) = std::env::var("CLAUDE_BIN") {
if std::path::Path::new(&path).exists() {
return Some(path);
}
}
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_default();
let candidates = [
format!(r"{}\.local\bin\claude.exe", home),
format!(r"{}\AppData\Local\AnthropicClaude\claude.exe", home),
r"C:\Program Files\AnthropicClaude\claude.exe".to_string(),
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.clone());
}
}
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
if let Ok(out) = crate::silent_cmd("where").arg("claude.exe").output() {
if out.status.success() {
if let Ok(stdout) = String::from_utf8(out.stdout) {
if let Some(line) = stdout.lines().next() {
return Some(line.trim().to_string());
}
}
}
}
None
}
/// 查找 git-bash 路径(claude CLI 在 Windows 上必需)。
/// 顺序:环境变量(校验存在)→ 常见安装位置(Program Files / Scoop)→ 从 git.exe 位置推导
fn find_git_bash() -> Option<String> {
if let Ok(p) = std::env::var("CLAUDE_CODE_GIT_BASH_PATH") {
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let home = std::env::var("USERPROFILE").unwrap_or_default();
let scoop = std::env::var("SCOOP").unwrap_or_else(|_| format!(r"{}\scoop", home));
let candidates = [
r"C:\Program Files\Git\bin\bash.exe".to_string(),
format!(r"{}\apps\git\current\bin\bash.exe", scoop),
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.clone());
}
}
// 从 git.exe 推导:<root>\cmd\git.exe / <root>\mingw64\bin\git.exe → <root>\bin\bash.exe
if let Ok(out) = crate::silent_cmd("where").arg("git.exe").output() {
if out.status.success() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
let git = std::path::Path::new(line.trim());
if let Some(root) = git.parent().and_then(|d| d.parent()) {
let bash = root.join("bin").join("bash.exe");
if bash.exists() {
return Some(bash.to_string_lossy().to_string());
}
}
}
}
}
None
}
/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json)。
/// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号)
fn try_register_globally(port: u16) -> bool {
let server_url = format!("http://127.0.0.1:{}/mcp", port);
// 机器级、幂等:只读确认已注册就不起 CLI(每次注入都会走到这里,避免无谓的 1–2 秒阻塞)
if read_claude_config().is_some_and(|config| claude_global_registered(&config)) {
return true;
}
let Some(claude_bin) = find_claude_bin() else {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过全局 MCP 注册",
None,
);
return false;
};
let Some(git_bash) = find_git_bash() else {
db::log_event(
"mcp_inject",
"warn",
"git-bash 未找到(claude CLI 依赖),跳过全局 MCP 注册",
None,
);
return false;
};
match run_claude_cli(
&claude_bin,
&git_bash,
None,
&[
"mcp",
"add",
"--transport",
"sse",
"-s",
"user",
MCP_KEY,
&server_url,
],
) {
Ok(_) => {
db::log_event(
"mcp_inject",
"info",
"全局 MCP 注册成功",
Some(&global_log_context()),
);
true
}
// 同名已存在不是故障——CLI 链路健康,注册目标已达成
Err(output) if output.contains("already exists") => true,
Err(output) => {
db::log_event(
"mcp_inject",
"warn",
&format!("claude mcp add 失败: {output}"),
Some(&global_log_context()),
);
false
}
}
}
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
fn project_wants_global_scope(root: &std::path::Path) -> bool {
read_context_object(
&root.join(".lian-jing").join("context.json"),
"canonical 项目上下文",
)
.ok()
.and_then(|ctx| {
ctx.get("mcp_scope")
.and_then(Value::as_str)
.map(|scope| scope == "global")
})
.unwrap_or(false)
}
/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。
fn inject_log_context(project_id: &str, client: &str) -> String {
json!({ "project_id": project_id, "client": client }).to_string()
}
fn global_log_context() -> String {
json!({ "client": "claude-global", "server": MCP_KEY }).to_string()
}
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value {
json!({
"type": "http",
"url": server_url,
"headersHelper": header_helper,
})
}
/// Claude 全局配置文件位置,与 Claude 的解析规则一致:旧版 `<配置目录>/.config.json` 存在则优先,
/// 否则为 `$CLAUDE_CONFIG_DIR`(或用户目录)下的 `.claude.json`。只用于只读检查。
fn claude_config_path() -> Option<PathBuf> {
let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from);
let home = std::env::var_os("USERPROFILE").map(PathBuf::from)?;
let legacy = config_dir
.clone()
.unwrap_or_else(|| home.join(".claude"))
.join(".config.json");
if legacy.is_file() {
return Some(legacy);
}
Some(config_dir.unwrap_or(home).join(".claude.json"))
}
/// 只读读取 Claude 全局配置(容忍 BOM)。读不到或解析失败返回 None,调用方按「未知」保守处理。
fn read_claude_config() -> Option<Value> {
let text = std::fs::read_to_string(claude_config_path()?).ok()?;
serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()
}
/// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 helper 认证的炼境托管条目。按 URL 匹配,
/// 不复刻 Claude 的项目键推导(Claude 以 git 根为键、worktree 映射到主仓库根、路径做 NFC 归一化)。
fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Value)> {
let suffix = format!("/mcp/projects/{}", urlencoding::encode(project_id));
let Some(projects) = config.get("projects").and_then(Value::as_object) else {
return Vec::new();
};
projects
.iter()
.filter_map(|(key, project)| {
let entry = project.get("mcpServers")?.get(MCP_KEY)?;
let url = entry.get("url").and_then(Value::as_str)?;
let helper = entry
.get("headersHelper")
.and_then(Value::as_str)
.unwrap_or("");
(url.starts_with("http://127.0.0.1:")
&& url.ends_with(&suffix)
&& helper.contains(MCP_HEADER_SHIM_NAME))
.then(|| (key.clone(), entry.clone()))
})
.collect()
}
fn claude_global_registered(config: &Value) -> bool {
config
.get("mcpServers")
.and_then(|servers| servers.get(MCP_KEY))
.is_some()
}
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。
const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。
fn run_claude_cli(
claude_bin: &str,
git_bash: &str,
cwd: Option<&std::path::Path>,
args: &[&str],
) -> Result<String, String> {
use std::io::Read;
let mut command = crate::silent_cmd(claude_bin);
if let Some(dir) = cwd {
command.current_dir(dir);
}
let mut child = command
.args(args)
.env("CLAUDE_CODE_GIT_BASH_PATH", git_bash)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| format!("执行 claude CLI 失败: {error}"))?;
let deadline = std::time::Instant::now() + CLAUDE_CLI_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"claude CLI 超过 {} 秒未结束,已终止",
CLAUDE_CLI_TIMEOUT.as_secs()
));
}
Err(error) => return Err(format!("等待 claude CLI 失败: {error}")),
}
};
let mut output = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut output);
}
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut output);
}
if status.success() {
Ok(output)
} else {
Err(output)
}
}
/// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。
fn normalize_root_key(path: &str) -> String {
path.replace('\\', "/").trim_end_matches('/').to_lowercase()
}
/// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。
fn key_covers_root(key: &str, root: &str) -> bool {
let key = normalize_root_key(key);
let root = normalize_root_key(root);
root == key || root.starts_with(&format!("{key}/"))
}
/// 炼境项目路由 URL 里的 project_id;不是炼境项目路由则为 None(视为手写配置)。
fn lianjing_project_of(url: &str) -> Option<String> {
let rest = url.strip_prefix("http://127.0.0.1:")?;
let encoded = rest.split_once("/mcp/projects/")?.1.trim_end_matches('/');
urlencoding::decode(encoded).ok().map(|id| id.into_owned())
}
#[derive(Debug, PartialEq)]
enum ClaudeEffective {
/// 本目录生效的是 local 条目,附其 URL
Local(String),
/// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing)
NotLocal,
}
/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。
/// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
use crate::onboard::probe_l1::ClaudeScope;
let parsed = crate::onboard::probe_l1::parse_claude_mcp_get(output)?;
match parsed.scope {
ClaudeScope::Local => Some(ClaudeEffective::Local(parsed.url?)),
ClaudeScope::Other(_) | ClaudeScope::Absent => Some(ClaudeEffective::NotLocal),
}
}
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。
///
/// 原则:写入一律交给 claude CLI(cwd=项目目录),判定以 Claude 自己的回答为准——炼境不复刻 Claude 的项目键
/// 推导、文件锁与配置路径(2026-10-01 多轮审查结论:任何自行推断都会在 worktree、目录迁移、共享键等边界出错)。
/// - 快路径(不起 CLI):本项目的托管条目已一致地位于项目根或其祖先目录的键下(Claude 的键是 git 根),视为已同步;
/// 只读确认没有任何托管条目时,移除无需动作。
/// - 其余情况在项目目录执行 `claude mcp get lian-jing` 取实际生效条目:本项目的托管条目 → 移除或刷新;
/// 他项目的托管条目(共享项目键)或手写条目 → 不碰并记录;没有 local 条目 → 按需写入。
/// - 刷新时先删后加,写入失败用原条目回滚;无法确认现状(读取失败、CLI 出错、目录不存在)一律不写,只记 warn。
///
/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用。
fn sync_claude_local(
project_id: &str,
root: &std::path::Path,
desired: Option<(String, String)>,
) -> ClientOutcome {
let config = read_claude_config();
sync_claude_local_with(project_id, root, desired, config.as_ref(), &|| {
let (claude_bin, git_bash) = (find_claude_bin()?, find_git_bash()?);
let cli: ClaudeCli =
Box::new(move |args: &[&str]| run_claude_cli(&claude_bin, &git_bash, Some(root), args));
Some(cli)
})
}
/// 在项目目录执行一条 claude 子命令,Ok/Err 都携带 CLI 输出。测试注入假实现,不起真实 CLI。
type ClaudeCli<'a> = Box<dyn Fn(&[&str]) -> Result<String, String> + 'a>;
/// sync_claude_local 的依赖注入版:Claude 全局配置与 CLI 执行器由调用方给出;
/// resolve_cli 返回 None 表示 claude CLI 或 git-bash 未找到。快路径不解析 CLI。
fn sync_claude_local_with<'a>(
project_id: &str,
root: &'a std::path::Path,
desired: Option<(String, String)>,
config: Option<&Value>,
resolve_cli: &dyn Fn() -> Option<ClaudeCli<'a>>,
) -> ClientOutcome {
let context = inject_log_context(project_id, "claude");
let warn = |message: &str| db::log_event(CLIENT_SYNC_LOG, "warn", message, Some(&context));
let unavailable = |reason: String| {
warn(&reason);
ClientOutcome::Unavailable { reason }
};
let root_text = root.display().to_string();
let managed_all = config
.map(|config| managed_claude_entries(config, project_id))
.unwrap_or_default();
let managed_here: Vec<&(String, Value)> = managed_all
.iter()
.filter(|(key, _)| key_covers_root(key, &root_text))
.collect();
let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper));
match &desired_entry {
Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => {
return ClientOutcome::Converged
}
None if config.is_some() && managed_all.is_empty() => return ClientOutcome::Converged,
_ => {}
}
if !root.is_dir() {
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
return unavailable(format!(
"项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
));
}
let Some(cli) = resolve_cli() else {
return unavailable("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into());
};
let effective = match cli(&["mcp", "get", MCP_KEY]) {
Ok(output) | Err(output) => parse_claude_mcp_get(&output),
};
let Some(effective) = effective else {
return unavailable(
"无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动"
.into(),
);
};
let remove = || cli(&["mcp", "remove", MCP_KEY, "--scope", "local"]);
let add = |entry: &Value| {
let payload = entry.to_string();
cli(&[
"mcp",
"add-json",
MCP_KEY,
payload.as_str(),
"--scope",
"local",
])
};
let failed = |reason: String| -> (ClientOutcome, Result<String, String>) {
(
ClientOutcome::Failed {
reason: reason.clone(),
},
Err(reason),
)
};
// (结构化结果, 日志):日志类别与文案保持原样
let (outcome, log): (ClientOutcome, Result<String, String>) = match (effective, &desired_entry)
{
(ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) {
Some(owner) if owner == project_id => match remove() {
Err(error) => failed(format!("旧条目移除失败: {error}")),
Ok(_) => match desired {
None => (
ClientOutcome::Removed,
Ok("Claude 项目级 MCP 条目已移除".into()),
),
Some(entry) => match add(entry) {
Ok(_) => (
ClientOutcome::Refreshed,
Ok("Claude 项目级 MCP 条目已刷新".into()),
),
Err(error) => {
// 回滚:用读到的原条目写回,避免项目失去 local 条目
let restored = managed_here
.first()
.map(|(_, previous)| add(previous).is_ok())
.unwrap_or(false);
failed(format!(
"新条目写入失败: {error}({})",
if restored {
"已恢复原条目"
} else {
"原条目未能恢复"
}
))
}
},
},
},
Some(owner) => {
let message = format!(
"本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动"
);
(ClientOutcome::OtherProject { owner }, Err(message))
}
None => (
ClientOutcome::ManualKept,
Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()),
),
},
(ClaudeEffective::NotLocal, Some(entry)) => match add(entry) {
Ok(_) => (
ClientOutcome::Applied,
Ok("Claude 项目级 MCP 条目已写入".into()),
),
Err(error) => failed(format!("写入失败: {error}")),
},
(ClaudeEffective::NotLocal, None) => {
if managed_all.is_empty() {
return ClientOutcome::Converged;
}
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
let reason = format!(
"本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
);
(
ClientOutcome::NeedsHuman {
reason: reason.clone(),
},
Err(reason),
)
}
};
match log {
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
Err(error) => warn(&format!("Claude 项目级 MCP 同步: {error}")),
}
outcome
}
/// 只读判定 Claude 在本项目根目录下的 local 条目状态(不起 CLI):只看覆盖本根目录的项目键。
fn claude_static_outcome(
config: &Value,
project_id: &str,
root: &std::path::Path,
desired: &Value,
) -> ClientOutcome {
let root_text = root.display().to_string();
// Claude 以 git 根为项目键:根目录自身是 git 根时只认等于根目录的键,祖先目录的条目不在本目录生效
let own_git_root = root.join(".git").exists();
let applies = |key: &str| {
if own_git_root {
normalize_root_key(key) == normalize_root_key(&root_text)
} else {
key_covers_root(key, &root_text)
}
};
let mut entries: Vec<(String, &Value)> = config
.get("projects")
.and_then(Value::as_object)
.map(|projects| {
projects
.iter()
.filter(|(key, _)| applies(key))
.filter_map(|(key, project)| {
Some((
normalize_root_key(key),
project.get("mcpServers")?.get(MCP_KEY)?,
))
})
.collect()
})
.unwrap_or_default();
// 嵌套项目:最具体(最长)的键才是本目录生效的条目,祖先目录的条目属于外层项目
entries.sort_by_key(|(key, _)| std::cmp::Reverse(key.len()));
let Some((_, effective)) = entries.first() else {
return ClientOutcome::Missing;
};
if *effective == desired {
return ClientOutcome::Converged;
}
match effective
.get("url")
.and_then(Value::as_str)
.and_then(lianjing_project_of)
{
None => ClientOutcome::ManualKept,
Some(owner) if owner == project_id => ClientOutcome::Missing,
Some(owner) => ClientOutcome::OtherProject { owner },
}
}
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行:
/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。
static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> {
MCP_OP_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌
// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址
// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址,
// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。
// 全局行只对单会话的 headless 按启动目录路由;桌面 GUI / web / acp 一个进程服务多个工作区,改由会话级插件
// (resources/dsh/,写到 %USERPROFILE%\.lian-jing\dsh\)在 agent/created 时按会话工作目录挂项目地址,
// 只对该会话可见并覆盖同名全局工具(蓝图 agent-client-parity P9)。
/// 会话级插件的文件名与托管块条目 id 带同一个版本号。插件内容变化时两者必须一起递增:DSH 只在条目 id 变化时
/// 才卸载旧条目并 import 新模块(同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM)。
/// 测试 dsh_session_plugin_source_matches_versioned_contract 钉住内容哈希,漏升版本会失败。
const DSH_SESSION_PLUGIN_FILE: &str = "lianjing-session-mcp.v1.mjs";
const DSH_SESSION_PLUGIN_SOURCE: &str =
include_str!("../resources/dsh/lianjing-session-mcp.v1.mjs");
const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp-v1";
/// 任意版本的会话级插件条目 id 前缀(判断块外手写条目用)。
const DSH_SESSION_PLUGIN_ID_PREFIX: &str = "lianjing-session-mcp";
/// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.dsh 配置,不进项目仓库)。
struct DshSessionPlugin {
module: String,
shim: String,
}
/// 写入会话级插件文件(内容不变不写),返回托管块要引用的路径。放用户目录而非 AppData,原因同稳定入口。
fn ensure_dsh_session_plugin() -> Result<DshSessionPlugin, String> {
let bin_dir = helper_bin_dir()?;
let dir = bin_dir
.parent()
.map(|lian_jing| lian_jing.join("dsh"))
.ok_or("无法定位炼境用户目录")?;
std::fs::create_dir_all(&dir).map_err(|error| format!("创建 DSH 插件目录失败: {error}"))?;
let module = dir.join(DSH_SESSION_PLUGIN_FILE);
crate::write_if_content_changed(&module, DSH_SESSION_PLUGIN_SOURCE)
.map_err(|error| format!("写入 DSH 会话级插件失败: {error}"))?;
Ok(DshSessionPlugin {
// DSH 把托管块里的绝对路径插件名转成 file URL;正斜杠写法已在 0.1/0.2 实测可加载
module: module.display().to_string().replace('\\', "/"),
shim: bin_dir.join(MCP_HEADER_SHIM_NAME).display().to_string(),
})
}
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
/// 登记的同目录判定(projects::registration_path_key)复用本函数,两处口径不得分叉。
pub(crate) fn normalize_dsh_root(path: &str) -> String {
let unified = path.replace('/', "\\");
let (prefix, rest) = match unified.strip_prefix("\\\\") {
Some(rest) => ("\\\\", rest),
None => ("", unified.as_str()),
};
let mut collapsed = String::with_capacity(rest.len());
for ch in rest.chars() {
if ch == '\\' && collapsed.ends_with('\\') {
continue;
}
collapsed.push(ch);
}
format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase()
}
/// DSH 白名单:已登记、非 WSL、根目录 context.json 的 project_id 与登记一致的 Windows 项目。
/// - 普通项目须已有项目 token(helper 能工作的前提)才进入白名单——退组或移除时 token 被删,剔除即持久;
/// - 控制面项目(mcp_scope=global)写入 None 作为停止哨兵:命中即停止向上查找,回退全局地址,
/// 不会越过它匹配到已登记的上级目录。
fn dsh_project_roots(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
exclude: Option<&str>,
) -> Vec<(String, Option<String>)> {
let rows: Vec<(String, String)> = conn
.prepare(
"SELECT id, win_path FROM project_workspaces
WHERE win_path IS NOT NULL AND COALESCE(platform, '') != 'wsl'
ORDER BY id",
)
.and_then(|mut stmt| {
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map(|rows| rows.filter_map(Result::ok).collect())
})
.unwrap_or_default();
rows.into_iter()
.filter(|(id, _)| Some(id.as_str()) != exclude)
.filter_map(|(id, win_path)| {
let root = validate_registered_path(&win_path, "win_path").ok()?;
// 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope
let context = read_context_object(
&root.join(".lian-jing").join("context.json"),
"canonical 项目上下文",
)
.ok()?;
if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) {
return None;
}
let value = if context.get("mcp_scope").and_then(Value::as_str) == Some("global") {
None
} else {
if !is_project_injected(store, &id) {
return None;
}
Some(id)
};
Some((win_path, root, value))
})
.flat_map(|(win_path, root, value)| {
// DSH GUI 会话的工作目录是 realpath;登记路径若经 junction/符号链接或 8.3 短名,两种键都写进白名单
let key = normalize_dsh_root(win_path.trim());
let canonical = std::fs::canonicalize(&root)
.ok()
.map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string())))
.filter(|canonical| canonical != &key);
std::iter::once((key, value.clone()))
.chain(canonical.map(|canonical| (canonical, value)))
})
.collect()
}
/// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。
pub(crate) fn strip_verbatim_prefix(path: &str) -> String {
match path.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(),
}
}
fn render_dsh_block(
port: u16,
roots: &[(String, Option<String>)],
session: Option<&DshSessionPlugin>,
) -> String {
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
lines
.iter()
.map(|line| format!("{}{line}", " ".repeat(spaces)))
.collect()
}
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
let roots_map: serde_json::Map<String, Value> = roots
.iter()
.map(|(root, id)| (root.clone(), json!(id)))
.collect();
let roots_json = Value::Object(roots_map).to_string();
let roots_line = format!("const roots = {roots_json};");
// 只有单会话的 headless 按启动目录路由:web/acp/tui 一个进程可服务多个工作区,按启动目录会让别的工作区
// 的会话连到该项目并带上它的认证;桌面版启动目录固定为 profile 目录。DSH 0.2 起由 profileContext.name
// 给出 profile,0.1.x 没有该服务,退回看启动参数。
let find_project_id = [
"const path = process.getBuiltinModule('path');",
roots_line.as_str(),
"let profile;",
"try { profile = ctx.get('profileContext')?.name; } catch {}",
"if (profile === undefined && process.argv.includes('headless')) profile = 'headless';",
"let id;",
"if (profile === 'headless') for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {",
" const key = d.toLowerCase().replace(/\\\\+$/, '');",
" if (Object.prototype.hasOwnProperty.call(roots, key)) { id = roots[key] || undefined; break; }",
"}",
];
let global_url = format!("http://127.0.0.1:{port}/mcp");
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}');");
let header_body = [
"if (!id) return {};",
"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');",
"const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');",
"const digest = crypto.createHash('sha256').update(id).digest('hex');",
"// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。",
"const out = cp.execFileSync(cmd, ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],",
" { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });",
"return { Authorization: String(JSON.parse(out).Authorization) };",
];
let mut lines: Vec<String> = vec![
DSH_MANAGED_START.to_string(),
"# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(),
"- insert:".into(),
" - id: mcp-lian-jing".into(),
" name: '@deepseek-ai/dsh-mcp-client'".into(),
" config:".into(),
" transport: streamable-http".into(),
format!(" serverName: {MCP_KEY}"),
" url: !!js |".into(),
" (() => {".into(),
];
lines.extend(indent(12, &find_project_id));
lines.extend(indent(12, &[url_return.as_str()]));
lines.extend(indent(10, &["})()"]));
lines.extend(indent(8, &["headers: !!js |"]));
lines.extend(indent(10, &["(() => {"]));
lines.extend(indent(12, &["try {"]));
lines.extend(indent(14, &find_project_id));
lines.extend(indent(14, &header_body));
lines.extend(indent(12, &["} catch (e) {"]));
lines.extend(indent(
14,
&[
"process.stderr.write('[lian-jing] mcp header helper failed: status=' + (e.status ?? '') + ' code=' + (e.code ?? '') + '\\n');",
"return {};",
],
));
lines.extend(indent(12, &["}"]));
lines.extend(indent(10, &["})()"]));
if let Some(session) = session {
// 路径与白名单都以 JSON 写入:JSON 字符串即合法的 YAML 双引号标量,用户名里的引号、空格等无需另行转义
lines.push(format!(" - id: {DSH_SESSION_PLUGIN_ID}"));
lines.push(format!(" name: {}", json!(session.module)));
// 插件文件被删或移走时不加载该条目(DSH 0.1.x 加载缺失的插件文件会启动失败),只留全局行
let exists_guard = format!(
"!process.getBuiltinModule('fs').existsSync({})",
json!(session.module)
);
lines.push(format!(" disabled: !!js {}", json!(exists_guard)));
lines.push(" config:".into());
lines.push(format!(" port: {port}"));
lines.push(format!(" shim: {}", json!(session.shim)));
lines.push(format!(" roots: {roots_json}"));
}
lines.push(DSH_MANAGED_END.to_string());
let mut block = lines.join("\n");
block.push('\n');
block
}
/// 用 YAML 解析判断托管块之外是否已有 lian-jing 条目(id 或 serverName,任意引号/注释/流式写法)。
/// 解析失败时保守拒绝写入,不冒险追加。
fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
if outside_block.trim().is_empty() {
return Ok(false);
}
let doc: serde_yaml::Value = serde_yaml::from_str(outside_block)
.map_err(|error| format!("DSH 补丁文件无法解析,炼境不写入: {error}"))?;
let Some(items) = doc.as_sequence() else {
return Ok(false);
};
Ok(items.iter().any(|item| {
item.get("insert")
.and_then(serde_yaml::Value::as_sequence)
.is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.get("id")
.and_then(serde_yaml::Value::as_str)
.is_some_and(|id| {
id == "mcp-lian-jing" || id.starts_with(DSH_SESSION_PLUGIN_ID_PREFIX)
})
|| entry
.get("config")
.and_then(|config| config.get("serverName"))
.and_then(serde_yaml::Value::as_str)
== Some(MCP_KEY)
})
})
}))
}
/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换;
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
const MANUAL: &str = DSH_MANUAL_MESSAGE;
match (
current.find(DSH_MANAGED_START),
current.find(DSH_MANAGED_END),
) {
(Some(start), Some(end)) if end >= start => {
let after = &current[end + DSH_MANAGED_END.len()..];
if has_manual_lian_jing(&format!("{}{}", &current[..start], after))? {
return Err(MANUAL.into());
}
Ok(format!(
"{}{}{}",
&current[..start],
block.trim_end_matches('\n'),
after
))
}
(Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()),
(None, None) if has_manual_lian_jing(current)? => Err(MANUAL.into()),
(None, None) if current.trim().is_empty() => Ok(block.to_string()),
(None, None) => Ok(format!("{}\n{}", current.trim_end(), block)),
}
}
const DSH_MANUAL_MESSAGE: &str =
"DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
/// DSH 配置目录:DSH_HOME 优先,否则 %USERPROFILE%\.dsh。
fn dsh_home() -> Result<PathBuf, String> {
std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| {
std::env::var("USERPROFILE")
.map(|home| PathBuf::from(home).join(".dsh"))
.map_err(|_| "USERPROFILE 未设置".to_string())
})
}
/// 未安装 DSH(无 ~/.dsh)时记 not_installed;块外手写 lian-jing 记 manual_kept;内容不变记 converged。
/// exclude 用于删除项目时提前剔除白名单。
pub fn ensure_dsh_patch(
conn: &rusqlite::Connection,
port: u16,
exclude: Option<&str>,
) -> Result<ClientOutcome, String> {
let home = dsh_home()?;
if !home.is_dir() {
return Ok(ClientOutcome::NotInstalled);
}
// 插件文件写失败时托管块只保留全局行(headless 仍可用),不阻断整体刷新
let session = ensure_dsh_session_plugin()
.map_err(|error| {
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!("{error};DSH 桌面版暂不能按工作区连项目地址"),
Some(&json!({ "client": "dsh" }).to_string()),
)
})
.ok();
ensure_dsh_patch_at(
conn,
&crate::credential_store::WindowsCredentialStore,
port,
exclude,
&home,
session.as_ref(),
)
}
/// ensure_dsh_patch 的依赖注入版:DSH 目录、凭据存储与会话插件引用由调用方给出(测试不碰用户 ~/.dsh)。
fn ensure_dsh_patch_at(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
port: u16,
exclude: Option<&str>,
home: &std::path::Path,
session: Option<&DshSessionPlugin>,
) -> Result<ClientOutcome, String> {
if !home.is_dir() {
return Ok(ClientOutcome::NotInstalled);
}
let path = home.join("cordis.patch.yml");
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!("读取 DSH 补丁文件失败: {error}")),
};
let block = render_dsh_block(port, &dsh_project_roots(conn, store, exclude), session);
let next = match upsert_dsh_block(&current, &block) {
Ok(next) => next,
Err(error) if error == DSH_MANUAL_MESSAGE => return Ok(ClientOutcome::ManualKept),
Err(error) => return Err(error),
};
if next == current {
return Ok(ClientOutcome::Converged);
}
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
Ok(ClientOutcome::Applied)
}
/// 只读判定 DSH 托管块是否已把本项目根目录映射到本项目、且指向当前端口(不写文件)。
fn dsh_static_outcome(
patch: &str,
root_keys: &[String],
project_id: &str,
port: u16,
) -> ClientOutcome {
let (Some(start), Some(end)) = (patch.find(DSH_MANAGED_START), patch.find(DSH_MANAGED_END))
else {
return match has_manual_lian_jing(patch) {
Ok(true) => ClientOutcome::ManualKept,
Ok(false) => ClientOutcome::Missing,
Err(reason) => ClientOutcome::Unavailable { reason },
};
};
if end < start {
// apply 遇到同样的标记错序会报 failed;静态判定如实标需人工
return ClientOutcome::NeedsHuman {
reason: "DSH 补丁文件中的炼境托管块不完整或顺序错误".into(),
};
}
// 块外手写 lian-jing 时 apply 永远保留手写、不更新块:静态判定与之同口径
let outside = format!(
"{}{}",
&patch[..start],
&patch[end + DSH_MANAGED_END.len()..]
);
match has_manual_lian_jing(&outside) {
Ok(true) => return ClientOutcome::ManualKept,
Ok(false) => {}
Err(reason) => return ClientOutcome::Unavailable { reason },
}
let block = &patch[start..end];
if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) {
return ClientOutcome::Missing;
}
let roots: Option<serde_json::Map<String, Value>> = block.lines().find_map(|line| {
let json = line
.trim()
.strip_prefix("const roots = ")?
.strip_suffix(';')?;
serde_json::from_str(json).ok()
});
let mapped = roots.is_some_and(|roots| {
root_keys
.iter()
.any(|key| roots.get(key).and_then(Value::as_str) == Some(project_id))
});
if mapped {
ClientOutcome::Converged
} else {
ClientOutcome::Missing
}
}
/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。
static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 刷新 DSH 托管块的 best-effort 包装:失败记日志并返回 failed,不中断调用方。
pub fn refresh_dsh_patch(exclude: Option<&str>) -> ClientOutcome {
let _guard = DSH_PATCH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let result = db::pool()
.get()
.map_err(|error| error.to_string())
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
let log_warn = |error: &str| {
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!("DSH 托管块刷新失败: {error}"),
Some(&json!({ "client": "dsh" }).to_string()),
)
};
match result {
Ok(ClientOutcome::ManualKept) => {
// 原为报错路径:手写条目保留后仍按原文案留痕
log_warn(DSH_MANUAL_MESSAGE);
ClientOutcome::ManualKept
}
Ok(outcome) => outcome,
Err(error) => {
log_warn(&error);
ClientOutcome::Failed { reason: error }
}
}
}
/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
let Some(claude_bin) = find_claude_bin() else {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过旧注册清理",
None,
);
return Vec::new();
};
let Some(git_bash) = find_git_bash() else {
db::log_event(
"mcp_inject",
"warn",
"git-bash 未找到,跳过旧注册清理",
None,
);
return Vec::new();
};
let Ok(conn) = db::pool().get() else {
return Vec::new();
};
let group_ids: Vec<String> = conn
.prepare("SELECT id FROM product_groups")
.and_then(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
})
.unwrap_or_default();
let mut removed = Vec::new();
for gid in &group_ids {
let mut names = vec![format!("lian-jing-{}", gid)];
if gid.len() > 8 {
names.push(format!("lian-jing-{}", &gid[..8]));
}
for name in names {
let ok = crate::silent_cmd(&claude_bin)
.args(["mcp", "remove", "-s", "user", &name])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if ok {
db::log_event(
"mcp_inject",
"info",
"旧组级 MCP 注册已移除",
Some(&json!({ "client": "claude-global", "server": &name }).to_string()),
);
removed.push(name);
}
}
}
removed
}
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
fn read_json(path: &PathBuf) -> Result<Value, String> {
if !path.exists() {
return Ok(json!({}));
}
let content =
std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?;
if content.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&content).map_err(|e| {
format!(
"settings.json 格式错误,请手动修复后重试({}): {}",
path.display(),
e
)
})
}
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
write_json_with(path, value, "settings.json", &crate::DiskSink)
}
fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, String> {
let helper_literal = serde_json::to_string(header_helper)
.map_err(|_| "MCP helper 配置序列化失败".to_string())?;
Ok(format!(
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n"
))
}
fn has_manual_codex_entry(text: &str) -> bool {
probe_l1::codex_has_table(text)
}
/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定
/// ——Codex 只在目录受信任时加载项目配置(P4-0 H-P8-1),由 L1 的 `codex mcp get` 自省给出。
fn codex_static_outcome(
current: Option<&str>,
server_url: &str,
header_helper: &str,
) -> ClientOutcome {
let Some(current) = current else {
return ClientOutcome::Missing;
};
let normalized = current.replace("\r\n", "\n");
let outside_block = match (
normalized.find(CODEX_MCP_MANAGED_START),
normalized.find(CODEX_MCP_MANAGED_END),
) {
(Some(start), Some(end)) if end >= start => format!(
"{}{}",
&normalized[..start],
&normalized[end + CODEX_MCP_MANAGED_END.len()..]
),
(None, None) => normalized.clone(),
_ => {
return ClientOutcome::NeedsHuman {
reason: "炼境 Codex 管理块不完整或顺序错误".into(),
}
}
};
let manual = has_manual_codex_entry(&outside_block);
let has_block = normalized.contains(CODEX_MCP_MANAGED_START);
match render_codex_block(server_url, header_helper) {
// 托管块与块外手写同名表并存:Codex 加载配置会因重复表失败
_ if has_block && manual => ClientOutcome::NeedsHuman {
reason: format!("项目 .codex/config.toml 托管块外另有手写 [mcp_servers.{MCP_KEY}],重复定义会使 Codex 配置加载失败"),
},
Ok(block) if normalized.contains(&block) => ClientOutcome::Converged,
_ if manual => ClientOutcome::ManualKept,
_ => ClientOutcome::Missing,
}
}
/// 写入或原位更新 Codex 托管块。内容不变记 converged;块外已手工定义同名 server 记 manual_kept(不覆盖)。
fn upsert_codex_mcp_config(
path: &PathBuf,
server_url: &str,
header_helper: &str,
) -> Result<ClientOutcome, String> {
upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink)
}
pub(crate) fn upsert_codex_mcp_config_with(
path: &std::path::Path,
server_url: &str,
header_helper: &str,
sink: &dyn crate::WriteSink,
) -> Result<ClientOutcome, String> {
let block = render_codex_block(server_url, header_helper)?;
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),
);
let next = match (start, end) {
(Some(start), Some(end)) if end >= start => {
// 块自带结尾换行:吸收 END 之后原有的空行再接上块外内容,否则每次注入都多一个空行
let tail =
current[end + CODEX_MCP_MANAGED_END.len()..].trim_start_matches(['\r', '\n']);
let separator = if tail.is_empty() { "" } else { "\n" };
format!("{}{block}{separator}{tail}", &current[..start])
}
(Some(_), _) | (_, Some(_)) => {
return Err(format!(
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
path.display()
))
}
// 已手工定义同名 server:炼境不覆盖(原为整体报错,会连带阻断 Claude 与 DSH 的接入)
(None, None) if has_manual_codex_entry(&current) => return Ok(ClientOutcome::ManualKept),
(None, None) if current.trim().is_empty() => block,
(None, None) => format!("{}\n{}", current.trim_end(), block),
};
sink.write(path, &next)
.map(|written| {
if written {
ClientOutcome::Applied
} else {
ClientOutcome::Converged
}
})
.map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
}
/// 条目 URL 指向别的炼境项目路由:同路径重复登记时,删除一条登记不得连带清掉另一条的接入。
/// 不是炼境项目路由的旧地址(全局 /mcp、组级地址)视为本项目遗留,照旧清理。
fn owned_by_other_project(url: &str, project_id: &str) -> bool {
lianjing_project_of(url).is_some_and(|owner| owner != project_id)
}
fn remove_codex_mcp_config(path: &PathBuf, project_id: &str) -> Result<(), String> {
let current = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")),
};
let (Some(start), Some(end)) = (
current.find(CODEX_MCP_MANAGED_START),
current.find(CODEX_MCP_MANAGED_END),
) else {
return Ok(());
};
if end < start {
return Err(format!("{} 的炼境 Codex 管理块不完整", path.display()));
}
let block_url = current[start..end]
.lines()
.find_map(|line| line.trim().strip_prefix("url = \"")?.strip_suffix('"'));
if block_url.is_some_and(|url| owned_by_other_project(url, project_id)) {
return Ok(());
}
let next = format!(
"{}{}",
&current[..start],
&current[end + CODEX_MCP_MANAGED_END.len()..]
)
.trim_matches(['\r', '\n'])
.to_string();
let output = if next.is_empty() {
String::new()
} else {
format!("{next}\n")
};
std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}"))
}
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目注入单一 lian-jing MCP 配置(canonical context、Codex 托管块、Claude 兼容投影与 local 条目、
/// DSH 白名单),并清理旧组级 key(迁移内建于重注入)。只有 Codex 托管块写入失败算整体失败(沿用旧语义),
/// Claude 与 DSH 一直是 best-effort;需要逐端结果时用 inject_project_mcp_report。
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
inject_project_mcp_report(project_id).and_then(|reports| match hard_failure(&reports) {
Some(error) => Err(error),
None => Ok(()),
})
}
/// 注入并返回三端逐端结果(agent-client-parity P8b)。
pub fn inject_project_mcp_report(project_id: &str) -> Result<Vec<ClientReport>, String> {
let _guard = mcp_op_lock();
inject_project_mcp_locked(project_id, true)
}
/// 项目文件里的 Codex 托管块写入失败沿用旧语义作为整体失败。
fn hard_failure(reports: &[ClientReport]) -> Option<String> {
reports
.iter()
.find_map(|report| match (report.client, &report.outcome) {
(ClientId::Codex, ClientOutcome::Failed { reason }) => Some(reason.clone()),
_ => None,
})
}
/// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,
/// 由调用方在末尾统一刷新一次。项目身份与兼容投影写失败整体报错;三端客户端配置经适配器注册表逐端收敛,
/// 一端失败不中断其他端。
fn inject_project_mcp_locked(
project_id: &str,
refresh_dsh: bool,
) -> Result<Vec<ClientReport>, String> {
let port = crate::mcp_server::read_port();
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
crate::credential_store::get_or_create_mcp_auth_token(
&crate::credential_store::WindowsCredentialStore,
project_id,
)?;
ensure_header_shim()?;
let header_helper = shim_helper_command(&digest);
let root = project_root(project_id)?;
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?;
let facts = project_facts(project_id, &root);
let desired = DesiredMcp {
port,
server_url,
header_helper,
refresh_dsh,
};
let reports: Vec<ClientReport> = crate::onboard::clients::registry()
.iter()
.map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired)))
.collect();
// 诊断按 project_id 取最后一条 mcp_inject 事件:成功日志须在三端收敛之后、且只在没有整体失败时写
let ctx = json!({ "project_id": project_id }).to_string();
match hard_failure(&reports) {
None => log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)),
Some(error) => log_event(
"mcp_inject",
"warn",
&format!("注入失败: {error}"),
Some(&ctx),
),
}
Ok(reports)
}
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
ProjectFacts {
project_id,
root,
control_plane: project_wants_global_scope(root),
wsl: crate::onboard::clients::is_wsl_root(root),
}
}
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
/// 根目录与注入同源(project_root 校验登记路径),避免两边对同一项目算出不同的键。
pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
let port = crate::mcp_server::read_port();
let resolved = crate::credential_store::mcp_auth_digest(project_id)
.and_then(|digest| project_root(project_id).map(|root| (digest, root)));
let (digest, root) = match resolved {
Ok(resolved) => resolved,
Err(reason) => {
return crate::onboard::clients::registry()
.iter()
.map(|adapter| {
ClientReport::new(
adapter.id(),
ClientOutcome::Unavailable {
reason: reason.clone(),
},
)
})
.collect()
}
};
let header_helper = shim_helper_command(&digest);
let facts = project_facts(project_id, &root);
let desired = DesiredMcp {
port,
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
header_helper,
refresh_dsh: false,
};
crate::onboard::clients::registry()
.iter()
.map(|adapter| ClientReport::new(adapter.id(), adapter.static_status(&facts, &desired)))
.collect()
}
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
/// 一键接入写入前计划(只读:不写文件、不起客户端 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,
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
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),
codex_trust(&root),
))
}
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
#[allow(dead_code)]
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
let mut steps = vec![
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) {
let name = match report.client {
ClientId::Claude => "claude",
ClientId::Codex => "codex",
ClientId::Dsh => "dsh",
};
steps.push(crate::onboard::plan::MachineStep {
step: name.into(),
status: outcome_status(&report.outcome).into(),
detail: report.outcome.describe(),
});
}
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()) {
(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 配置目录(CODEX_HOME 或 ~/.codex)。
fn codex_home() -> Option<PathBuf> {
std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.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<PathBuf> {
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, 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<String> {
// None:不在 projects 段;Some(None):[projects];Some(Some(key)):[projects.<key>]
let mut section: Option<Option<String>> = None;
let mut trusted = Vec::new();
for line in config.lines().map(str::trim) {
if line.starts_with('[') {
section = parse_projects_header(line);
continue;
}
match &section {
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));
}
}
_ => {}
}
}
trusted
}
/// `[projects]` → Some(None);`[projects.<键>]` → Some(Some(键));其他段(含数组表)→ None。
fn parse_projects_header(line: &str) -> Option<Option<String>> {
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::<Option<String>>()?;
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)──────────────────────
pub(crate) struct ClaudeAdapter;
pub(crate) struct CodexAdapter;
pub(crate) struct DshAdapter;
pub(crate) static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter;
pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter;
const NA_CONTROL_PLANE: ClientOutcome = ClientOutcome::NotApplicable {
reason: NotApplicableReason::ControlPlane,
};
const NA_WSL: ClientOutcome = ClientOutcome::NotApplicable {
reason: NotApplicableReason::Wsl,
};
impl ClientAdapter for ClaudeAdapter {
fn id(&self) -> ClientId {
ClientId::Claude
}
/// ~/.claude.json(或 CLAUDE_CONFIG_DIR 下的配置)存在即视为已装;不起子进程。
fn detect(&self) -> bool {
claude_config_path().is_some_and(|path| path.is_file())
}
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
if project.wsl {
return NA_WSL;
}
if !self.detect() {
return ClientOutcome::NotInstalled;
}
let Some(config) = read_claude_config() else {
return ClientOutcome::Unavailable {
reason: "无法读取 Claude 全局配置".into(),
};
};
if project.control_plane {
return claude_control_plane_outcome(&config, project.project_id, project.root);
}
let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper);
claude_static_outcome(&config, project.project_id, project.root, &entry)
}
/// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
/// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
/// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
try_register_globally(desired.port);
if project.wsl {
return NA_WSL;
}
// 未装 Claude(无全局配置、也找不到 CLI)与 DSH 未装同口径记 not_installed,不计失败
if !self.detect() && find_claude_bin().is_none() {
return ClientOutcome::NotInstalled;
}
let wanted = (!project.control_plane)
.then(|| (desired.server_url.clone(), desired.header_helper.clone()));
finalize_for_project(
project,
sync_claude_local(project.project_id, project.root, wanted),
)
}
/// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
let not_applicable = l1_not_applicable(project);
let entries = match not_applicable {
Some(_) => Vec::new(),
None => {
let mcp_json = read_optional(&project.root.join(".mcp.json"))
.ok()
.flatten()
.and_then(|text| {
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok()
});
claude_l1_entries(
&read_claude_config().unwrap_or(Value::Null),
mcp_json.as_ref(),
project.root,
&desired.server_url,
)
}
};
L1ClientFacts {
client: ClientId::Claude,
not_applicable,
rules: probe_l1::rules_finding(project.root, "CLAUDE.md", true),
skill: probe_l1::skill_finding(project.root, ".claude/skills/lian-jing/SKILL.md"),
source: L1Source::Claude { entries },
}
}
}
/// Claude 与 DSH 的 L1 不适用情形(与各自 static_status 同口径):WSL 路径项目不支持;控制面项目走全局地址。
fn l1_not_applicable(project: &ProjectFacts) -> Option<NotApplicableReason> {
if project.wsl {
Some(NotApplicableReason::Wsl)
} else if project.control_plane {
Some(NotApplicableReason::ControlPlane)
} else {
None
}
}
/// L1 回查 headersHelper 的候选条目:URL 等于本项目地址的 lian-jing 条目(含手写),按 URL 匹配、不复刻
/// Claude 的项目键推导(与 managed_claude_entries 同一原则:worktree 映射到主仓库根、NFC 归一化的键也能找到)。
/// 顺序:覆盖本根目录的项目键(最具体在前)→ 其余项目键 → 项目 .mcp.json(project 作用域)→ 顶层 mcpServers
/// (user 作用域)。生效哪一条由 claude mcp get 报告的作用域决定,顺序只是兜底。
fn claude_l1_entries(
config: &Value,
mcp_json: Option<&Value>,
root: &std::path::Path,
url: &str,
) -> Vec<probe_l1::ClaudeEntry> {
use probe_l1::{ClaudeEntry, ClaudeEntrySource};
let root_text = root.display().to_string();
let lian_jing = |servers: Option<&Value>| {
servers
.and_then(|servers| servers.get(MCP_KEY))
.filter(|entry| entry.get("url").and_then(Value::as_str) == Some(url))
.cloned()
};
// (覆盖本根目录, 键长度) 降序:覆盖者在前、越具体越前
let mut local: Vec<((bool, usize), Value)> = config
.get("projects")
.and_then(Value::as_object)
.map(|projects| {
projects
.iter()
.filter_map(|(key, project)| {
let entry = lian_jing(project.get("mcpServers"))?;
let rank = (
key_covers_root(key, &root_text),
normalize_root_key(key).len(),
);
Some((rank, entry))
})
.collect()
})
.unwrap_or_default();
local.sort_by(|(left, _), (right, _)| right.cmp(left));
local
.into_iter()
.map(|(_, entry)| ClaudeEntry {
source: ClaudeEntrySource::Local,
entry,
})
.chain(
lian_jing(mcp_json.and_then(|json| json.get("mcpServers"))).map(|entry| ClaudeEntry {
source: ClaudeEntrySource::Project,
entry,
}),
)
.chain(
lian_jing(config.get("mcpServers")).map(|entry| ClaudeEntry {
source: ClaudeEntrySource::User,
entry,
}),
)
.collect()
}
/// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理),
/// 有残留报 missing,没有才是 not_applicable。
fn claude_control_plane_outcome(
config: &Value,
project_id: &str,
root: &std::path::Path,
) -> ClientOutcome {
let root_text = root.display().to_string();
let residual = managed_claude_entries(config, project_id)
.iter()
.any(|(key, _)| key_covers_root(key, &root_text));
if residual {
ClientOutcome::Missing
} else {
NA_CONTROL_PLANE
}
}
/// 机器级或按边界保留的成功结果,在控制面 / WSL 项目上报 not_applicable;未成功的结果原样保留。
fn finalize_for_project(project: &ProjectFacts, outcome: ClientOutcome) -> ClientOutcome {
match (project.control_plane, project.wsl, outcome.is_settled()) {
(true, _, true) => NA_CONTROL_PLANE,
(_, true, true) => NA_WSL,
_ => outcome,
}
}
impl ClientAdapter for CodexAdapter {
fn id(&self) -> ClientId {
ClientId::Codex
}
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。
fn detect(&self) -> bool {
codex_home().is_some_and(|dir| dir.is_dir())
}
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
if !self.detect() {
return ClientOutcome::NotInstalled;
}
let current = match read_optional(&project.root.join(".codex").join("config.toml")) {
Ok(current) => current,
Err(reason) => return ClientOutcome::Unavailable { reason },
};
codex_static_outcome(
current.as_deref(),
&desired.server_url,
&desired.header_helper,
)
}
/// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
let path = project.root.join(".codex").join("config.toml");
let outcome = upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper)
.unwrap_or_else(|reason| ClientOutcome::Failed { reason });
if outcome == ClientOutcome::ManualKept {
// 原为整体报错(调用方记日志);改为保留后仍留痕,诊断里看得到
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!(
"{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖",
path.display()
),
Some(&inject_log_context(project.project_id, "codex")),
);
}
outcome
}
/// 生效配置以 codex mcp get --json 为准(L1 执行时取);它把 helper 脱敏,helper 只能回查项目配置。
fn l1_facts(&self, project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts {
let config = read_optional(&project.root.join(".codex").join("config.toml"))
.ok()
.flatten()
.unwrap_or_default();
L1ClientFacts {
client: ClientId::Codex,
// 控制面与 WSL 项目的 Codex 同样写项目地址(P8b),照常检查
not_applicable: None,
rules: probe_l1::rules_finding(project.root, "AGENTS.md", false),
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
source: L1Source::Codex {
project_entry: has_manual_codex_entry(&config),
project_url: probe_l1::codex_table_url(&config),
project_helper: probe_l1::codex_table_helper(&config),
},
}
}
}
/// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。
fn read_optional(path: &std::path::Path) -> Result<Option<String>, String> {
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(format!("读取 {} 失败: {error}", path.display())),
}
}
impl ClientAdapter for DshAdapter {
fn id(&self) -> ClientId {
ClientId::Dsh
}
fn detect(&self) -> bool {
dsh_home().is_ok_and(|home| home.is_dir())
}
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
if project.control_plane {
return NA_CONTROL_PLANE;
}
if project.wsl {
return NA_WSL;
}
let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else {
return ClientOutcome::NotInstalled;
};
let patch = match read_optional(&home.join("cordis.patch.yml")) {
Ok(patch) => patch.unwrap_or_default(),
Err(reason) => return ClientOutcome::Unavailable { reason },
};
let key = normalize_dsh_root(&project.root.display().to_string());
let canonical = std::fs::canonicalize(project.root)
.ok()
.map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string())));
let keys: Vec<String> = std::iter::once(key).chain(canonical).collect();
dsh_static_outcome(&patch, &keys, project.project_id, desired.port)
}
/// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
if !desired.refresh_dsh {
return ClientOutcome::Deferred;
}
finalize_for_project(project, refresh_dsh_patch(None))
}
/// DSH 无零模型自省入口:白名单复用 static_status,另核对会话级插件;helper 按托管块 !!js 的方式执行。
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
let not_applicable = l1_not_applicable(project);
let (whitelist, session_plugin) = match not_applicable {
Some(reason) => (
ClientOutcome::NotApplicable { reason },
StaticFinding {
ok: true,
detail: "不适用".into(),
},
),
None => (
self.static_status(project, desired),
dsh_session_plugin_finding(),
),
};
L1ClientFacts {
client: ClientId::Dsh,
not_applicable,
rules: probe_l1::rules_finding(project.root, "AGENTS.md", false),
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
source: L1Source::Dsh {
whitelist,
session_plugin,
// 与托管块 !!js 同源:%USERPROFILE%\.lian-jing\bin 下的稳定入口 + sha256(project_id)
helper: HelperCommand::DshShim {
shim: helper_bin_dir()
.map(|dir| dir.join(MCP_HEADER_SHIM_NAME))
.unwrap_or_else(|_| PathBuf::from(MCP_HEADER_SHIM_NAME)),
digest: crate::credential_store::mcp_auth_digest(project.project_id)
.unwrap_or_default(),
},
},
}
}
}
/// DSH 会话级插件(P9,桌面 GUI 按工作区连项目地址)的完整性:托管块引用了当前版本的插件条目,
/// 且插件文件与炼境内置版本的 sha256 一致(忽略行尾)。只读。
fn dsh_session_plugin_finding() -> StaticFinding {
use sha2::Digest;
let hash = |text: &str| -> String {
sha2::Sha256::digest(text.replace("\r\n", "\n").as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
};
let patch = dsh_home()
.ok()
.and_then(|home| read_optional(&home.join("cordis.patch.yml")).ok().flatten())
.unwrap_or_default();
if !patch.contains(&format!("- id: {DSH_SESSION_PLUGIN_ID}")) {
return StaticFinding {
ok: false,
detail: format!("DSH 托管块未引用会话级插件 {DSH_SESSION_PLUGIN_ID};重跑 inject_mcp"),
};
}
let expected = hash(DSH_SESSION_PLUGIN_SOURCE);
let path = helper_bin_dir().ok().and_then(|bin| {
bin.parent()
.map(|dir| dir.join("dsh").join(DSH_SESSION_PLUGIN_FILE))
});
match path
.as_ref()
.map(|path| (path, std::fs::read_to_string(path)))
{
Some((_, Ok(content))) if hash(&content) == expected => StaticFinding {
ok: true,
detail: format!(
"会话级插件 {DSH_SESSION_PLUGIN_FILE} 与炼境内置版本一致(sha256 {}…)",
&expected[..12]
),
},
Some((path, Ok(_))) => StaticFinding {
ok: false,
detail: format!(
"会话级插件 {} 与炼境内置版本不一致;重启炼境或重跑 inject_mcp",
path.display()
),
},
_ => StaticFinding {
ok: false,
detail: "会话级插件文件缺失;重启炼境或重跑 inject_mcp".into(),
},
}
}
// ── L1 零模型三端检查(P4a):本体入口与本机客户端定位 ─────────────────────────
/// 三端对等 L1 零模型检查的本体入口(MCP probe_client_parity 与 Tauri command 共用,边界只在这里)。
/// 只读三端配置,起零模型的客户端自省进程,经稳定 shim 取认证头后在项目地址上重放(认证头不出本进程)。
pub fn probe_client_parity(
project_id: &str,
tier: Option<&str>,
) -> Result<probe_l1::L1Report, String> {
match tier.unwrap_or("l1") {
"l1" => {}
other => {
return Err(format!(
"tier={other} 不支持:目前只有 l1(零模型检查);L3 无头会话由后续 start_parity_probe 提供"
))
}
}
let root = project_root(project_id)?;
probe_client_parity_at(project_id, &root, crate::mcp_server::read_port())
}
/// probe_client_parity 去掉登记查询的部分(根目录与端口由调用方给出;实机冒烟测试直接调用)。
fn probe_client_parity_at(
project_id: &str,
root: &std::path::Path,
port: u16,
) -> Result<probe_l1::L1Report, String> {
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
let facts = project_facts(project_id, root);
let desired = DesiredMcp {
port,
server_url: server_url.clone(),
header_helper: shim_helper_command(&digest),
refresh_dsh: false,
};
let clients: Vec<L1ClientFacts> = crate::onboard::clients::registry()
.iter()
.map(|adapter| adapter.l1_facts(&facts, &desired))
.collect();
let expected_tools = crate::mcp_tools::tools_list_result_for_project(project_id)["tools"]
.as_array()
.map(|tools| {
tools
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let project = probe_l1::L1Project {
project_id,
root,
control_plane: facts.control_plane,
expected_url: server_url,
expected_tools,
managed_helpers: vec![
shim_helper_command(&digest),
legacy_shim_helper_command(&digest),
],
};
let env = |name: &str| std::env::var(name).ok();
let deps = probe_l1::L1Deps {
runner: &probe_l1::SystemRunner,
http: &probe_l1::UreqHttp,
locator: &SystemLocator,
env: &env,
budget: probe_l1::L1_BUDGET,
};
// 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连项目地址,服务端无法与
// 真实会话区分:检查期间本项目的 initialize 不计入「真实客户端已连接」,免得抹掉 reload_required 证据(审查 ③)
let _quiet = crate::mcp_server::quiet_project_initializes(project_id);
Ok(probe_l1::run_l1(&project, &clients, &deps))
}
/// 本机客户端定位(R15:客户端私有安装位置集中在这里,格式变化归因 adapter_drift)。
struct SystemLocator;
impl probe_l1::ClientLocator for SystemLocator {
fn locate(&self, client: ClientId) -> Option<Located> {
match client {
ClientId::Claude => find_claude_bin().map(|bin| Located {
program: Some(PathBuf::from(bin)),
version: None,
}),
ClientId::Codex => find_codex_bin().map(|bin| Located {
program: Some(bin),
version: None,
}),
ClientId::Dsh => DSH_ADAPTER.detect().then(|| Located {
program: None,
version: dsh_install_dir().and_then(|dir| {
probe_l1::read_asar_package_version(&dir.join("resources").join("app.asar"))
}),
}),
}
}
fn git_bash(&self) -> Option<PathBuf> {
find_git_bash().map(PathBuf::from)
}
}
/// codex 可执行文件:CODEX_BIN → Codex 桌面版自带的 %LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe
/// (本机可能有多个哈希目录,取最新)→ PATH 中的 codex.exe。npm 全局安装的 codex.cmd 不在此列。
fn find_codex_bin() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("CODEX_BIN")
.map(PathBuf::from)
.filter(|path| path.is_file())
{
return Some(path);
}
let bundled = std::env::var_os("LOCALAPPDATA")
.map(|base| PathBuf::from(base).join("OpenAI").join("Codex").join("bin"))
.and_then(|bin| std::fs::read_dir(bin).ok())
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path().join("codex.exe"))
.filter(|exe| exe.is_file())
.max_by_key(|exe| exe.metadata().and_then(|meta| meta.modified()).ok());
if bundled.is_some() {
return bundled;
}
let output = crate::silent_cmd("where").arg("codex.exe").output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|line| PathBuf::from(line.trim()))
.filter(|path| path.is_file())
}
/// DSH 桌面版安装目录(读版本用):DSH_INSTALL_DIR → %LOCALAPPDATA%\Programs\DeepSeek Harness。
fn dsh_install_dir() -> Option<PathBuf> {
std::env::var_os("DSH_INSTALL_DIR")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("LOCALAPPDATA").map(|base| {
PathBuf::from(base)
.join("Programs")
.join("DeepSeek Harness")
})
})
.filter(|dir| dir.is_dir())
}
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
let _guard = mcp_op_lock();
// 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
if let Ok(root) = project_root(project_id) {
sync_claude_local(project_id, &root, None);
}
// token 最先删:DSH 白名单剔除与根目录迁移判定都以 token 为准,后面的文件清理即使失败也不影响这一点
let token_removed = crate::credential_store::delete_mcp_auth_token_for_project(
&crate::credential_store::WindowsCredentialStore,
project_id,
);
// 登记此时仍在,exclude 立即剔除;token 已删,之后的刷新也不会再把它加回白名单
refresh_dsh_patch(Some(project_id));
let mut errors: Vec<String> = Vec::new();
if let Err(error) = remove_project_files_mcp(project_id) {
errors.push(error);
}
if let Err(error) = token_removed {
errors.push(error);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(";"))
}
}
/// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。
fn remove_project_files_mcp(project_id: &str) -> Result<(), String> {
remove_codex_mcp_config(&codex_config_path(project_id)?, project_id)?;
remove_settings_mcp_entry(&settings_path(project_id)?, project_id)
}
fn remove_settings_mcp_entry(path: &PathBuf, project_id: &str) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let mut settings = read_json(path)?;
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
let foreign = servers
.get(MCP_KEY)
.and_then(|entry| entry.get("url"))
.and_then(Value::as_str)
.is_some_and(|url| owned_by_other_project(url, project_id));
if !foreign {
servers.remove(MCP_KEY);
}
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(path, &settings)
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
/// 并清理旧组级全局注册。settings 表 mcp_single_migrated 标记位保证只执行一次;
/// 部分项目失败不阻塞(记日志,可对单个项目手动重注入补救)。
pub fn migrate_to_single_server() {
let already: Option<String> = db::pool().get().ok().and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'mcp_single_migrated'",
[],
|r| r.get(0),
)
.ok()
});
if already.as_deref() == Some("1") {
return;
}
let errors = reinject_all_mcp();
let removed = cleanup_legacy_global_registrations();
let register_ok = try_register_globally(crate::mcp_server::read_port());
let summary = format!(
"单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条,全局注册 {}{}",
errors.len(),
removed.len(),
if register_ok { "成功" } else { "失败" },
if errors.is_empty() {
String::new()
} else {
format!(";失败详情: {}", errors.join("; "))
}
);
let level = if errors.is_empty() && register_ok {
"info"
} else {
"warn"
};
db::log_event("mcp_inject", level, &summary, None);
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。
// try_register_globally 在已注册时会只读早返回,不再证明 CLI 可用,所以另外确认 CLI 与 git-bash 都在。
let cli_available = find_claude_bin().is_some() && find_git_bash().is_some();
if !register_ok || !cli_available {
return;
}
if let Ok(conn) = db::pool().get() {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')",
[],
);
}
}
/// 「已接入」的单一判定:本机受保护存储里有该项目的 MCP 认证 token。
/// token 由注入创建、由 remove_project_mcp 最先删除;DSH 白名单、根目录迁移、批量重注入与
/// list_all_projects 都以此为准(P8a)。项目里的 .claude/liangjing.json 只是兼容投影,可能残留或缺失,不作判据。
pub(crate) fn is_project_injected(
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
) -> bool {
crate::credential_store::mcp_auth_digest(project_id)
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
.is_ok()
}
/// 批量重注入的目标:产品组成员(历史上入组即注入),加上已接入(有 token)但不在任何组的登记项目。
/// 后者此前被漏掉——独立接入的项目在端口变更后不会被重注入,项目配置仍指向旧端口。
fn reinject_targets(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
) -> Result<Vec<String>, String> {
let query_ids = |sql: &str| -> Result<Vec<String>, String> {
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
let ids = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?
.filter_map(Result::ok)
.collect();
Ok(ids)
};
let mut targets: std::collections::BTreeSet<String> =
query_ids("SELECT DISTINCT project_id FROM project_group_map")?
.into_iter()
.collect();
for id in query_ids("SELECT id FROM project_workspaces")? {
if is_project_injected(store, &id) {
targets.insert(id);
}
}
Ok(targets.into_iter().collect())
}
/// 锁内复查:目标快照取自锁外,期间被移出组且 token 已删的项目不再注入(避免重建刚删除的 token)。
fn is_reinject_target(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
) -> Result<bool, String> {
let member = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)",
[project_id],
|row| row.get::<_, bool>(0),
)
.map_err(|error| error.to_string())?;
Ok(member || is_project_injected(store, project_id))
}
/// 端口变更后重新注入所有已接入项目(产品组成员 + 有 token 的独立项目)的 MCP 配置
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
let store = crate::credential_store::WindowsCredentialStore;
let project_ids = match db::pool()
.get()
.map_err(|e| e.to_string())
.and_then(|conn| reinject_targets(&conn, &store))
{
Ok(ids) => ids,
Err(e) => return vec![e],
};
let mut errors = Vec::new();
for project_id in project_ids {
let _guard = mcp_op_lock();
let still_target = db::pool()
.get()
.map_err(|error| error.to_string())
.and_then(|conn| is_reinject_target(&conn, &store, &project_id));
match still_target {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
errors.push(format!("项目 {project_id}: 复查接入状态失败: {error}"));
continue;
}
}
let result = inject_project_mcp_locked(&project_id, false)
.and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err));
if let Err(e) = result {
errors.push(format!("项目 {}: {}", project_id, e));
}
}
// 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志
refresh_dsh_patch(None);
errors
}
/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude
/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),
/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
/// 移除先完成则 token 已不在,这里就不会再注入。
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
let _guard = mcp_op_lock();
if let Some(root) = old_root {
sync_claude_local(project_id, &root, None);
}
if !is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) {
refresh_dsh_patch(None);
return;
}
let result = inject_project_mcp_locked(project_id, true)
.and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err));
if let Err(error) = result {
// 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次
db::log_event(
"mcp_inject",
"warn",
&format!("项目根目录变更后重新注入失败: {error}"),
Some(&inject_log_context(project_id, "all")),
);
refresh_dsh_patch(None);
}
}
/// 纯函数:update_project 后是否需要迁移客户端配置。只要能解析出新根且与旧根不同就迁移;
/// 旧根解析不出(例如旧路径为空)也照样迁移,只是没有旧条目要清理。
pub(crate) fn project_root_changed(old_root: Option<&PathBuf>, new_root: Option<&PathBuf>) -> bool {
new_root.is_some() && new_root != old_root
}
#[cfg(test)]
mod tests {
use super::*;
/// 回归:空路径登记曾使 inject 链路把 settings.json/liangjing.json 写进
/// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。
#[test]
fn registered_path_rejects_empty_and_relative() {
assert!(
validate_registered_path("", "win_path").is_err(),
"空串应拒绝"
);
assert!(
validate_registered_path(" ", "win_path").is_err(),
"空白应拒绝"
);
assert!(
validate_registered_path("src-tauri", "win_path").is_err(),
"相对路径应拒绝(相对炼境 cwd 解析)"
);
}
/// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行),
/// 且内容不变也会用 LF 重写 CRLF 检出的文件。
/// 回归:Claude Code 改写 settings.json 后键序与末尾换行不同,语义未变时注入不应整文件重排。
#[test]
fn json_writes_skip_semantically_equal_files() {
let base = std::env::temp_dir().join(format!("lianjing_json_idem_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let path = base.join("settings.json");
let client_written = "{\r\n \"permissions\": {\"allow\": []},\r\n \"mcpServers\": {\"lian-jing\": {\"type\": \"sse\", \"url\": \"u\"}}\r\n}\r\n";
std::fs::write(&path, client_written).unwrap();
let value: Value = serde_json::from_str(client_written).unwrap();
write_json(&path, &value).unwrap();
write_json_with(&path, &value, "settings.json", &crate::DiskSink).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
client_written,
"语义相同,保留客户端的键序、行尾与末尾换行"
);
let mut changed = value.clone();
changed["mcpServers"]["lian-jing"]["url"] = json!("u2");
write_json(&path, &changed).unwrap();
let written: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written, changed, "语义变化时照常写入");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() {
let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
let path = base.join(".codex/config.toml");
let url = "http://127.0.0.1:27190/mcp/projects/p1";
let helper = shim_helper_command("d1");
// 块在末尾、END 之后堆了 15 个空行(CRLF 检出)
let block = format!(
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{url}\"\nhttp_headers_helper = {}\n{CODEX_MCP_MANAGED_END}",
serde_json::to_string(&helper).unwrap()
);
let accumulated =
format!("model = \"gpt-6\"\n{block}{}", "\n".repeat(16)).replace('\n', "\r\n");
std::fs::write(&path, &accumulated).unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
let cleaned = std::fs::read_to_string(&path).unwrap();
assert!(
cleaned.ends_with(&format!("{CODEX_MCP_MANAGED_END}\n")),
"END 之后的累积空行被吸收"
);
// 再注入一次:内容与字节都不变
let before = std::fs::read(&path).unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), before);
// 块在中间:块外后续内容保留,并且重复注入稳定
std::fs::write(
&path,
format!("{cleaned}\n\n\n[profiles.x]\nmodel = \"y\"\n"),
)
.unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
let middle = std::fs::read_to_string(&path).unwrap();
assert!(middle.contains(&format!(
"{CODEX_MCP_MANAGED_END}\n\n[profiles.x]\nmodel = \"y\"\n"
)));
upsert_codex_mcp_config(&path, url, &helper).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), middle);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
let base =
std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
let path = base.join(".codex/config.toml");
std::fs::write(&path, "model = \"gpt-6\"\n").unwrap();
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp", helper).unwrap();
let configured = std::fs::read_to_string(&path).unwrap();
assert!(configured.contains("model = \"gpt-6\""));
assert!(configured.contains("[mcp_servers.lian-jing]"));
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
assert!(configured.contains("http_headers_helper"));
assert!(!configured.contains("qa-only-synthetic-mcp-token"));
upsert_codex_mcp_config(
&path,
"http://127.0.0.1:27190/mcp/projects/project%20one",
helper,
)
.unwrap();
let migrated = std::fs::read_to_string(&path).unwrap();
assert!(migrated.contains("model = \"gpt-6\""));
assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\""));
assert!(!migrated.contains("url = \"http://127.0.0.1:27190/mcp\"\n"));
remove_codex_mcp_config(&path, "project one").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"model = \"gpt-6\"\n"
);
let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n";
std::fs::write(&path, manual).unwrap();
// 手写同名 server:记 manual_kept 不覆盖(P8b 起不再整体报错,不连带阻断 Claude 与 DSH)
assert_eq!(
upsert_codex_mcp_config(
&path,
"http://127.0.0.1:27190/mcp/projects/project%20one",
helper,
),
Ok(ClientOutcome::ManualKept)
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
let _ = std::fs::remove_dir_all(&base);
}
/// 2026-10-03 真机:同一目录登记了 dev-manager-tauri 与 2c5b54ef 两条,删 UUID 那条会把
/// 指向规范 ID 的 Codex 管理块一并删掉。删除只清 URL 属于本项目(或旧格式)的条目。
#[test]
fn remove_keeps_entries_owned_by_another_project_at_same_root() {
let base = std::env::temp_dir().join(format!(
"lianjing_remove_scoped_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
std::fs::create_dir_all(base.join(".claude")).unwrap();
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
let codex = base.join(".codex/config.toml");
let other_url = "http://127.0.0.1:27190/mcp/projects/canonical-id";
std::fs::write(&codex, "model = \"gpt-6\"\n").unwrap();
upsert_codex_mcp_config(&codex, other_url, helper).unwrap();
let before = std::fs::read_to_string(&codex).unwrap();
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
before,
"别的项目的 Codex 管理块必须原样保留"
);
remove_codex_mcp_config(&codex, "canonical-id").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
"model = \"gpt-6\"\n"
);
// 旧格式全局地址不属于任何项目路由,视为遗留照旧清理
upsert_codex_mcp_config(&codex, "http://127.0.0.1:27190/mcp", helper).unwrap();
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
"model = \"gpt-6\"\n"
);
let settings = base.join(".claude/settings.json");
let write_settings = |url: &str| {
std::fs::write(
&settings,
json!({ "mcpServers": {
MCP_KEY: { "type": "http", "url": url },
"lian-jing:group-old": { "type": "http", "url": "http://127.0.0.1:27190/mcp/group/g" },
"other": { "command": "x" }
} })
.to_string(),
)
.unwrap();
};
let servers = || read_json(&settings).unwrap()["mcpServers"].clone();
write_settings(other_url);
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
let kept = servers();
assert_eq!(
kept[MCP_KEY]["url"], other_url,
"别的项目的 settings 条目必须保留"
);
assert!(
kept.get("lian-jing:group-old").is_none(),
"旧组级 key 照旧清理"
);
assert!(kept.get("other").is_some());
write_settings("http://127.0.0.1:27190/mcp/projects/duplicate-uuid");
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
let removed = servers();
assert!(removed.get(MCP_KEY).is_none());
assert!(removed.get("other").is_some());
let _ = std::fs::remove_dir_all(&base);
}
// ── P8b:三端结构化结果 ──
fn claude_get_local(url: &str) -> String {
format!("lian-jing:\n Scope: Local config (private to you in this project)\n Type: http\n URL: {url}\n")
}
/// 假 claude CLI:按子命令给出预设结果并记录调用;resolve 被调用时返回它。
struct FakeClaude {
get: String,
remove_ok: bool,
add_ok: bool,
calls: std::cell::RefCell<Vec<String>>,
}
impl FakeClaude {
fn new(get: &str, remove_ok: bool, add_ok: bool) -> Self {
Self {
get: get.into(),
remove_ok,
add_ok,
calls: Default::default(),
}
}
fn run(&self, args: &[&str]) -> Result<String, String> {
self.calls.borrow_mut().push(args[1].to_string());
match args[1] {
"get" => Ok(self.get.clone()),
"remove" if self.remove_ok => Ok(String::new()),
"add-json" if self.add_ok => Ok(String::new()),
other => Err(format!("{other} failed")),
}
}
}
fn sync_with_fake(
project_id: &str,
root: &std::path::Path,
desired: Option<(String, String)>,
config: Option<&Value>,
fake: Option<&FakeClaude>,
) -> ClientOutcome {
sync_claude_local_with(project_id, root, desired, config, &|| {
fake.map(|fake| {
let cli: ClaudeCli = Box::new(move |args: &[&str]| fake.run(args));
cli
})
})
}
#[test]
fn claude_sync_maps_every_branch_to_an_outcome() {
let root = std::env::temp_dir().join(format!("lj_p8b_claude_{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let url = "http://127.0.0.1:27190/mcp/projects/p1";
let helper = "\"C:\\Users\\u\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" abc";
let desired = || Some((url.to_string(), helper.to_string()));
let root_key = root.display().to_string().replace('\\', "/");
let entry = claude_local_server_entry(url, helper);
let config_with = |entry: Value| json!({ "projects": { root_key.clone(): { "mcpServers": { "lian-jing": entry } } } });
let empty = json!({ "projects": {} });
let no_cli: Option<&FakeClaude> = None;
// 快路径:已一致 → converged,不解析 CLI
assert_eq!(
sync_with_fake(
"p1",
&root,
desired(),
Some(&config_with(entry.clone())),
no_cli
),
ClientOutcome::Converged
);
// 移除且确认无托管条目 → converged
assert_eq!(
sync_with_fake("p1", &root, None, Some(&empty), no_cli),
ClientOutcome::Converged
);
// 缺 CLI → unavailable
assert!(matches!(
sync_with_fake("p1", &root, desired(), Some(&empty), no_cli),
ClientOutcome::Unavailable { reason } if reason.contains("claude CLI")
));
// 目录不存在 → unavailable:CLI 在也不起(审查修复:原用例缺 CLI,删掉目录守卫也会通过)
let not_local = "No MCP server found with name: \"lian-jing\".";
let untouched = FakeClaude::new(not_local, true, true);
assert!(matches!(
sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), Some(&untouched)),
ClientOutcome::Unavailable { reason } if reason.contains("项目目录不存在")
));
assert!(
untouched.calls.borrow().is_empty(),
"目录不存在时不得起 CLI"
);
// get 输出无法识别 → unavailable
let garbled = FakeClaude::new("???", true, true);
assert!(matches!(
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)),
ClientOutcome::Unavailable { reason } if reason.contains("无法识别")
));
// 本目录无 local 条目:写入成功 → applied,失败 → failed
assert_eq!(
sync_with_fake(
"p1",
&root,
desired(),
Some(&empty),
Some(&FakeClaude::new(not_local, true, true))
),
ClientOutcome::Applied
);
assert!(matches!(
sync_with_fake(
"p1",
&root,
desired(),
Some(&empty),
Some(&FakeClaude::new(not_local, true, false))
),
ClientOutcome::Failed { .. }
));
// 本项目旧托管条目:刷新 → refreshed;移除 → removed;移除失败 → failed
let stale = config_with(claude_local_server_entry(
"http://127.0.0.1:1/mcp/projects/p1",
helper,
));
let own = claude_get_local("http://127.0.0.1:1/mcp/projects/p1");
assert_eq!(
sync_with_fake(
"p1",
&root,
desired(),
Some(&stale),
Some(&FakeClaude::new(&own, true, true))
),
ClientOutcome::Refreshed
);
assert_eq!(
sync_with_fake(
"p1",
&root,
None,
Some(&stale),
Some(&FakeClaude::new(&own, true, true))
),
ClientOutcome::Removed
);
assert!(matches!(
sync_with_fake("p1", &root, desired(), Some(&stale), Some(&FakeClaude::new(&own, false, true))),
ClientOutcome::Failed { reason } if reason.contains("旧条目移除失败")
));
// 新条目写入失败:用原条目回滚并记 failed
let rollback = FakeClaude::new(&own, true, false);
assert!(matches!(
sync_with_fake("p1", &root, desired(), Some(&stale), Some(&rollback)),
ClientOutcome::Failed { reason } if reason.contains("新条目写入失败")
));
assert_eq!(
*rollback.calls.borrow(),
vec!["get", "remove", "add-json", "add-json"]
);
// 共享项目键属于他项目 → other_project;手写条目 → manual_kept,均不写
let other = FakeClaude::new(
&claude_get_local("http://127.0.0.1:27190/mcp/projects/p2"),
true,
true,
);
assert_eq!(
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&other)),
ClientOutcome::OtherProject { owner: "p2".into() }
);
assert_eq!(*other.calls.borrow(), vec!["get"]);
let manual = FakeClaude::new(&claude_get_local("http://example.com/mcp"), true, true);
assert_eq!(
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&manual)),
ClientOutcome::ManualKept
);
// 移除时本目录无条目、但他处项目键残留本项目托管条目 → needs_human
let elsewhere =
json!({ "projects": { "D:/other": { "mcpServers": { "lian-jing": entry } } } });
assert!(matches!(
sync_with_fake(
"p1",
&root,
None,
Some(&elsewhere),
Some(&FakeClaude::new(not_local, true, true))
),
ClientOutcome::NeedsHuman { .. }
));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn static_outcomes_are_read_only_judgments() {
let url = "http://127.0.0.1:27190/mcp/projects/p1";
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
let root = std::path::Path::new("E:\\code\\p1");
let entry = claude_local_server_entry(url, helper);
let config = |entry: Value| json!({ "projects": { "E:/code/p1": { "mcpServers": { "lian-jing": entry } } } });
assert_eq!(
claude_static_outcome(&config(entry.clone()), "p1", root, &entry),
ClientOutcome::Converged
);
assert_eq!(
claude_static_outcome(&json!({}), "p1", root, &entry),
ClientOutcome::Missing
);
let stale = claude_local_server_entry("http://127.0.0.1:1/mcp/projects/p1", helper);
assert_eq!(
claude_static_outcome(&config(stale), "p1", root, &entry),
ClientOutcome::Missing
);
let other = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p2", helper);
assert_eq!(
claude_static_outcome(&config(other), "p1", root, &entry),
ClientOutcome::OtherProject { owner: "p2".into() }
);
let manual = json!({ "type": "http", "url": "http://example.com/mcp" });
assert_eq!(
claude_static_outcome(&config(manual), "p1", root, &entry),
ClientOutcome::ManualKept
);
let block = render_codex_block(url, helper).unwrap();
assert_eq!(
codex_static_outcome(Some(&block.replace('\n', "\r\n")), url, helper),
ClientOutcome::Converged
);
assert_eq!(
codex_static_outcome(None, url, helper),
ClientOutcome::Missing
);
assert_eq!(
codex_static_outcome(Some("[mcp_servers.lian-jing]\nurl = \"x\"\n"), url, helper),
ClientOutcome::ManualKept
);
assert_eq!(
codex_static_outcome(Some(&block.replace("27190", "1")), url, helper),
ClientOutcome::Missing
);
let keys = vec![normalize_dsh_root("E:\\code\\p1")];
let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None);
assert_eq!(
dsh_static_outcome(&dsh, &keys, "p1", 27190),
ClientOutcome::Converged
);
assert_eq!(
dsh_static_outcome(&dsh, &keys, "p1", 1),
ClientOutcome::Missing,
"端口已变"
);
let without = render_dsh_block(27190, &[], None);
assert_eq!(
dsh_static_outcome(&without, &keys, "p1", 27190),
ClientOutcome::Missing
);
assert_eq!(
dsh_static_outcome("", &keys, "p1", 27190),
ClientOutcome::Missing
);
}
#[test]
fn dsh_patch_reports_not_installed_applied_converged_and_manual_kept() {
let base = std::env::temp_dir().join(format!("lj_p8b_dsh_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::new();
let home = base.join(".dsh");
assert_eq!(
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
Ok(ClientOutcome::NotInstalled)
);
std::fs::create_dir_all(&home).unwrap();
assert_eq!(
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
Ok(ClientOutcome::Applied)
);
assert_eq!(
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
Ok(ClientOutcome::Converged)
);
let manual = "- insert:\n - id: mcp-lian-jing\n name: x\n";
std::fs::write(home.join("cordis.patch.yml"), manual).unwrap();
assert_eq!(
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
Ok(ClientOutcome::ManualKept)
);
assert_eq!(
std::fs::read_to_string(home.join("cordis.patch.yml")).unwrap(),
manual,
"手写条目不被覆盖"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn adapters_skip_control_plane_and_wsl_without_touching_disk() {
let root = std::path::Path::new("E:\\nowhere\\ctrl");
let desired = DesiredMcp {
port: 27190,
server_url: "http://127.0.0.1:27190/mcp/projects/ctrl".into(),
header_helper: "h".into(),
refresh_dsh: false,
};
let control = ProjectFacts {
project_id: "ctrl",
root,
control_plane: true,
wsl: false,
};
let skip_ctrl = ClientOutcome::NotApplicable {
reason: NotApplicableReason::ControlPlane,
};
// Claude 的控制面判定要读本机配置,改测纯函数(见 control_plane_claude_residual_is_not_hidden)
assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl);
let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p");
let wsl = ProjectFacts {
project_id: "w",
root: wsl_root,
control_plane: false,
wsl: true,
};
let skip_wsl = ClientOutcome::NotApplicable {
reason: NotApplicableReason::Wsl,
};
assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl);
assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl);
// 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新
assert_eq!(DSH_ADAPTER.apply(&wsl, &desired), ClientOutcome::Deferred);
assert!(
[skip_ctrl.clone(), skip_wsl.clone()]
.iter()
.all(ClientOutcome::is_settled),
"不适用不计失败"
);
// apply 的结果映射:成功态在控制面 / WSL 上报 not_applicable,失败原样保留
let failed = ClientOutcome::Failed { reason: "x".into() };
assert_eq!(
finalize_for_project(&control, ClientOutcome::Removed),
skip_ctrl
);
assert_eq!(
finalize_for_project(&control, ClientOutcome::NotInstalled),
skip_ctrl
);
assert_eq!(finalize_for_project(&control, failed.clone()), failed);
assert_eq!(
finalize_for_project(&wsl, ClientOutcome::Converged),
skip_wsl
);
let normal = ProjectFacts {
project_id: "p",
root,
control_plane: false,
wsl: false,
};
assert_eq!(
finalize_for_project(&normal, ClientOutcome::Applied),
ClientOutcome::Applied
);
}
#[test]
fn control_plane_claude_residual_is_not_hidden() {
let root = std::path::Path::new("E:\\code\\ctrl");
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
let residual =
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/ctrl", helper);
let with =
json!({ "projects": { "E:/code/ctrl": { "mcpServers": { "lian-jing": residual } } } });
assert_eq!(
claude_control_plane_outcome(&with, "ctrl", root),
ClientOutcome::Missing
);
assert_eq!(
claude_control_plane_outcome(&json!({ "projects": {} }), "ctrl", root),
ClientOutcome::NotApplicable {
reason: NotApplicableReason::ControlPlane
}
);
}
#[test]
fn claude_static_prefers_own_git_root_over_ancestor_entry() {
let base = std::env::temp_dir().join(format!("lj_p8b_nested_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let child = base.join("sub").join("child");
std::fs::create_dir_all(child.join(".git")).unwrap();
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
let desired =
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/child", helper);
let parent_entry =
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/parent", helper);
let key = |path: &std::path::Path| path.display().to_string().replace('\\', "/");
// 子项目是独立 git 根、自己还没有条目:祖先目录的条目不在本目录生效 → missing(apply 会写入)
let only_parent =
json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } } } });
assert_eq!(
claude_static_outcome(&only_parent, "child", &child, &desired),
ClientOutcome::Missing
);
// 子项目自己的条目已一致 → converged(不被祖先条目干扰)
let both = json!({ "projects": {
key(&base): { "mcpServers": { "lian-jing": parent_entry } },
key(&child): { "mcpServers": { "lian-jing": desired } }
} });
assert_eq!(
claude_static_outcome(&both, "child", &child, &desired),
ClientOutcome::Converged
);
// 非 git 根的子目录(共享外层项目键):外层条目生效 → other_project
let plain = base.join("plain");
std::fs::create_dir_all(&plain).unwrap();
assert_eq!(
claude_static_outcome(&only_parent, "plain", &plain, &desired),
ClientOutcome::OtherProject {
owner: "parent".into()
}
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn static_outcomes_flag_coexisting_manual_entries_and_unreadable_patch() {
let url = "http://127.0.0.1:27190/mcp/projects/p1";
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
let block = render_codex_block(url, helper).unwrap();
let duplicated = format!("{block}\n[mcp_servers.lian-jing]\nurl = \"x\"\n");
assert!(matches!(
codex_static_outcome(Some(&duplicated), url, helper),
ClientOutcome::NeedsHuman { .. }
));
let keys = vec![normalize_dsh_root("E:\\code\\p1")];
let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None);
let with_manual = format!("- insert:\n - id: mcp-lian-jing\n name: x\n{dsh}");
assert_eq!(
dsh_static_outcome(&with_manual, &keys, "p1", 27190),
ClientOutcome::ManualKept
);
assert!(matches!(
dsh_static_outcome("- [unclosed", &keys, "p1", 27190),
ClientOutcome::Unavailable { .. }
));
let misordered = format!("{DSH_MANAGED_END}\n{DSH_MANAGED_START}\n");
assert!(matches!(
dsh_static_outcome(&misordered, &keys, "p1", 27190),
ClientOutcome::NeedsHuman { .. }
));
}
/// Codex 信任表解析:字面量键、按 TOML 规则转义的基本字符串键、[projects] 段内的内联表与点号键;
/// 只认精确目录(及调用方给出的 git 主仓库根候选),不逐级查祖先目录。
#[test]
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::<Vec<_>>())
};
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!(
!trusts(&[r"D:\Work\sub\proj"]),
"受信任的祖先目录不覆盖子目录(只认精确目录与 git 主仓库根)"
);
assert!(!trusts(&[r"D:\Bad"]), "非法转义的键忽略");
assert!(!trusts(&[r"E:\other"]), "untrusted 不算");
assert!(
!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)
);
}
#[test]
fn hard_failure_is_only_codex_failed() {
let report = |client, outcome| ClientReport::new(client, outcome);
let failed = || ClientOutcome::Failed {
reason: "boom".into(),
};
assert_eq!(
hard_failure(&[report(ClientId::Codex, failed())]),
Some("boom".into())
);
assert_eq!(
hard_failure(&[
report(ClientId::Claude, failed()),
report(ClientId::Dsh, failed()),
report(ClientId::Codex, ClientOutcome::ManualKept),
]),
None,
"Claude 与 DSH 一直是 best-effort,不算整体失败"
);
}
#[test]
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
let base = std::env::temp_dir().join(format!(
"lianjing_canonical_context_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".claude")).unwrap();
std::fs::write(
base.join(".claude/liangjing.json"),
r#"{"project_id":"legacy-project","mcp_port":27190}"#,
)
.unwrap();
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::write(
base.join(".lian-jing/context.json"),
r#"{"project_setting":"preserve-me"}"#,
)
.unwrap();
write_canonical_context_at(&base, "legacy-project").unwrap();
let context: Value = serde_json::from_str(
&std::fs::read_to_string(base.join(".lian-jing/context.json")).unwrap(),
)
.unwrap();
assert_eq!(context["schema_version"], CONTEXT_SCHEMA_VERSION);
assert_eq!(context["project_id"], "legacy-project");
assert_eq!(context["project_setting"], "preserve-me");
assert!(
!context.as_object().unwrap().contains_key("mcp_port"),
"运行时端口不应进入工具无关的 canonical context"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn shim_helper_command_has_no_machine_path() {
let helper = shim_helper_command("qa-digest");
assert_eq!(
helper,
"\"%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 header_shim_forwards_digest_and_exit_code() {
let ascii = render_header_shim(std::path::Path::new("C:\\Apps\\Lian Jing\\app.exe"));
assert_eq!(
ascii,
"@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1 & call exit /b %%ERRORLEVEL%%\r\n",
"调用与退出同一行:入口被并发改写时 cmd 不会再按旧偏移读后续行"
);
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
assert!(
unicode.contains("chcp 65001 >nul\r\n"),
"非 ASCII 路径须切 UTF-8 代码页"
);
let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe"));
assert!(
percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"),
"路径 % 须转义"
);
}
#[test]
fn reinject_targets_follow_single_injected_judgment() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::new();
conn.execute(
"INSERT INTO product_groups (id, name) VALUES ('g1', 'G')",
[],
)
.unwrap();
for id in ["grouped", "standalone", "removed"] {
conn.execute(
"INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')",
[format!("{id}-profile")],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)",
rusqlite::params![id, format!("{id}-profile"), format!("C:\\qa\\{id}")],
)
.unwrap();
}
conn.execute(
"INSERT INTO project_group_map (project_id, group_id) VALUES ('grouped', 'g1')",
[],
)
.unwrap();
// standalone:独立接入(有 token、不在任何组),此前被批量重注入漏掉;removed:从未接入或已移除
crate::credential_store::get_or_create_mcp_auth_token(&store, "standalone").unwrap();
assert!(is_project_injected(&store, "standalone"));
assert!(!is_project_injected(&store, "removed"));
assert_eq!(
reinject_targets(&conn, &store).unwrap(),
vec!["grouped".to_string(), "standalone".to_string()]
);
// 锁内复查:组成员仍是目标;独立项目的 token 先被 remove_project_mcp 删掉后不再注入
assert!(is_reinject_target(&conn, &store, "grouped").unwrap());
assert!(is_reinject_target(&conn, &store, "standalone").unwrap());
crate::credential_store::delete_mcp_auth_token_for_project(&store, "standalone").unwrap();
assert!(!is_reinject_target(&conn, &store, "standalone").unwrap());
assert_eq!(
reinject_targets(&conn, &store).unwrap(),
vec!["grouped".to_string()]
);
}
#[test]
fn global_scope_projects_are_excluded_and_roots_follow_registry() {
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::new();
for id in ["p1", "drift", "gone"] {
crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap();
}
// notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单
for (id, scope, bound) in [
("p1", None, true),
("ctrl", Some("global"), true),
("drift", None, false),
("gone", None, true),
("notoken", None, true),
] {
let root = base.join(id);
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 });
if let Some(scope) = scope {
ctx["mcp_scope"] = json!(scope);
}
std::fs::write(root.join(".lian-jing/context.json"), ctx.to_string()).unwrap();
conn.execute(
"INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')",
[format!("{id}-profile")],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)",
rusqlite::params![id, format!("{id}-profile"), root.display().to_string()],
)
.unwrap();
}
assert!(project_wants_global_scope(&base.join("ctrl")));
let roots = dsh_project_roots(&conn, &store, Some("gone"));
let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string());
// 规范路径(%TEMP% 常是 8.3 短名)与登记的字面路径不同时,两种键都要在白名单里且取值相同
let mut expected = Vec::new();
for (id, value) in [("ctrl", None), ("p1", Some("p1".to_string()))] {
expected.push((key(id), value.clone()));
let canonical = normalize_dsh_root(&strip_verbatim_prefix(
&std::fs::canonicalize(base.join(id))
.unwrap()
.display()
.to_string(),
));
if canonical != key(id) {
expected.push((canonical, value));
}
}
assert_eq!(
roots, expected,
"控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn verbatim_prefix_is_stripped_for_canonical_roots() {
assert_eq!(strip_verbatim_prefix(r"\\?\E:\code\demo"), r"E:\code\demo");
assert_eq!(
strip_verbatim_prefix(r"\\?\UNC\server\share\x"),
r"\\server\share\x"
);
assert_eq!(strip_verbatim_prefix(r"E:\plain"), r"E:\plain");
}
#[test]
fn dsh_root_keys_match_js_normalization() {
assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo");
assert_eq!(
normalize_dsh_root("E:/Code//Demo/"),
"e:\\code\\demo",
"正斜杠、重复与尾部分隔符"
);
assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致");
assert_eq!(
normalize_dsh_root("\\\\Server\\Share\\x"),
"\\\\server\\share\\x",
"保留 UNC 前缀"
);
}
#[test]
fn claude_local_entry_uses_scoped_url_and_shim() {
let entry = claude_local_server_entry(
"http://127.0.0.1:27190/mcp/projects/p1",
&shim_helper_command("d1"),
);
assert_eq!(entry["type"], "http");
assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1");
assert!(entry["headersHelper"]
.as_str()
.unwrap()
.contains("%USERPROFILE%\\.lian-jing\\bin\\"));
}
#[test]
fn managed_claude_entries_match_by_project_url_under_any_key() {
let managed = claude_local_server_entry(
"http://127.0.0.1:27190/mcp/projects/p%201",
&shim_helper_command("d1"),
);
let moved_port = claude_local_server_entry(
"http://127.0.0.1:28000/mcp/projects/p%201",
&shim_helper_command("d1"),
);
let config = json!({
"mcpServers": { "lian-jing": { "type": "sse", "url": "http://127.0.0.1:27190/mcp" } },
"projects": {
// Claude 以 git 根 / 主仓库根为键:条目可能在任意键下,按 URL 认领
"E:/repos/main": { "mcpServers": { "lian-jing": managed.clone() } },
"E:/repos/other": { "mcpServers": { "lian-jing": moved_port.clone() } },
"E:/repos/p10": { "mcpServers": { "lian-jing": claude_local_server_entry(
"http://127.0.0.1:27190/mcp/projects/p%2010", &shim_helper_command("d2")) } },
"E:/repos/manual": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:27190/mcp/projects/p%201" } } },
"E:/repos/none": { "lastCost": 1 }
}
});
let found = managed_claude_entries(&config, "p 1");
let keys: Vec<&str> = found.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(
keys,
vec!["E:/repos/main", "E:/repos/other"],
"端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领"
);
assert_eq!(found[0].1, managed);
assert!(claude_global_registered(&config));
assert!(!claude_global_registered(&json!({ "projects": {} })));
assert!(managed_claude_entries(&json!({}), "p 1").is_empty());
}
#[test]
fn claude_mcp_get_output_is_parsed_by_effective_scope() {
// 样本取自 claude 2.x 真实输出(2026-10-01)
let local = "lian-jing:\n Scope: Local config (private to you in this project)\n Status: ✔ Connected\n Type: http\n URL: http://127.0.0.1:27190/mcp/projects/p%201\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n";
assert_eq!(
parse_claude_mcp_get(local),
Some(ClaudeEffective::Local(
"http://127.0.0.1:27190/mcp/projects/p%201".into()
))
);
let user = "lian-jing:\r\n Scope: User config (available in all your projects)\r\n Type: sse\r\n URL: http://127.0.0.1:27190/mcp\r\n";
assert_eq!(parse_claude_mcp_get(user), Some(ClaudeEffective::NotLocal));
let missing = "No MCP server found with name: \"lian-jing\". Configured servers: a, b";
assert_eq!(
parse_claude_mcp_get(missing),
Some(ClaudeEffective::NotLocal)
);
assert_eq!(
parse_claude_mcp_get("Error: spawn EPERM"),
None,
"无法识别则按未知处理,不写入"
);
assert_eq!(
parse_claude_mcp_get("lian-jing:\n Scope: Local config\n Type: stdio\n"),
None,
"local 条目缺 URL 时无法判定归属"
);
}
#[test]
fn lianjing_project_routes_are_recognized_and_decoded() {
assert_eq!(
lianjing_project_of("http://127.0.0.1:27190/mcp/projects/p%201"),
Some("p 1".into())
);
assert_eq!(
lianjing_project_of("http://127.0.0.1:1/mcp/projects/abc/"),
Some("abc".into())
);
assert_eq!(
lianjing_project_of("http://127.0.0.1:27190/mcp"),
None,
"全局路由不属于任何项目"
);
assert_eq!(
lianjing_project_of("http://localhost:27190/mcp/projects/abc"),
None,
"非炼境地址视为手写"
);
assert_eq!(
lianjing_project_of("https://example.com/mcp/projects/abc"),
None
);
}
#[test]
fn claude_key_covers_root_and_its_descendants_only() {
assert!(key_covers_root(
"E:/code-project/demo",
"E:\\code-project\\demo\\"
));
assert!(
key_covers_root("e:\\code-project", "E:\\Code-Project\\demo"),
"git 根是祖先目录"
);
assert!(
!key_covers_root("E:/code-project/demo", "E:\\code-project\\demo-2"),
"前缀相同的兄弟目录不算"
);
assert!(
!key_covers_root("E:/code-project/demo/sub", "E:\\code-project\\demo"),
"子目录的键不覆盖父目录"
);
}
#[test]
fn project_root_change_detection() {
let old = PathBuf::from("E:\\old");
let new = PathBuf::from("E:\\new");
assert!(project_root_changed(Some(&old), Some(&new)));
assert!(
project_root_changed(None, Some(&new)),
"旧根解析不出也要迁移"
);
assert!(!project_root_changed(Some(&old), Some(&old)));
assert!(
!project_root_changed(Some(&old), None),
"新根解析不出则不动"
);
}
#[test]
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
let roots = vec![
("e:\\code-project".to_string(), None),
("e:\\code-project\\demo".to_string(), Some("p1".to_string())),
];
let session = DshSessionPlugin {
module: "C:/Users/o'brien x/.lian-jing/dsh/lianjing-session-mcp.v1.mjs".into(),
shim: "C:\\Users\\o'brien x\\.lian-jing\\bin\\lianjing-mcp-header.cmd".into(),
};
let block = render_dsh_block(27190, &roots, Some(&session));
assert!(
block.contains(
r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#
),
"控制面以 null 作停止哨兵"
);
assert!(
block.contains("hasOwnProperty.call(roots, key)")
&& block.contains(r"replace(/\\+$/, '')")
);
let parsed: serde_yaml::Value =
serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
let entry = &parsed[0]["insert"][0];
assert_eq!(entry["id"], "mcp-lian-jing");
assert_eq!(entry["config"]["serverName"], MCP_KEY);
assert_eq!(entry["config"]["transport"], "streamable-http");
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
assert!(
!block.contains("context.json"),
"身份只认登记白名单,不读目录里的 context.json"
);
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
assert!(
block.contains(": 'http://127.0.0.1:27190/mcp';"),
"未命中回退全局地址"
);
assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'"));
assert!(
block.contains("ctx.get('profileContext')?.name")
&& block.contains("if (profile === 'headless') for ("),
"多工作区 profile(web/acp/tui/桌面版)不按启动目录路由,防止跨项目带认证"
);
assert!(
block.contains("process.env.USERPROFILE") && block.contains("'.lian-jing', 'bin'"),
"与 Codex/Claude 共用用户目录下的入口"
);
assert!(
block.contains(MCP_HEADER_SHIM_NAME)
&& block.contains("windowsVerbatimArguments: true")
);
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
// 会话级插件条目:与全局行同一个 insert 列表,路径含引号与空格也必须是合法 YAML 并原样还原
let plugin = &parsed[0]["insert"][1];
assert_eq!(plugin["id"], DSH_SESSION_PLUGIN_ID);
assert_eq!(plugin["name"], session.module.as_str());
assert!(session.module.ends_with(DSH_SESSION_PLUGIN_FILE));
let guard = format!(
"!process.getBuiltinModule('fs').existsSync({})",
json!(session.module)
);
assert!(
block.contains(&format!(" disabled: !!js {}", json!(guard))),
"插件文件缺失时不加载该条目(DSH 0.1.x 会启动失败)"
);
assert_eq!(plugin["config"]["port"], 27190);
assert_eq!(plugin["config"]["shim"], session.shim.as_str());
assert_eq!(
plugin["config"]["roots"]["e:\\code-project"],
serde_yaml::Value::Null
);
assert_eq!(plugin["config"]["roots"]["e:\\code-project\\demo"], "p1");
let without = render_dsh_block(27190, &roots, None);
let parsed: serde_yaml::Value = serde_yaml::from_str(&without).unwrap();
assert_eq!(
parsed[0]["insert"].as_sequence().unwrap().len(),
1,
"插件文件写不出时只保留全局行"
);
}
#[test]
fn dsh_session_plugin_source_matches_versioned_contract() {
// 插件内容随二进制打包;文件名带版本号,内容变化须同步递增(Node 按 URL 缓存 ESM)
let version = DSH_SESSION_PLUGIN_FILE
.strip_prefix("lianjing-session-mcp.v")
.and_then(|rest| rest.strip_suffix(".mjs"))
.expect("文件名形如 lianjing-session-mcp.v<N>.mjs");
assert_eq!(
DSH_SESSION_PLUGIN_ID,
format!("{DSH_SESSION_PLUGIN_ID_PREFIX}-v{version}"),
"条目 id 与文件名同一版本号:DSH 只在 id 变化时重载插件"
);
assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!(
"export const name = \"{DSH_SESSION_PLUGIN_ID_PREFIX}\""
)));
// 钉住内容:改了插件却没升版本号时,运行中的 DSH 会一直跑旧代码。
// 改插件后:文件名与 DSH_SESSION_PLUGIN_ID 的版本号一起 +1,再把这里换成新哈希(内容按 LF 计算)。
use sha2::Digest;
let digest =
sha2::Sha256::digest(DSH_SESSION_PLUGIN_SOURCE.replace("\r\n", "\n").as_bytes());
let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
assert_eq!(
hex, "b8a5c7b251c1cf28623f9c08d352593536fd5051be637c0b1357c270b1090f7d",
"插件内容已变化:请同时递增文件名与条目 id 的版本号,再更新此哈希"
);
assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!("const MCP_KEY = \"{MCP_KEY}\"")));
assert!(
DSH_SESSION_PLUGIN_SOURCE.contains("/mcp/projects/"),
"插件只挂项目专用地址"
);
assert!(
!DSH_SESSION_PLUGIN_SOURCE.contains("Bearer "),
"插件不得含任何 token"
);
}
#[test]
fn dsh_block_upsert_preserves_user_entries_and_refuses_manual_lian_jing() {
let user = "- insert:\n - id: mcp-yljt\n config:\n serverName: yljt\n";
let first = upsert_dsh_block(user, &render_dsh_block(27190, &[], None)).unwrap();
assert!(first.starts_with(user.trim_end()), "用户条目原样保留");
let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap();
assert_eq!(parsed.as_sequence().unwrap().len(), 2);
let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[], None)).unwrap();
assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190"));
assert_eq!(
moved.matches(DSH_MANAGED_START).count(),
1,
"原位替换不重复追加"
);
assert_eq!(
upsert_dsh_block(&moved, &render_dsh_block(28000, &[], None)).unwrap(),
moved,
"幂等"
);
for manual in [
"- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n",
"- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n",
"- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n",
"- insert:\n - id: lianjing-session-mcp\n name: ./x.mjs\n",
"- insert:\n - id: lianjing-session-mcp-v9\n name: ./x.mjs\n",
] {
assert!(
upsert_dsh_block(manual, &render_dsh_block(27190, &[], None)).is_err(),
"{manual}"
);
let with_block = format!("{manual}{}", render_dsh_block(27190, &[], None));
assert!(
upsert_dsh_block(&with_block, &render_dsh_block(27190, &[], None)).is_err(),
"块外手写也须拒绝"
);
}
assert!(
upsert_dsh_block("- insert: [unclosed\n", "b").is_err(),
"无法解析时保守拒绝"
);
assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err());
assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n");
}
#[test]
fn l1_facts_skip_control_plane_and_wsl_and_codex_helper_round_trips() {
let desired = DesiredMcp {
port: 27190,
server_url: "http://127.0.0.1:27190/mcp/projects/l1".into(),
header_helper: shim_helper_command("abc"),
refresh_dsh: false,
};
let control = ProjectFacts {
project_id: "l1",
root: std::path::Path::new("E:\\nowhere\\l1"),
control_plane: true,
wsl: false,
};
// 不适用时不读客户端配置(Claude、DSH 的判定都不碰 ~/.claude.json 与 ~/.dsh)
for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] {
assert_eq!(
adapter.l1_facts(&control, &desired).not_applicable,
Some(NotApplicableReason::ControlPlane)
);
}
let wsl = ProjectFacts {
project_id: "w",
root: std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"),
control_plane: false,
wsl: true,
};
for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] {
assert_eq!(
adapter.l1_facts(&wsl, &desired).not_applicable,
Some(NotApplicableReason::Wsl)
);
}
assert_eq!(
CODEX_ADAPTER.l1_facts(&control, &desired).not_applicable,
None
);
// Codex 的 helper 被 codex mcp get 脱敏:L1 从项目托管块回查,须与写入时逐字一致
let root = std::env::temp_dir().join(format!("lj-l1-codex-{}", uuid::Uuid::new_v4()));
let config = root.join(".codex").join("config.toml");
upsert_codex_mcp_config_with(
&config,
&desired.server_url,
&desired.header_helper,
&crate::DiskSink,
)
.unwrap();
let normal = ProjectFacts {
project_id: "l1",
root: &root,
control_plane: false,
wsl: false,
};
let facts = CODEX_ADAPTER.l1_facts(&normal, &desired);
let _ = std::fs::remove_dir_all(&root);
match facts.source {
L1Source::Codex {
project_entry,
project_url,
project_helper,
} => {
assert!(project_entry);
assert_eq!(project_url.as_deref(), Some(desired.server_url.as_str()));
assert_eq!(
project_helper.as_deref(),
Some(desired.header_helper.as_str())
);
}
other => panic!("Codex 应给出 Codex 来源:{other:?}"),
}
}
#[test]
fn claude_l1_entries_match_project_url_in_every_scope() {
use probe_l1::ClaudeEntrySource::{Local, Project, User};
let url = "http://127.0.0.1:27190/mcp/projects/p";
let entry = |helper: &str| claude_local_server_entry(url, helper);
let config = json!({
"mcpServers": { "lian-jing": entry("user") },
"projects": {
"E:/code": { "mcpServers": { "lian-jing": entry("ancestor") } },
"E:/code/proj": { "mcpServers": { "lian-jing": entry("exact") } },
// worktree 被 Claude 映射到主仓库根:键不覆盖本根目录,但 URL 是本项目地址
"E:/code/proj-main": { "mcpServers": { "lian-jing": entry("main-repo") } },
// 覆盖本根目录、却指向别处:不是本项目地址,不作候选
"E:/": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:9/mcp" } } }
}
});
let mcp_json = json!({ "mcpServers": { "lian-jing": entry("project") } });
let entries = claude_l1_entries(
&config,
Some(&mcp_json),
std::path::Path::new("E:\\code\\proj"),
url,
);
let got: Vec<(probe_l1::ClaudeEntrySource, &str)> = entries
.iter()
.map(|candidate| {
(
candidate.source,
candidate.entry["headersHelper"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
got,
vec![
(Local, "exact"),
(Local, "ancestor"),
(Local, "main-repo"),
(Project, "project"),
(User, "user"),
]
);
// ~/.claude.json 读不到时仍回查 .mcp.json
assert_eq!(
claude_l1_entries(
&Value::Null,
Some(&mcp_json),
std::path::Path::new("E:\\x"),
url
)
.len(),
1
);
}
#[test]
fn managed_helpers_cover_current_and_legacy_shim_forms() {
assert!(legacy_shim_helper_command("d1").starts_with("\"%LOCALAPPDATA%\\lian-jing\\bin\\"));
assert!(legacy_shim_helper_command("d1").ends_with(&format!("{MCP_HEADER_SHIM_NAME}\" d1")));
assert_ne!(legacy_shim_helper_command("d1"), shim_helper_command("d1"));
}
/// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端):
/// LJ_L1_LIVE="<project_id>|<项目根目录>" cargo test l1_live -- --ignored --nocapture
/// 只打印矩阵(不含认证头),由人或统筹会话判读。
#[test]
#[ignore = "实机冒烟:起真实客户端自省进程并连运行中的炼境"]
fn l1_live_probe_against_running_lianjing() {
let spec = std::env::var("LJ_L1_LIVE").expect("设置 LJ_L1_LIVE=<project_id>|<root>");
let (project_id, root) = spec.split_once('|').expect("格式 <project_id>|<root>");
let started = std::time::Instant::now();
let report = probe_client_parity_at(project_id, std::path::Path::new(root), 27190).unwrap();
println!("{}", serde_json::to_string_pretty(&report).unwrap());
println!("elapsed_ms={}", started.elapsed().as_millis());
}
#[test]
fn registered_path_accepts_absolute_and_unc() {
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
assert!(validate_registered_path("E:/code-project/demo", "win_path").is_ok());
assert!(
validate_registered_path("\\\\wsl.localhost\\Ubuntu\\home\\u\\p", "win_path").is_ok(),
"WSL UNC 路径应通过"
);
}
}