fix(mcp/parity): 收尾审查修复——Claude 现状以 claude mcp get 为准,接入命令移出界面线程

收尾对抗审查指出:只读扫描 ~/.claude.json 的结论不能代表 Claude 在项目目录实际生效的条目
(共享项目键、worktree、目录迁移下会误删他项目条目或漏写),且同步 CLI 调用会阻塞界面线程。

- 快路径只认「项目根或其祖先目录键下」的一致托管条目;其余一律在项目目录执行
  `claude mcp get lian-jing` 取实际生效条目,按归属决策:本项目托管条目 → 刷新/移除
  (写入失败用原条目回滚);他项目托管条目或手写条目 → 不碰并记录;无 local 条目 → 写入
- 输出无法识别、CLI 出错或目录不存在时一律不写,只记 warn
- 全局迁移需 claude CLI 与 git-bash 均可用才置完成标记;批量重注入的成员复查失败计入错误
- 删组/移出组先删成员关系再清 MCP 配置,避免并发重注入把项目加回;调 CLI 前释放数据库连接
- delete_group / add_group_member / remove_group_member / delete_project / update_settings /
  apply_blueprint_buff 改为 #[tauri::command(async)],在线程池执行
- 新增 parse_claude_mcp_get / lianjing_project_of / key_covers_root 单元测试

实测(enterprise):稳态注入 174ms 不起 CLI;条目缺失 → 写入;过期 helper → 刷新;
手写条目 → 保留;三种情况 claude mcp get 均为 Local 且 Connected;经 shim 零凭据探针认证通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-10-01 16:55:28 +09:00
parent 92e6450403
commit 0a92a5b695
5 changed files with 415 additions and 136 deletions

View File

@ -17,7 +17,8 @@ pub struct BuffStatus {
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB
#[tauri::command]
// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn apply_blueprint_buff(
project_path: String,
project_id: String,

View File

@ -72,10 +72,10 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
Ok(())
}
#[tauri::command]
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn delete_group(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除所有成员的 MCP 配置(DB cascade 删除之前)
let member_ids: Vec<String> = conn
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
.map_err(|e| e.to_string())?
@ -83,18 +83,24 @@ pub fn delete_group(id: String) -> Result<(), String> {
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for project_id in &member_ids {
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
let in_other_group: i64 = conn
.query_row(
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置。删组前先算出需清理的成员
let to_clear: Vec<&String> = member_ids
.iter()
.filter(|project_id| {
conn.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, id],
|row| row.get(0),
|row| row.get::<_, i64>(0),
)
.unwrap_or(0);
if in_other_group > 0 {
continue;
}
.unwrap_or(0)
== 0
})
.collect();
// 先删组(级联删除成员关系)再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它们重新注入
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
drop(conn);
for project_id in to_clear {
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
log_event(
@ -105,8 +111,6 @@ pub fn delete_group(id: String) -> Result<(), String> {
);
}
}
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
@ -181,6 +185,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
drop(conn);
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
@ -195,7 +200,8 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
Ok(())
}
#[tauri::command]
// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn add_group_member(
project_id: String,
group_id: String,
@ -207,6 +213,7 @@ pub fn add_group_member(
params![project_id, group_id, role],
)
.map_err(|e| e.to_string())?;
drop(conn);
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
Ok(()) => None,
@ -220,7 +227,8 @@ pub fn add_group_member(
Ok(inject_warning)
}
#[tauri::command]
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
@ -231,6 +239,13 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S
|row| row.get(0),
)
.unwrap_or(0);
// 先删成员关系再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它重新注入
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
drop(conn);
if in_other_group == 0 {
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
let ctx =
@ -243,10 +258,5 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S
);
}
}
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -327,7 +327,8 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;

View File

@ -76,7 +76,8 @@ pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>,
}
/// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表
#[tauri::command]
// 端口变更会重注入全部项目(含 claude CLI):在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn update_settings(
settings: HashMap<String, String>,
space_id: Option<String>,

View File

@ -31,7 +31,11 @@ fn header_shim_path() -> Result<PathBuf, String> {
/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。
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" };
let codepage = if exe.is_ascii() {
""
} else {
"chcp 65001 >nul\r\n"
};
format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n")
}
@ -214,10 +218,7 @@ fn find_claude_bin() -> Option<String> {
}
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
if let Ok(out) = crate::silent_cmd("where")
.arg("claude.exe")
.output()
{
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() {
@ -300,10 +301,24 @@ fn try_register_globally(port: u16) -> bool {
&claude_bin,
&git_bash,
None,
&["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url],
&[
"mcp",
"add",
"--transport",
"sse",
"-s",
"user",
MCP_KEY,
&server_url,
],
) {
Ok(()) => {
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()));
Ok(_) => {
db::log_event(
"mcp_inject",
"info",
"全局 MCP 注册成功",
Some(&global_log_context()),
);
true
}
// 同名已存在不是故障——CLI 链路健康,注册目标已达成
@ -328,7 +343,11 @@ fn project_wants_global_scope(root: &std::path::Path) -> bool {
"canonical 项目上下文",
)
.ok()
.and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global"))
.and_then(|ctx| {
ctx.get("mcp_scope")
.and_then(Value::as_str)
.map(|scope| scope == "global")
})
.unwrap_or(false)
}
@ -383,7 +402,10 @@ fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Valu
.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("");
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))
@ -399,16 +421,16 @@ fn claude_global_registered(config: &Value) -> bool {
.is_some()
}
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。
const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。
fn run_claude_cli(
claude_bin: &str,
git_bash: &str,
cwd: Option<&std::path::Path>,
args: &[&str],
) -> Result<(), String> {
) -> Result<String, String> {
use std::io::Read;
let mut command = crate::silent_cmd(claude_bin);
if let Some(dir) = cwd {
@ -448,96 +470,178 @@ fn run_claude_cli(
let _ = stderr.read_to_string(&mut output);
}
if status.success() {
Ok(())
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(按「未知」处理,不写入)。
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
if output.contains("No MCP server found") {
return Some(ClaudeEffective::NotLocal);
}
let field = |name: &str| {
output.lines().find_map(|line| {
line.trim()
.strip_prefix(name)
.map(|value| value.trim().to_string())
})
};
let scope = field("Scope:")?;
if scope.starts_with("Local config") {
Some(ClaudeEffective::Local(field("URL:")?))
} else {
Some(ClaudeEffective::NotLocal)
}
}
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。
///
/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由
/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 /
/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。
/// 只读扫描判定现状:已一致则不起 CLI(稳态不阻塞);只替换/删除炼境托管条目,手写条目不碰。
/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。
/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。
/// 原则:写入一律交给 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)>) {
let context = inject_log_context(project_id, "claude");
let managed = read_claude_config()
.map(|config| managed_claude_entries(&config, project_id))
let warn = |message: String| db::log_event(CLIENT_SYNC_LOG, "warn", &message, Some(&context));
let config = read_claude_config();
let root_text = root.display().to_string();
let managed_all = config
.as_ref()
.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 {
None if managed.is_empty() => return,
Some(entry) if managed.iter().any(|(_, current)| current == entry) => return,
Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => return,
None if config.is_some() && managed_all.is_empty() => return,
_ => {}
}
if !root.is_dir() {
let keys: Vec<&str> = managed.iter().map(|(key, _)| key.as_str()).collect();
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!(
"项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
),
Some(&context),
);
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
warn(format!(
"项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
));
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),
);
warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into());
return;
};
let remove_error = if managed.is_empty() {
None
} else {
let effective =
match run_claude_cli(&claude_bin, &git_bash, Some(root), &["mcp", "get", MCP_KEY]) {
Ok(output) | Err(output) => parse_claude_mcp_get(&output),
};
let Some(effective) = effective else {
warn("无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动".into());
return;
};
let remove = || {
run_claude_cli(
&claude_bin,
&git_bash,
Some(root),
&["mcp", "remove", MCP_KEY, "--scope", "local"],
)
.err()
};
let outcome: Result<&str, String> = match &desired_entry {
None => match remove_error {
None => Ok("Claude 项目级 MCP 条目已移除"),
Some(error) => Err(error),
},
Some(entry) => {
let payload = entry.to_string();
match run_claude_cli(
&claude_bin,
&git_bash,
Some(root),
&["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"],
) {
Ok(()) => Ok("Claude 项目级 MCP 条目已写入"),
Err(error) if error.contains("already exists") => match remove_error {
None => Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖"),
Some(remove_error) => Err(format!("旧条目移除失败: {remove_error}")),
let add = |entry: &Value| {
let payload = entry.to_string();
run_claude_cli(
&claude_bin,
&git_bash,
Some(root),
&[
"mcp",
"add-json",
MCP_KEY,
payload.as_str(),
"--scope",
"local",
],
)
};
let outcome: 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) => Err(format!("旧条目移除失败: {error}")),
Ok(_) => match desired {
None => Ok("Claude 项目级 MCP 条目已移除".into()),
Some(entry) => match add(entry) {
Ok(_) => Ok("Claude 项目级 MCP 条目已刷新".into()),
Err(error) => {
// 回滚:用读到的原条目写回,避免项目失去 local 条目
let restored = managed_here
.first()
.map(|(_, previous)| add(previous).is_ok())
.unwrap_or(false);
Err(format!(
"新条目写入失败: {error}({})",
if restored { "已恢复原条目" } else { "原条目未能恢复" }
))
}
},
},
Err(error) => Err(error),
},
Some(owner) => Err(format!(
"本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动"
)),
None => Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()),
},
(ClaudeEffective::NotLocal, Some(entry)) => add(entry)
.map(|_| "Claude 项目级 MCP 条目已写入".into())
.map_err(|error| format!("写入失败: {error}")),
(ClaudeEffective::NotLocal, None) => {
if managed_all.is_empty() {
return;
}
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
Err(format!(
"本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
))
}
};
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}"),
Some(&context),
),
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
Err(error) => warn(format!("Claude 项目级 MCP 同步: {error}")),
}
}
@ -546,7 +650,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
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())
MCP_OP_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
@ -598,9 +704,11 @@ fn dsh_project_roots(
.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()?;
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;
}
@ -609,7 +717,9 @@ fn dsh_project_roots(
return Some((key, None));
}
let has_token = crate::credential_store::mcp_auth_digest(&id)
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
.and_then(|digest| {
crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)
})
.is_ok();
has_token.then(|| (key, Some(id)))
})
@ -619,7 +729,10 @@ fn dsh_project_roots(
fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
lines
.iter()
.map(|line| format!("{}{line}", " ".repeat(spaces)))
.collect()
}
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
let roots_map: serde_json::Map<String, Value> = roots
@ -637,12 +750,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
"}",
];
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 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.LOCALAPPDATA, sys = process.env.SystemRoot;",
@ -724,7 +835,10 @@ fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) {
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))? {
@ -766,7 +880,11 @@ pub fn ensure_dsh_patch(
};
let block = render_dsh_block(
port,
&dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude),
&dsh_project_roots(
conn,
&crate::credential_store::WindowsCredentialStore,
exclude,
),
);
let next = upsert_dsh_block(&current, &block)?;
if next != current {
@ -843,7 +961,12 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
.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()));
db::log_event(
"mcp_inject",
"info",
"旧组级 MCP 注册已移除",
Some(&json!({ "client": "claude-global", "server": &name }).to_string()),
);
removed.push(name);
}
}
@ -1114,8 +1237,10 @@ pub fn migrate_to_single_server() {
db::log_event("mcp_inject", level, &summary, None);
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)
if !register_ok {
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。
// 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() {
@ -1151,18 +1276,22 @@ pub fn reinject_all_mcp() -> Vec<String> {
// 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token)
let still_member = db::pool()
.get()
.ok()
.map_err(|error| error.to_string())
.and_then(|conn| {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)",
[&project_id],
|row| row.get::<_, bool>(0),
)
.ok()
})
.unwrap_or(false);
if !still_member {
continue;
.map_err(|error| error.to_string())
});
match still_member {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
errors.push(format!("项目 {project_id}: 复查组成员关系失败: {error}"));
continue;
}
}
if let Err(e) = inject_project_mcp_locked(&project_id, false) {
errors.push(format!("项目 {}: {}", project_id, e));
@ -1173,8 +1302,9 @@ pub fn reinject_all_mcp() -> Vec<String> {
errors
}
/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。
/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude
/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),
/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
/// 移除先完成则 token 已不在,这里就不会再注入。
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
@ -1336,9 +1466,15 @@ mod tests {
"@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n"
);
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页");
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"), "路径 % 须转义");
assert!(
percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"),
"路径 % 须转义"
);
}
#[test]
@ -1351,7 +1487,13 @@ mod tests {
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)] {
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 });
@ -1384,9 +1526,17 @@ mod tests {
#[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:/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 前缀");
assert_eq!(
normalize_dsh_root("\\\\Server\\Share\\x"),
"\\\\server\\share\\x",
"保留 UNC 前缀"
);
}
#[test]
@ -1397,7 +1547,10 @@ mod tests {
);
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%"));
assert!(entry["headersHelper"]
.as_str()
.unwrap()
.contains("%LOCALAPPDATA%"));
}
#[test]
@ -1435,14 +1588,95 @@ mod tests {
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(None, Some(&new)),
"旧根解析不出也要迁移"
);
assert!(!project_root_changed(Some(&old), Some(&old)));
assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动");
assert!(
!project_root_changed(Some(&old), None),
"新根解析不出则不动"
);
}
#[test]
@ -1453,21 +1687,36 @@ mod tests {
];
let block = render_dsh_block(27190, &roots);
assert!(
block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#),
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");
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("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(": 'http://127.0.0.1:27190/mcp';"),
"未命中回退全局地址"
);
assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'"));
assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true"));
assert!(
block.contains(MCP_HEADER_SHIM_NAME)
&& block.contains("windowsVerbatimArguments: true")
);
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
}
@ -1481,19 +1730,36 @@ mod tests {
let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).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, &[])).unwrap(), moved, "幂等");
assert_eq!(
moved.matches(DSH_MANAGED_START).count(),
1,
"原位替换不重复追加"
);
assert_eq!(
upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).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",
] {
assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}");
assert!(
upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(),
"{manual}"
);
let with_block = format!("{manual}{}", render_dsh_block(27190, &[]));
assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝");
assert!(
upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(),
"块外手写也须拒绝"
);
}
assert!(upsert_dsh_block("- insert: [unclosed\n", "b").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");
}