dev-manager-tauri/src-tauri/src/mcp_inject.rs
lanrtop fe0458fe97
All checks were successful
Push & PR Check / check (push) Successful in 1m6s
Push & PR Check / check (pull_request) Successful in 1m4s
fix(mcp/parity): P9 审查修复——受限子代理不挂载、条目 id 带版本、插件缺失守卫、规范路径键
对抗审查(11 个 agent,6 条成立,均 low):
- 受限子代理(toolFilter)会绕过限制:挂在子代理自己作用域的工具不受过滤。改为经 tools.view 判断,
  受限或无法判断即不挂;根会话与未受限子代理照常挂载
- 版本号只编进文件名时,运行中的 DSH 不会重载新插件(同 id 只改 name 不重载):条目 id 改为
  lianjing-session-mcp-v1,与文件名同一版本;新增内容哈希测试,改插件漏升版本即失败;块外手写按前缀拒绝
- 插件文件缺失时 DSH 0.1.x 启动失败:条目加 `disabled: !!js` 文件存在性守卫
- GUI 会话工作目录是 realpath:白名单同时写入登记路径的规范形态(junction、8.3 短名)
- 补测试:分层停止哨兵、取认证失败与超时(.cmd 夹具)、受限/未受限/无法判断的子代理
- 蓝图如实更正首轮端到端只有插件一行;补测完整托管块

验证:cargo test 247 passed;vitest 16 passed(插件 8 条)、typecheck 通过;完整托管块端到端在
DSH 0.2.0-rc.2 与 0.1.5-rc.2 上项目会话带认证连上项目地址、其他会话无请求;插件文件缺失时两版都正常启动。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:04:54 +09:00

2120 lines
91 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::{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}")
}
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
/// 空路径经 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 liangjing_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?
.join(".claude")
.join("liangjing.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> {
match std::fs::read_to_string(path) {
Ok(content) if content.trim().is_empty() => Ok(json!({})),
Ok(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()
))
}
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
}
}
/// 现有 JSON 文件与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
fn json_file_equals(path: &std::path::Path, value: &Value) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
.is_some_and(|existing| &existing == value)
}
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
if json_file_equals(path, value) {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
}
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
crate::write_if_content_changed(path, &content)
.map(|_| ())
.map_err(|error| format!("写入 {label} 失败: {error}"))
}
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
let path = root.join(".lian-jing").join("context.json");
let mut ctx = read_context_object(&path, "canonical 项目上下文")?;
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_context_object(&path, &ctx, "canonical 项目上下文")
}
fn write_canonical_context(project_id: &str) -> Result<(), String> {
let root = project_root(project_id)?;
write_canonical_context_at(&root, project_id)
}
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
let path = liangjing_path(project_id)?;
let mut ctx = read_context_object(&path, "Claude 兼容上下文")?;
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_context_object(&path, &ctx, "Claude 兼容上下文")
}
/// 查找 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(按「未知」处理,不写入)。
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 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 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 {
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_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 {
warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into());
return;
};
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"],
)
};
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 { "原条目未能恢复" }
))
}
},
},
},
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) => warn(format!("Claude 项目级 MCP 同步: {error}")),
}
}
/// 进程内所有「炼境 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:。
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 {
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();
if !has_token {
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\ 还原为 \\),得到普通路径。
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 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
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)),
}
}
/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。exclude 用于删除项目时提前剔除白名单。
pub fn ensure_dsh_patch(
conn: &rusqlite::Connection,
port: u16,
exclude: Option<&str>,
) -> Result<bool, String> {
let home = 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())
})?;
if !home.is_dir() {
return Ok(false);
}
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}")),
};
// 插件文件写失败时托管块只保留全局行(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();
let block = render_dsh_block(
port,
&dsh_project_roots(
conn,
&crate::credential_store::WindowsCredentialStore,
exclude,
),
session.as_ref(),
);
let next = upsert_dsh_block(&current, &block)?;
if next != current {
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
}
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())
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
if let Err(error) = result {
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!("DSH 托管块刷新失败: {error}"),
Some(&json!({ "client": "dsh" }).to_string()),
);
}
}
/// 清理旧组级全局注册(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> {
if json_file_equals(path, value) {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
}
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
crate::write_if_content_changed(path, &content)
.map(|_| ())
.map_err(|e| format!("写入 settings.json 失败: {}", e))
}
fn upsert_codex_mcp_config(
path: &PathBuf,
server_url: &str,
header_helper: &str,
) -> Result<(), String> {
let helper_literal = serde_json::to_string(header_helper)
.map_err(|_| "MCP helper 配置序列化失败".to_string())?;
let block = format!(
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n"
);
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!("读取 Codex config.toml 失败: {error}")),
};
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()
))
}
(None, None)
if current
.lines()
.any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) =>
{
return Err(format!(
"{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖",
path.display()
));
}
(None, None) if current.trim().is_empty() => block,
(None, None) => format!("{}\n{}", current.trim_end(), block),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 .codex 目录失败: {error}"))?;
}
crate::write_if_content_changed(path, &next)
.map(|_| ())
.map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
}
fn remove_codex_mcp_config(path: &PathBuf) -> 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 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}"))
}
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
/// 并清理旧组级 key(迁移内建于重注入)
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
let _guard = mcp_op_lock();
inject_project_mcp_locked(project_id, true)
}
/// 调用方须持有 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)?;
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);
write_canonical_context(project_id)?;
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
let path = settings_path(project_id)?;
let mut settings = read_json(&path)?;
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(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})?;
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
write_liangjing_context(project_id, port)?;
// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。
try_register_globally(port);
let root = project_root(project_id)?;
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
sync_claude_local(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> {
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)?)?;
let path = settings_path(project_id)?;
if path.exists() {
let mut settings = read_json(&path)?;
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(MCP_KEY);
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(&path, &settings)?;
}
Ok(())
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 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 配置
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => return vec![e.to_string()],
};
let project_ids: Vec<String> = {
let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") {
Ok(s) => s,
Err(e) => return vec![e.to_string()],
};
stmt.query_map([], |row| row.get(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
drop(conn);
let mut errors = Vec::new();
for project_id in project_ids {
let _guard = mcp_op_lock();
// 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token)
let still_member = db::pool()
.get()
.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),
)
.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));
}
}
// 逐项目注入时跳过了 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);
}
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_locked(project_id, true) {
// 属于注入结果,归入 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_context_object(&path, &value, "settings.json").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).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();
assert!(upsert_codex_mcp_config(
&path,
"http://127.0.0.1:27190/mcp/projects/project%20one",
helper,
)
.is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
let _ = std::fs::remove_dir_all(&base);
}
#[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 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 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 路径应通过"
);
}
}