fix(mcp/parity): 第三轮审查修复——Claude 条目改为直接编辑配置,接入操作加进程锁
- Claude local 条目不再经 claude CLI(cwd=项目目录)+ 后台队列管理,改为直接编辑 ~/.claude.json 中本项目键下的 mcpServers.lian-jing:同步、毫秒级、原子替换,只动该字段; 不依赖项目目录存在,消除删除本地文件夹时的竞态、目录移动后旧条目无法清理、队列丢任务/卡死 - 项目键用真实长路径(实测 8.3 短路径键不被 Claude 识别);手写条目不碰 - 注入、移除、根目录迁移在进程锁内串行;移除时最先删 token,后续清理收集错误统一返回 - 根目录迁移:旧根解析不出也迁移;重新注入失败归入 mcp_inject 并补刷 DSH;DSH 补丁写入加锁 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b702a9d88
commit
fc8492b88b
@ -6,7 +6,7 @@ contracts:
|
||||
- label: API 接口(Axum)
|
||||
paths:
|
||||
- "src-tauri/src/mcp_server.rs"
|
||||
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
||||
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局;直接编辑该文件中本项目键下的 mcpServers.lian-jing,不经 claude CLI、不依赖项目目录存在,项目键用真实长路径;原子替换写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
||||
|
||||
- label: MCP 工具接口
|
||||
paths:
|
||||
|
||||
@ -304,11 +304,11 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
|
||||
|
||||
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
|
||||
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
|
||||
if let Some(old_root) = old_root {
|
||||
if root_may_change {
|
||||
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
|
||||
if new_root.as_ref() != Some(&old_root) {
|
||||
if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) {
|
||||
drop(conn);
|
||||
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root)));
|
||||
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -362,19 +362,18 @@ fn global_log_context() -> String {
|
||||
}
|
||||
|
||||
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
|
||||
fn claude_local_server_json(server_url: &str, header_helper: &str) -> String {
|
||||
fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value {
|
||||
json!({
|
||||
"type": "http",
|
||||
"url": server_url,
|
||||
"headersHelper": header_helper,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ClaudeLocalEntry {
|
||||
Absent,
|
||||
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。
|
||||
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。
|
||||
Managed(Value),
|
||||
/// 用户手写的同名条目:炼境不覆盖、不删除。
|
||||
Manual,
|
||||
@ -413,128 +412,166 @@ fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> C
|
||||
}
|
||||
}
|
||||
|
||||
fn read_claude_user_config() -> Option<Value> {
|
||||
let home = std::env::var("USERPROFILE").ok()?;
|
||||
let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ClaudeLocalOutcome {
|
||||
Unchanged,
|
||||
Registered,
|
||||
Removed,
|
||||
KeptManual,
|
||||
}
|
||||
|
||||
fn run_claude_cli(
|
||||
claude_bin: &str,
|
||||
git_bash: &str,
|
||||
/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing,
|
||||
/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。
|
||||
fn apply_claude_local(
|
||||
config: &mut Value,
|
||||
root: &std::path::Path,
|
||||
args: &[&str],
|
||||
) -> Result<(), String> {
|
||||
let out = crate::silent_cmd(claude_bin)
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.env("CLAUDE_CODE_GIT_BASH_PATH", git_bash)
|
||||
.output()
|
||||
.map_err(|error| format!("执行 claude CLI 失败: {error}"))?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
desired: Option<Value>,
|
||||
) -> Result<ClaudeLocalOutcome, String> {
|
||||
match (classify_claude_local_entry(config, root), &desired) {
|
||||
(ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual),
|
||||
(ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged),
|
||||
(ClaudeLocalEntry::Managed(current), Some(entry)) if ¤t == entry => {
|
||||
return Ok(ClaudeLocalOutcome::Unchanged)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let wanted = normalize_root_key(&root.display().to_string());
|
||||
let projects = config
|
||||
.as_object_mut()
|
||||
.ok_or("~/.claude.json 顶层不是 JSON 对象")?
|
||||
.entry("projects")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("~/.claude.json 的 projects 不是 JSON 对象")?;
|
||||
// 复用 Claude 已记录的键(大小写、分隔符以它为准),没有才按它的惯例(正斜杠)新建
|
||||
let key = projects
|
||||
.keys()
|
||||
.find(|key| normalize_root_key(key) == wanted)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| root.display().to_string().replace('\\', "/"));
|
||||
match desired {
|
||||
Some(entry) => {
|
||||
projects
|
||||
.entry(key)
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("~/.claude.json 的项目条目不是 JSON 对象")?
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("~/.claude.json 的 mcpServers 不是 JSON 对象")?
|
||||
.insert(MCP_KEY.to_string(), entry);
|
||||
Ok(ClaudeLocalOutcome::Registered)
|
||||
}
|
||||
None => {
|
||||
if let Some(servers) = projects
|
||||
.get_mut(&key)
|
||||
.and_then(|project| project.get_mut("mcpServers"))
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
servers.remove(MCP_KEY);
|
||||
}
|
||||
Ok(ClaudeLocalOutcome::Removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。
|
||||
fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> {
|
||||
let tmp = path.with_file_name(format!(
|
||||
"{}.lianjing-tmp",
|
||||
path.file_name().and_then(|name| name.to_str()).unwrap_or("config")
|
||||
));
|
||||
std::fs::write(&tmp, content).map_err(|error| format!("写临时文件失败: {error}"))?;
|
||||
let mut last_error = None;
|
||||
for _ in 0..5 {
|
||||
match std::fs::rename(&tmp, path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(format!("替换文件失败: {}", last_error.map(|e| e.to_string()).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Claude 以真实长路径记录项目(8.3 短名与大小写都以文件系统为准;2026-10-01 实测短路径键不会被识别)。
|
||||
/// 目录存在时规范化为长路径(去掉 \\?\ 前缀),不存在(已移动或删除)时原样使用。
|
||||
fn claude_project_path(root: &std::path::Path) -> PathBuf {
|
||||
let Ok(canonical) = std::fs::canonicalize(root) else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
let text = canonical.display().to_string();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
PathBuf::from(format!(r"\\{rest}"))
|
||||
} else if let Some(rest) = text.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(rest)
|
||||
} else {
|
||||
Err(format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
))
|
||||
canonical
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
|
||||
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
|
||||
|
||||
type ClaudeSyncJob = (String, PathBuf, Option<(String, String)>);
|
||||
static CLAUDE_SYNC_QUEUE: std::sync::OnceLock<std::sync::Mutex<std::sync::mpsc::Sender<ClaudeSyncJob>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// 所有 Claude local 同步投递到同一个后台线程,按提交顺序串行执行:避免多个 claude CLI 并发
|
||||
/// 读改写 ~/.claude.json,也保证「注入后立刻移除」时移除不会被更早的注册越过。调用方不阻塞。
|
||||
fn enqueue_claude_sync(project_id: &str, root: PathBuf, desired: Option<(String, String)>) {
|
||||
let queue = CLAUDE_SYNC_QUEUE.get_or_init(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<ClaudeSyncJob>();
|
||||
std::thread::spawn(move || {
|
||||
for (project_id, root, desired) in receiver {
|
||||
sync_claude_local(&project_id, &root, desired);
|
||||
}
|
||||
});
|
||||
std::sync::Mutex::new(sender)
|
||||
});
|
||||
if let Ok(sender) = queue.lock() {
|
||||
let _ = sender.send((project_id.to_string(), root, desired));
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
||||
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。
|
||||
/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录
|
||||
/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。
|
||||
/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。
|
||||
/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。
|
||||
fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) {
|
||||
let context = inject_log_context(project_id, "claude");
|
||||
let state = read_claude_user_config()
|
||||
.map(|config| classify_claude_local_entry(&config, root))
|
||||
.unwrap_or(ClaudeLocalEntry::Absent);
|
||||
if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) {
|
||||
let Ok(home) = std::env::var("USERPROFILE") else {
|
||||
return;
|
||||
};
|
||||
let path = PathBuf::from(home).join(".claude.json");
|
||||
if !path.is_file() {
|
||||
// 本机从未运行过 Claude Code:不替它创建配置
|
||||
return;
|
||||
}
|
||||
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
|
||||
db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步",
|
||||
Some(&context),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"];
|
||||
let outcome = match (state, desired) {
|
||||
(ClaudeLocalEntry::Manual, _) => {
|
||||
Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string())
|
||||
let root = claude_project_path(root);
|
||||
let root = root.as_path();
|
||||
let result = (|| -> Result<ClaudeLocalOutcome, String> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|error| format!("读取失败: {error}"))?;
|
||||
let mut config: Value =
|
||||
serde_json::from_str(&text).map_err(|error| format!("解析失败,炼境不写入: {error}"))?;
|
||||
let entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper));
|
||||
let outcome = apply_claude_local(&mut config, root, entry)?;
|
||||
if matches!(outcome, ClaudeLocalOutcome::Registered | ClaudeLocalOutcome::Removed) {
|
||||
let content = serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?;
|
||||
write_file_atomic(&path, &content)?;
|
||||
}
|
||||
(ClaudeLocalEntry::Absent, None) => return,
|
||||
(ClaudeLocalEntry::Managed(_), None) => {
|
||||
run_claude_cli(&claude_bin, &git_bash, root, &remove_args)
|
||||
.map(|_| "Claude 项目级 MCP 条目已移除".to_string())
|
||||
}
|
||||
(previous, Some((server_url, header_helper))) => {
|
||||
let payload = claude_local_server_json(&server_url, &header_helper);
|
||||
let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"];
|
||||
let registered = if let ClaudeLocalEntry::Managed(old) = &previous {
|
||||
if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) {
|
||||
db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("Claude 项目级 MCP 旧条目移除失败: {error}"),
|
||||
Some(&context),
|
||||
);
|
||||
return;
|
||||
}
|
||||
run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| {
|
||||
let restore = old.to_string();
|
||||
let _ = run_claude_cli(
|
||||
&claude_bin,
|
||||
&git_bash,
|
||||
root,
|
||||
&["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"],
|
||||
);
|
||||
Err(format!("{error}(已尝试恢复原条目)"))
|
||||
})
|
||||
} else {
|
||||
run_claude_cli(&claude_bin, &git_bash, root, &add_args)
|
||||
Ok(outcome)
|
||||
})();
|
||||
let context = inject_log_context(project_id, "claude");
|
||||
match result {
|
||||
Ok(ClaudeLocalOutcome::Unchanged) => {}
|
||||
Ok(outcome) => {
|
||||
let message = match outcome {
|
||||
ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入",
|
||||
ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除",
|
||||
_ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖",
|
||||
};
|
||||
registered.map(|_| "Claude 项目级 MCP 注册成功".to_string())
|
||||
db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context));
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
|
||||
Err(error) => db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("Claude 项目级 MCP 同步失败: {error}"),
|
||||
&format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"),
|
||||
Some(&context),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程内所有「炼境 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」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址
|
||||
@ -761,8 +798,14 @@ pub fn ensure_dsh_patch(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。
|
||||
static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。
|
||||
pub fn refresh_dsh_patch(exclude: Option<&str>) {
|
||||
let _guard = DSH_PATCH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let result = db::pool()
|
||||
.get()
|
||||
.map_err(|error| error.to_string())
|
||||
@ -948,11 +991,13 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
|
||||
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
|
||||
/// 并清理旧组级 key(迁移内建于重注入)
|
||||
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
inject_project_mcp_with(project_id, true)
|
||||
let _guard = mcp_op_lock();
|
||||
inject_project_mcp_locked(project_id, true)
|
||||
}
|
||||
|
||||
/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。
|
||||
fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> {
|
||||
/// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,
|
||||
/// 由调用方在末尾统一刷新一次。
|
||||
fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), 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)?;
|
||||
@ -999,8 +1044,7 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St
|
||||
try_register_globally(port);
|
||||
let root = project_root(project_id)?;
|
||||
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
|
||||
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态
|
||||
enqueue_claude_sync(project_id, root, desired);
|
||||
sync_claude_local(project_id, &root, desired);
|
||||
if refresh_dsh {
|
||||
refresh_dsh_patch(None);
|
||||
}
|
||||
@ -1010,13 +1054,34 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St
|
||||
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
// 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
|
||||
// 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。
|
||||
let _guard = mcp_op_lock();
|
||||
// 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
|
||||
if let Ok(root) = project_root(project_id) {
|
||||
enqueue_claude_sync(project_id, root, None);
|
||||
sync_claude_local(project_id, &root, None);
|
||||
}
|
||||
// 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单
|
||||
// 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)?)?;
|
||||
let path = settings_path(project_id)?;
|
||||
if path.exists() {
|
||||
@ -1030,10 +1095,7 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
}
|
||||
write_json(&path, &settings)?;
|
||||
}
|
||||
crate::credential_store::delete_mcp_auth_token_for_project(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
|
||||
@ -1108,7 +1170,8 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for project_id in project_ids {
|
||||
if let Err(e) = inject_project_mcp_with(&project_id, false) {
|
||||
let _guard = mcp_op_lock();
|
||||
if let Err(e) = inject_project_mcp_locked(&project_id, false) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
}
|
||||
}
|
||||
@ -1117,11 +1180,14 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
errors
|
||||
}
|
||||
|
||||
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目
|
||||
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。
|
||||
/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
|
||||
/// 已接入(有 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 {
|
||||
enqueue_claude_sync(project_id, root, None);
|
||||
sync_claude_local(project_id, &root, None);
|
||||
}
|
||||
let injected = crate::credential_store::mcp_auth_digest(project_id)
|
||||
.and_then(|digest| {
|
||||
@ -1135,16 +1201,24 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
|
||||
refresh_dsh_patch(None);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = inject_project_mcp(project_id) {
|
||||
if let Err(error) = inject_project_mcp_locked(project_id, true) {
|
||||
// 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次
|
||||
db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"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::*;
|
||||
@ -1345,16 +1419,91 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn claude_local_entry_uses_scoped_url_and_shim() {
|
||||
let entry: Value = serde_json::from_str(&claude_local_server_json(
|
||||
let entry = claude_local_server_entry(
|
||||
"http://127.0.0.1:27190/mcp/projects/p1",
|
||||
&shim_helper_command("d1"),
|
||||
))
|
||||
.unwrap();
|
||||
);
|
||||
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("%LOCALAPPDATA%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_local_direct_edit_touches_only_lian_jing_entry() {
|
||||
let root = std::path::Path::new("E:\\code-project\\Demo");
|
||||
let entry = claude_local_server_entry(
|
||||
"http://127.0.0.1:27190/mcp/projects/p1",
|
||||
&shim_helper_command("d1"),
|
||||
);
|
||||
// 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server
|
||||
let mut config = json!({
|
||||
"numStartups": 3,
|
||||
"projects": { "e:/code-project/demo": {
|
||||
"lastCost": 1.5,
|
||||
"mcpServers": { "other": { "type": "stdio" } }
|
||||
} }
|
||||
});
|
||||
assert_eq!(
|
||||
apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(),
|
||||
ClaudeLocalOutcome::Registered
|
||||
);
|
||||
let project = &config["projects"]["e:/code-project/demo"];
|
||||
assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键");
|
||||
assert_eq!(project["lastCost"], 1.5);
|
||||
assert_eq!(project["mcpServers"]["other"]["type"], "stdio");
|
||||
assert_eq!(project["mcpServers"][MCP_KEY], entry);
|
||||
assert_eq!(config["numStartups"], 3);
|
||||
assert_eq!(
|
||||
apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(),
|
||||
ClaudeLocalOutcome::Unchanged,
|
||||
"相同条目不重写"
|
||||
);
|
||||
|
||||
assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Removed);
|
||||
let project = &config["projects"]["e:/code-project/demo"];
|
||||
assert!(project["mcpServers"].get(MCP_KEY).is_none());
|
||||
assert_eq!(project["mcpServers"]["other"]["type"], "stdio", "移除只删 lian-jing");
|
||||
assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Unchanged);
|
||||
|
||||
// 尚无项目键:按 Claude 惯例(正斜杠)新建
|
||||
let mut empty = json!({});
|
||||
apply_claude_local(&mut empty, root, Some(entry.clone())).unwrap();
|
||||
assert_eq!(empty["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], entry);
|
||||
|
||||
// 手写条目:注册与移除都不碰
|
||||
let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" });
|
||||
let mut handwritten = json!({ "projects": { "E:/code-project/Demo": { "mcpServers": { "lian-jing": manual.clone() } } } });
|
||||
assert_eq!(
|
||||
apply_claude_local(&mut handwritten, root, Some(entry)).unwrap(),
|
||||
ClaudeLocalOutcome::KeptManual
|
||||
);
|
||||
assert_eq!(apply_claude_local(&mut handwritten, root, None).unwrap(), ClaudeLocalOutcome::KeptManual);
|
||||
assert_eq!(handwritten["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_project_path_is_long_form_without_verbatim_prefix() {
|
||||
let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let resolved = claude_project_path(&dir).display().to_string();
|
||||
assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}");
|
||||
assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}");
|
||||
assert!(std::path::Path::new(&resolved).is_dir());
|
||||
let missing = std::path::Path::new("E:\\definitely\\missing\\root");
|
||||
assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[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![
|
||||
|
||||
Loading…
Reference in New Issue
Block a user