DSH 桌面版只有一个 Host 进程、启动目录固定,托管块的 !!js 只在启动时求值,拿不到会话信息; web/acp 同样一个进程服务多个工作区。改由炼境分发一个会话级插件:会话创建(agent/created)时 按会话工作目录匹配炼境登记的项目白名单,命中才在该会话作用域挂连项目专用地址的 mcp-client, 同名工具覆盖全局 lian-jing,其他会话不受影响。 - 插件随二进制打包(resources/dsh/lianjing-session-mcp.v1.mjs),刷新 DSH 托管块时写到 %USERPROFILE%\.lian-jing\dsh\;文件名带版本号(Node 按 URL 缓存 ESM) - 托管块在全局行之后追加插件条目(port、shim、roots 以 JSON 写入);插件文件写失败时只保留全局行 - 插件防御:headless 交给全局行;特性探测不符即降级为全局;取认证 ≤3 秒、整次挂载 ≤5 秒; 所有回调吞掉异常(Host 对未处理异常会崩溃,agent/created 串行失败会让会话创建失败) - 块外手写的 lianjing-session-mcp 条目同样拒绝覆盖 验证:cargo test 246 passed;vitest 插件用例 6 条通过、typecheck 通过;端到端在 DSH 0.2.0-rc.2 与 0.1.5-rc.2 上,项目会话经真实稳定入口带认证连上项目地址,其他会话无请求。 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2034 lines
87 KiB
Rust
2034 lines
87 KiB
Rust
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)。
|
||
|
||
/// 会话级插件文件名。插件内容变化时必须递增版本号:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重读。
|
||
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";
|
||
|
||
/// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.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 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, 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)));
|
||
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| {
|
||
matches!(
|
||
entry.get("id").and_then(serde_yaml::Value::as_str),
|
||
Some("mcp-lian-jing" | DSH_SESSION_PLUGIN_ID)
|
||
) || 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 = ¤t[end + DSH_MANAGED_END.len()..];
|
||
if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? {
|
||
return Err(MANUAL.into());
|
||
}
|
||
Ok(format!(
|
||
"{}{}{}",
|
||
¤t[..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(¤t, &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}", ¤t[..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!(
|
||
"{}{}",
|
||
¤t[..start],
|
||
¤t[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());
|
||
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 = 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));
|
||
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)
|
||
assert!(DSH_SESSION_PLUGIN_FILE.starts_with("lianjing-session-mcp.v"));
|
||
assert!(DSH_SESSION_PLUGIN_SOURCE
|
||
.contains(&format!("export const name = \"{DSH_SESSION_PLUGIN_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",
|
||
] {
|
||
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 路径应通过"
|
||
);
|
||
}
|
||
}
|