@ -214,7 +214,7 @@ fn find_claude_bin() -> Option<String> {
}
// 最后尝试 where 命令( GUI 进程 PATH 可能不含用户 bin, but worth trying)
if let Ok ( out ) = std ::process ::Command ::new ( " where " )
if let Ok ( out ) = crate ::silent_cmd ( " where " )
. arg ( " claude.exe " )
. output ( )
{
@ -252,7 +252,7 @@ fn find_git_bash() -> Option<String> {
}
// 从 git.exe 推导:<root>\cmd\git.exe / <root>\mingw64\bin\git.exe → <root>\bin\bash.exe
if let Ok ( out ) = std ::process ::Command ::new ( " where " ) . arg ( " git.exe " ) . output ( ) {
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 ( ) ) ;
@ -292,7 +292,7 @@ fn try_register_globally(port: u16) -> bool {
return false ;
} ;
match std ::process ::Command ::new ( & claude_bin )
match crate ::silent_cmd ( & claude_bin )
. args ( [
" mcp " ,
" add " ,
@ -442,22 +442,49 @@ fn run_claude_cli(
}
}
/// 客户端侧同步( 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, 结果只记日志。
/// 手写条目一律不碰; 替换失败时用原值回滚。best-effort, 结果只记日志。 只应经 enqueue_claude_sync 调用。
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 ) ) {
return ;
}
let ( Some ( claude_bin ) , Some ( git_bash ) ) = ( find_claude_bin ( ) , find_git_bash ( ) ) else {
db ::log_event (
" mcp_inject " ,
CLIENT_SYNC_LOG ,
" warn " ,
" claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步 " ,
Some ( & context ) ,
) ;
return ;
} ;
let state = read_claude_user_config ( )
. map ( | config | classify_claude_local_entry ( & config , root ) )
. unwrap_or ( ClaudeLocalEntry ::Absent ) ;
let remove_args = [ " mcp " , " remove " , MCP_KEY , " --scope " , " local " ] ;
let outcome = match ( state , desired ) {
( ClaudeLocalEntry ::Manual , _ ) = > {
@ -474,7 +501,7 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
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 (
" mcp_inject " ,
CLIENT_SYNC_LOG ,
" warn " ,
& format! ( " Claude 项目级 MCP 旧条目移除失败: {error} " ) ,
Some ( & context ) ,
@ -498,9 +525,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
}
} ;
match outcome {
Ok ( message ) = > db ::log_event ( " mcp_inject " , " info " , & message , Some ( & context ) ) ,
Ok ( message ) = > db ::log_event ( CLIENT_SYNC_LOG , " info " , & message , Some ( & context ) ) ,
Err ( error ) = > db ::log_event (
" mcp_inject " ,
CLIENT_SYNC_LOG ,
" warn " ,
& format! ( " Claude 项目级 MCP 同步失败: {error} " ) ,
Some ( & context ) ,
@ -514,16 +541,33 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址,
// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。
/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
fn normalize_dsh_root ( path : & str ) -> String {
path . replace ( '/' , " \\ " ) . trim_end_matches ( '\\' ) . to_lowercase ( )
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 ( )
}
/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。
/// 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 , String ) > {
) -> Vec < ( String , Option< String> ) > {
let rows : Vec < ( String , String ) > = conn
. prepare (
" SELECT id, win_path FROM project_workspaces
@ -539,21 +583,31 @@ fn dsh_project_roots(
. 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 ( ) ? ;
let bound = context . get ( " project_id " ) . and_then ( Value ::as_str ) = = Some ( id . as_str ( ) ) ;
( bound & & ! project_wants_global_scope ( & root ) )
. then ( | | ( normalize_dsh_root ( & win_path ) , id ) )
if context . get ( " project_id " ) . and_then ( Value ::as_str ) ! = Some ( id . as_str ( ) ) {
return None ;
}
let key = normalize_dsh_root ( & win_path ) ;
if context . get ( " mcp_scope " ) . and_then ( Value ::as_str ) = = Some ( " global " ) {
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 ) )
. is_ok ( ) ;
has_token . then ( | | ( key , Some ( id ) ) )
} )
. collect ( )
}
fn render_dsh_block ( port : u16 , roots : & [ ( String , String ) ] ) -> String {
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 ( )
}
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
let roots_map : serde_json ::Map < String , Value > = roots
. iter ( )
. map ( | ( root , id ) | ( root . clone ( ) , json! ( id ) ) )
@ -564,8 +618,8 @@ fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
roots_line . as_str ( ) ,
" let id; " ,
" for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) { " ,
" const hit = roots[d.toLowerCase()] ;" ,
" if ( hit) { id = hit ; break; }" ,
" 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 " ) ;
@ -696,7 +750,10 @@ pub fn ensure_dsh_patch(
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 , exclude ) ) ;
let block = render_dsh_block (
port ,
& dsh_project_roots ( conn , & crate ::credential_store ::WindowsCredentialStore , exclude ) ,
) ;
let next = upsert_dsh_block ( & current , & block ) ? ;
if next ! = current {
std ::fs ::write ( & path , next ) . map_err ( | error | format! ( " 写入 DSH 补丁文件失败: {error} " ) ) ? ;
@ -712,7 +769,7 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) {
. and_then ( | conn | ensure_dsh_patch ( & conn , crate ::mcp_server ::read_port ( ) , exclude ) ) ;
if let Err ( error ) = result {
db ::log_event (
" mcp_inject " ,
CLIENT_SYNC_LOG ,
" warn " ,
& format! ( " DSH 托管块刷新失败: {error} " ) ,
Some ( & json! ( { " client " : " dsh " } ) . to_string ( ) ) ,
@ -759,7 +816,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
names . push ( format! ( " lian-jing- {} " , & gid [ .. 8 ] ) ) ;
}
for name in names {
let ok = std ::process ::Command ::new ( & claude_bin )
let ok = crate ::silent_cmd ( & claude_bin )
. args ( [ " mcp " , " remove " , " -s " , " user " , & name ] )
. env ( " CLAUDE_CODE_GIT_BASH_PATH " , & git_bash )
. output ( )
@ -891,6 +948,11 @@ 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 )
}
/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。
fn inject_project_mcp_with ( 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 ) ? ;
@ -937,20 +999,23 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
try_register_globally ( port ) ;
let root = project_root ( project_id ) ? ;
let desired = ( ! project_wants_global_scope ( & root ) ) . then ( | | ( server_url , header_helper ) ) ;
let owned_id = project_id . to_string ( ) ;
// claude CLI 每次调用约 1-2 秒,放到后台线程,避免同步命令阻塞界面
std ::thread ::spawn ( move | | sync_claude_local ( & owned_id , & root , desired ) ) ;
refresh_dsh_patch ( None ) ;
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态
enqueue_claude_sync ( project_id , root , desired ) ;
if refresh_dsh {
refresh_dsh_patch ( None ) ;
}
Ok ( ( ) )
}
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key) 。
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp ( project_id : & str ) -> Result < ( ) , String > {
// 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
// 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
// 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。
if let Ok ( root ) = project_root ( project_id ) {
sync_claude_local( project_id , & root , None ) ;
enqueue_claude_sync( project_id , root , None ) ;
}
// 登记此时仍在,用 exclude 立即剔除; token 随后被删,之后的刷新也不会再把它加回白名单
refresh_dsh_patch ( Some ( project_id ) ) ;
remove_codex_mcp_config ( & codex_config_path ( project_id ) ? ) ? ;
let path = settings_path ( project_id ) ? ;
@ -1043,15 +1108,43 @@ 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 ( & project_id ) {
if let Err ( e ) = inject_project_mcp _with ( & project_id , false ) {
errors . push ( format! ( " 项目 {} : {} " , project_id , e ) ) ;
}
}
// 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底 一次,失败只记日志
// 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新 一次,失败只记日志
refresh_dsh_patch ( None ) ;
errors
}
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token) 的项目
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI, 调用方应放在后台线程。
pub fn on_project_root_changed ( project_id : & str , old_root : Option < PathBuf > ) {
if let Some ( root ) = old_root {
enqueue_claude_sync ( project_id , root , None ) ;
}
let injected = crate ::credential_store ::mcp_auth_digest ( project_id )
. and_then ( | digest | {
crate ::credential_store ::read_mcp_auth_token_for_digest (
& crate ::credential_store ::WindowsCredentialStore ,
& digest ,
)
} )
. is_ok ( ) ;
if ! injected {
refresh_dsh_patch ( None ) ;
return ;
}
if let Err ( error ) = inject_project_mcp ( project_id ) {
db ::log_event (
CLIENT_SYNC_LOG ,
" warn " ,
& format! ( " 项目根目录变更后重新注入失败: {error} " ) ,
Some ( & inject_log_context ( project_id , " all " ) ) ,
) ;
}
}
#[ cfg(test) ]
mod tests {
use super ::* ;
@ -1207,7 +1300,12 @@ mod tests {
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 ( ) ;
for ( id , scope , bound ) in [ ( " p1 " , None , true ) , ( " ctrl " , Some ( " global " ) , true ) , ( " drift " , None , false ) , ( " gone " , None , true ) ] {
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 } ) ;
@ -1227,13 +1325,24 @@ mod tests {
. unwrap ( ) ;
}
assert! ( project_wants_global_scope ( & base . join ( " ctrl " ) ) ) ;
let roots = dsh_project_roots ( & conn , Some ( " gone " ) ) ;
let ids : Vec < & str > = roots . iter ( ) . map ( | ( _ , id ) | id . as_str ( ) ) . collect ( ) ;
assert_eq! ( ids , vec! [ " p1 " ] , " 只保留已绑定、非控制面、未被剔除的项目 " ) ;
assert_eq! ( roots [ 0 ] . 0 , normalize_dsh_root ( & base . join ( " p1 " ) . display ( ) . to_string ( ) ) ) ;
let roots = dsh_project_roots ( & conn , & store , Some ( " gone " ) ) ;
let key = | id : & str | normalize_dsh_root ( & base . join ( id ) . display ( ) . to_string ( ) ) ;
assert_eq! (
roots ,
vec! [ ( key ( " ctrl " ) , None ) , ( key ( " p1 " ) , Some ( " p1 " . to_string ( ) ) ) ] ,
" 控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目 "
) ;
let _ = std ::fs ::remove_dir_all ( & base ) ;
}
#[ 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 : Value = serde_json ::from_str ( & claude_local_server_json (
@ -1248,15 +1357,22 @@ mod tests {
#[ test ]
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth ( ) {
let roots = vec! [ ( " e: \\ code-project \\ demo " . to_string ( ) , " p1 " . to_string ( ) ) ] ;
let roots = vec! [
( " e: \\ code-project " . to_string ( ) , None ) ,
( " e: \\ code-project \\ demo " . to_string ( ) , Some ( " p1 " . to_string ( ) ) ) ,
] ;
let block = render_dsh_block ( 27190 , & roots ) ;
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 ( r # "const roots = {"e:\\code-project\\demo":"p1"};"# ) , " 白名单内嵌 " ) ;
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'; " ) , " 未命中回退全局地址 " ) ;