两边各自新增全局路由工具(probe_client_parity / register_project):清单插入、 is_project_scoped_tool 排除、STRUCTURED_TOOLS 与摘要分支均保留双方;契约文档合并两段说明。 合并结果 cargo test 377 passed / 0 failed / 6 ignored,pnpm typecheck 通过。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4268 lines
177 KiB
Rust
4268 lines
177 KiB
Rust
use crate::onboard::clients::{
|
||
ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason,
|
||
ProjectFacts,
|
||
};
|
||
use crate::onboard::probe_l1::{
|
||
self, HelperCommand, L1ClientFacts, L1Source, Located, StaticFinding,
|
||
};
|
||
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}")
|
||
}
|
||
|
||
/// 迁移期旧写法(%LOCALAPPDATA% 入口,ensure_header_shim 仍同步写入):L1 视同托管写法、照常执行。
|
||
fn legacy_shim_helper_command(digest: &str) -> String {
|
||
format!("\"%LOCALAPPDATA%\\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 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> {
|
||
read_context_object_with(path, label, &crate::DiskSink)
|
||
}
|
||
|
||
fn read_context_object_with(
|
||
path: &std::path::Path,
|
||
label: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<Value, String> {
|
||
match sink.read(path) {
|
||
Ok(Some(content)) if content.trim().is_empty() => Ok(json!({})),
|
||
Ok(Some(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()
|
||
))
|
||
}
|
||
}),
|
||
Ok(None) => Ok(json!({})),
|
||
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
|
||
}
|
||
}
|
||
|
||
/// 现有 JSON 文本与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
|
||
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
|
||
fn json_text_equals(existing: Option<&str>, value: &Value) -> bool {
|
||
existing
|
||
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
|
||
.is_some_and(|existing| &existing == value)
|
||
}
|
||
|
||
/// 语义相同不写;否则以 pretty JSON 经写入汇写入(父目录由写入汇按需创建)。
|
||
fn write_json_with(
|
||
path: &std::path::Path,
|
||
value: &Value,
|
||
label: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<(), String> {
|
||
let existing = sink
|
||
.read(path)
|
||
.map_err(|error| format!("读取 {label} 失败: {error}"))?;
|
||
if json_text_equals(existing.as_deref(), value) {
|
||
return Ok(());
|
||
}
|
||
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
|
||
sink.write(path, &content)
|
||
.map(|_| ())
|
||
.map_err(|error| format!("写入 {label} 失败: {error}"))
|
||
}
|
||
|
||
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
|
||
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
|
||
#[cfg(test)]
|
||
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
|
||
write_canonical_context_with(root, project_id, &crate::DiskSink)
|
||
}
|
||
|
||
pub(crate) fn write_canonical_context_with(
|
||
root: &std::path::Path,
|
||
project_id: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<(), String> {
|
||
let path = root.join(".lian-jing").join("context.json");
|
||
let mut ctx = read_context_object_with(&path, "canonical 项目上下文", sink)?;
|
||
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_json_with(&path, &ctx, "canonical 项目上下文", sink)
|
||
}
|
||
|
||
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
|
||
pub(crate) fn write_liangjing_context_with(
|
||
root: &std::path::Path,
|
||
project_id: &str,
|
||
port: u16,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<(), String> {
|
||
let path = root.join(".claude").join("liangjing.json");
|
||
let mut ctx = read_context_object_with(&path, "Claude 兼容上下文", sink)?;
|
||
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_json_with(&path, &ctx, "Claude 兼容上下文", sink)
|
||
}
|
||
|
||
/// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容):
|
||
/// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。
|
||
pub(crate) fn write_settings_compat_with(
|
||
root: &std::path::Path,
|
||
server_url: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<(), String> {
|
||
let path = root.join(".claude").join("settings.json");
|
||
let mut settings = match sink.read(&path) {
|
||
Ok(None) => json!({}),
|
||
Ok(Some(content)) if content.trim().is_empty() => json!({}),
|
||
Ok(Some(content)) => serde_json::from_str(&content).map_err(|e| {
|
||
format!(
|
||
"settings.json 格式错误,请手动修复后重试({}): {}",
|
||
path.display(),
|
||
e
|
||
)
|
||
})?,
|
||
Err(e) => return Err(format!("读取 settings.json 失败: {}", e)),
|
||
};
|
||
|
||
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_with(&path, &settings, "settings.json", sink)
|
||
}
|
||
|
||
/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。
|
||
/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份;
|
||
/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。
|
||
/// 失败时带出真正出错的文件,供计划的阻断项指向它。
|
||
pub(crate) fn write_project_identity_files_with(
|
||
root: &std::path::Path,
|
||
project_id: &str,
|
||
port: u16,
|
||
server_url: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<(), crate::WriteFailure> {
|
||
let context = root.join(".lian-jing").join("context.json");
|
||
write_canonical_context_with(root, project_id, sink)
|
||
.map_err(crate::WriteFailure::at(&context))?;
|
||
let settings = root.join(".claude").join("settings.json");
|
||
write_settings_compat_with(root, server_url, sink)
|
||
.map_err(crate::WriteFailure::at(&settings))?;
|
||
let liangjing = root.join(".claude").join("liangjing.json");
|
||
write_liangjing_context_with(root, project_id, port, sink)
|
||
.map_err(crate::WriteFailure::at(&liangjing))
|
||
}
|
||
|
||
/// 查找 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(按「未知」处理,不写入)。
|
||
/// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。
|
||
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
|
||
use crate::onboard::probe_l1::ClaudeScope;
|
||
let parsed = crate::onboard::probe_l1::parse_claude_mcp_get(output)?;
|
||
match parsed.scope {
|
||
ClaudeScope::Local => Some(ClaudeEffective::Local(parsed.url?)),
|
||
ClaudeScope::Other(_) | ClaudeScope::Absent => 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)>,
|
||
) -> ClientOutcome {
|
||
let config = read_claude_config();
|
||
sync_claude_local_with(project_id, root, desired, config.as_ref(), &|| {
|
||
let (claude_bin, git_bash) = (find_claude_bin()?, find_git_bash()?);
|
||
let cli: ClaudeCli =
|
||
Box::new(move |args: &[&str]| run_claude_cli(&claude_bin, &git_bash, Some(root), args));
|
||
Some(cli)
|
||
})
|
||
}
|
||
|
||
/// 在项目目录执行一条 claude 子命令,Ok/Err 都携带 CLI 输出。测试注入假实现,不起真实 CLI。
|
||
type ClaudeCli<'a> = Box<dyn Fn(&[&str]) -> Result<String, String> + 'a>;
|
||
|
||
/// sync_claude_local 的依赖注入版:Claude 全局配置与 CLI 执行器由调用方给出;
|
||
/// resolve_cli 返回 None 表示 claude CLI 或 git-bash 未找到。快路径不解析 CLI。
|
||
fn sync_claude_local_with<'a>(
|
||
project_id: &str,
|
||
root: &'a std::path::Path,
|
||
desired: Option<(String, String)>,
|
||
config: Option<&Value>,
|
||
resolve_cli: &dyn Fn() -> Option<ClaudeCli<'a>>,
|
||
) -> ClientOutcome {
|
||
let context = inject_log_context(project_id, "claude");
|
||
let warn = |message: &str| db::log_event(CLIENT_SYNC_LOG, "warn", message, Some(&context));
|
||
let unavailable = |reason: String| {
|
||
warn(&reason);
|
||
ClientOutcome::Unavailable { reason }
|
||
};
|
||
let root_text = root.display().to_string();
|
||
let managed_all = config
|
||
.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 ClientOutcome::Converged
|
||
}
|
||
None if config.is_some() && managed_all.is_empty() => return ClientOutcome::Converged,
|
||
_ => {}
|
||
}
|
||
if !root.is_dir() {
|
||
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
|
||
return unavailable(format!(
|
||
"项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
|
||
));
|
||
}
|
||
let Some(cli) = resolve_cli() else {
|
||
return unavailable("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into());
|
||
};
|
||
let effective = match cli(&["mcp", "get", MCP_KEY]) {
|
||
Ok(output) | Err(output) => parse_claude_mcp_get(&output),
|
||
};
|
||
let Some(effective) = effective else {
|
||
return unavailable(
|
||
"无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动"
|
||
.into(),
|
||
);
|
||
};
|
||
let remove = || cli(&["mcp", "remove", MCP_KEY, "--scope", "local"]);
|
||
let add = |entry: &Value| {
|
||
let payload = entry.to_string();
|
||
cli(&[
|
||
"mcp",
|
||
"add-json",
|
||
MCP_KEY,
|
||
payload.as_str(),
|
||
"--scope",
|
||
"local",
|
||
])
|
||
};
|
||
let failed = |reason: String| -> (ClientOutcome, Result<String, String>) {
|
||
(
|
||
ClientOutcome::Failed {
|
||
reason: reason.clone(),
|
||
},
|
||
Err(reason),
|
||
)
|
||
};
|
||
// (结构化结果, 日志):日志类别与文案保持原样
|
||
let (outcome, log): (ClientOutcome, 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) => failed(format!("旧条目移除失败: {error}")),
|
||
Ok(_) => match desired {
|
||
None => (
|
||
ClientOutcome::Removed,
|
||
Ok("Claude 项目级 MCP 条目已移除".into()),
|
||
),
|
||
Some(entry) => match add(entry) {
|
||
Ok(_) => (
|
||
ClientOutcome::Refreshed,
|
||
Ok("Claude 项目级 MCP 条目已刷新".into()),
|
||
),
|
||
Err(error) => {
|
||
// 回滚:用读到的原条目写回,避免项目失去 local 条目
|
||
let restored = managed_here
|
||
.first()
|
||
.map(|(_, previous)| add(previous).is_ok())
|
||
.unwrap_or(false);
|
||
failed(format!(
|
||
"新条目写入失败: {error}({})",
|
||
if restored {
|
||
"已恢复原条目"
|
||
} else {
|
||
"原条目未能恢复"
|
||
}
|
||
))
|
||
}
|
||
},
|
||
},
|
||
},
|
||
Some(owner) => {
|
||
let message = format!(
|
||
"本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动"
|
||
);
|
||
(ClientOutcome::OtherProject { owner }, Err(message))
|
||
}
|
||
None => (
|
||
ClientOutcome::ManualKept,
|
||
Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()),
|
||
),
|
||
},
|
||
(ClaudeEffective::NotLocal, Some(entry)) => match add(entry) {
|
||
Ok(_) => (
|
||
ClientOutcome::Applied,
|
||
Ok("Claude 项目级 MCP 条目已写入".into()),
|
||
),
|
||
Err(error) => failed(format!("写入失败: {error}")),
|
||
},
|
||
(ClaudeEffective::NotLocal, None) => {
|
||
if managed_all.is_empty() {
|
||
return ClientOutcome::Converged;
|
||
}
|
||
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
|
||
let reason = format!(
|
||
"本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
|
||
);
|
||
(
|
||
ClientOutcome::NeedsHuman {
|
||
reason: reason.clone(),
|
||
},
|
||
Err(reason),
|
||
)
|
||
}
|
||
};
|
||
match log {
|
||
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
|
||
Err(error) => warn(&format!("Claude 项目级 MCP 同步: {error}")),
|
||
}
|
||
outcome
|
||
}
|
||
|
||
/// 只读判定 Claude 在本项目根目录下的 local 条目状态(不起 CLI):只看覆盖本根目录的项目键。
|
||
fn claude_static_outcome(
|
||
config: &Value,
|
||
project_id: &str,
|
||
root: &std::path::Path,
|
||
desired: &Value,
|
||
) -> ClientOutcome {
|
||
let root_text = root.display().to_string();
|
||
// Claude 以 git 根为项目键:根目录自身是 git 根时只认等于根目录的键,祖先目录的条目不在本目录生效
|
||
let own_git_root = root.join(".git").exists();
|
||
let applies = |key: &str| {
|
||
if own_git_root {
|
||
normalize_root_key(key) == normalize_root_key(&root_text)
|
||
} else {
|
||
key_covers_root(key, &root_text)
|
||
}
|
||
};
|
||
let mut entries: Vec<(String, &Value)> = config
|
||
.get("projects")
|
||
.and_then(Value::as_object)
|
||
.map(|projects| {
|
||
projects
|
||
.iter()
|
||
.filter(|(key, _)| applies(key))
|
||
.filter_map(|(key, project)| {
|
||
Some((
|
||
normalize_root_key(key),
|
||
project.get("mcpServers")?.get(MCP_KEY)?,
|
||
))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
// 嵌套项目:最具体(最长)的键才是本目录生效的条目,祖先目录的条目属于外层项目
|
||
entries.sort_by_key(|(key, _)| std::cmp::Reverse(key.len()));
|
||
let Some((_, effective)) = entries.first() else {
|
||
return ClientOutcome::Missing;
|
||
};
|
||
if *effective == desired {
|
||
return ClientOutcome::Converged;
|
||
}
|
||
match effective
|
||
.get("url")
|
||
.and_then(Value::as_str)
|
||
.and_then(lianjing_project_of)
|
||
{
|
||
None => ClientOutcome::ManualKept,
|
||
Some(owner) if owner == project_id => ClientOutcome::Missing,
|
||
Some(owner) => ClientOutcome::OtherProject { owner },
|
||
}
|
||
}
|
||
|
||
/// 进程内所有「炼境 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:。
|
||
/// 登记的同目录判定(projects::registration_path_key)复用本函数,两处口径不得分叉。
|
||
pub(crate) 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 {
|
||
if !is_project_injected(store, &id) {
|
||
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\ 还原为 \\),得到普通路径。
|
||
pub(crate) 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_MANUAL_MESSAGE;
|
||
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)),
|
||
}
|
||
}
|
||
|
||
const DSH_MANUAL_MESSAGE: &str =
|
||
"DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
|
||
|
||
/// DSH 配置目录:DSH_HOME 优先,否则 %USERPROFILE%\.dsh。
|
||
fn dsh_home() -> Result<PathBuf, String> {
|
||
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())
|
||
})
|
||
}
|
||
|
||
/// 未安装 DSH(无 ~/.dsh)时记 not_installed;块外手写 lian-jing 记 manual_kept;内容不变记 converged。
|
||
/// exclude 用于删除项目时提前剔除白名单。
|
||
pub fn ensure_dsh_patch(
|
||
conn: &rusqlite::Connection,
|
||
port: u16,
|
||
exclude: Option<&str>,
|
||
) -> Result<ClientOutcome, String> {
|
||
let home = dsh_home()?;
|
||
if !home.is_dir() {
|
||
return Ok(ClientOutcome::NotInstalled);
|
||
}
|
||
// 插件文件写失败时托管块只保留全局行(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();
|
||
ensure_dsh_patch_at(
|
||
conn,
|
||
&crate::credential_store::WindowsCredentialStore,
|
||
port,
|
||
exclude,
|
||
&home,
|
||
session.as_ref(),
|
||
)
|
||
}
|
||
|
||
/// ensure_dsh_patch 的依赖注入版:DSH 目录、凭据存储与会话插件引用由调用方给出(测试不碰用户 ~/.dsh)。
|
||
fn ensure_dsh_patch_at(
|
||
conn: &rusqlite::Connection,
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
port: u16,
|
||
exclude: Option<&str>,
|
||
home: &std::path::Path,
|
||
session: Option<&DshSessionPlugin>,
|
||
) -> Result<ClientOutcome, String> {
|
||
if !home.is_dir() {
|
||
return Ok(ClientOutcome::NotInstalled);
|
||
}
|
||
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}")),
|
||
};
|
||
let block = render_dsh_block(port, &dsh_project_roots(conn, store, exclude), session);
|
||
let next = match upsert_dsh_block(¤t, &block) {
|
||
Ok(next) => next,
|
||
Err(error) if error == DSH_MANUAL_MESSAGE => return Ok(ClientOutcome::ManualKept),
|
||
Err(error) => return Err(error),
|
||
};
|
||
if next == current {
|
||
return Ok(ClientOutcome::Converged);
|
||
}
|
||
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
|
||
Ok(ClientOutcome::Applied)
|
||
}
|
||
|
||
/// 只读判定 DSH 托管块是否已把本项目根目录映射到本项目、且指向当前端口(不写文件)。
|
||
fn dsh_static_outcome(
|
||
patch: &str,
|
||
root_keys: &[String],
|
||
project_id: &str,
|
||
port: u16,
|
||
) -> ClientOutcome {
|
||
let (Some(start), Some(end)) = (patch.find(DSH_MANAGED_START), patch.find(DSH_MANAGED_END))
|
||
else {
|
||
return match has_manual_lian_jing(patch) {
|
||
Ok(true) => ClientOutcome::ManualKept,
|
||
Ok(false) => ClientOutcome::Missing,
|
||
Err(reason) => ClientOutcome::Unavailable { reason },
|
||
};
|
||
};
|
||
if end < start {
|
||
// apply 遇到同样的标记错序会报 failed;静态判定如实标需人工
|
||
return ClientOutcome::NeedsHuman {
|
||
reason: "DSH 补丁文件中的炼境托管块不完整或顺序错误".into(),
|
||
};
|
||
}
|
||
// 块外手写 lian-jing 时 apply 永远保留手写、不更新块:静态判定与之同口径
|
||
let outside = format!(
|
||
"{}{}",
|
||
&patch[..start],
|
||
&patch[end + DSH_MANAGED_END.len()..]
|
||
);
|
||
match has_manual_lian_jing(&outside) {
|
||
Ok(true) => return ClientOutcome::ManualKept,
|
||
Ok(false) => {}
|
||
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||
}
|
||
let block = &patch[start..end];
|
||
if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) {
|
||
return ClientOutcome::Missing;
|
||
}
|
||
let roots: Option<serde_json::Map<String, Value>> = block.lines().find_map(|line| {
|
||
let json = line
|
||
.trim()
|
||
.strip_prefix("const roots = ")?
|
||
.strip_suffix(';')?;
|
||
serde_json::from_str(json).ok()
|
||
});
|
||
let mapped = roots.is_some_and(|roots| {
|
||
root_keys
|
||
.iter()
|
||
.any(|key| roots.get(key).and_then(Value::as_str) == Some(project_id))
|
||
});
|
||
if mapped {
|
||
ClientOutcome::Converged
|
||
} else {
|
||
ClientOutcome::Missing
|
||
}
|
||
}
|
||
|
||
/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。
|
||
static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
/// 刷新 DSH 托管块的 best-effort 包装:失败记日志并返回 failed,不中断调用方。
|
||
pub fn refresh_dsh_patch(exclude: Option<&str>) -> ClientOutcome {
|
||
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));
|
||
let log_warn = |error: &str| {
|
||
db::log_event(
|
||
CLIENT_SYNC_LOG,
|
||
"warn",
|
||
&format!("DSH 托管块刷新失败: {error}"),
|
||
Some(&json!({ "client": "dsh" }).to_string()),
|
||
)
|
||
};
|
||
match result {
|
||
Ok(ClientOutcome::ManualKept) => {
|
||
// 原为报错路径:手写条目保留后仍按原文案留痕
|
||
log_warn(DSH_MANUAL_MESSAGE);
|
||
ClientOutcome::ManualKept
|
||
}
|
||
Ok(outcome) => outcome,
|
||
Err(error) => {
|
||
log_warn(&error);
|
||
ClientOutcome::Failed { reason: error }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 清理旧组级全局注册(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> {
|
||
write_json_with(path, value, "settings.json", &crate::DiskSink)
|
||
}
|
||
|
||
fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, String> {
|
||
let helper_literal = serde_json::to_string(header_helper)
|
||
.map_err(|_| "MCP helper 配置序列化失败".to_string())?;
|
||
Ok(format!(
|
||
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n"
|
||
))
|
||
}
|
||
|
||
fn has_manual_codex_entry(text: &str) -> bool {
|
||
probe_l1::codex_has_table(text)
|
||
}
|
||
|
||
/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定
|
||
/// ——Codex 只在目录受信任时加载项目配置(P4-0 H-P8-1),由 L1 的 `codex mcp get` 自省给出。
|
||
fn codex_static_outcome(
|
||
current: Option<&str>,
|
||
server_url: &str,
|
||
header_helper: &str,
|
||
) -> ClientOutcome {
|
||
let Some(current) = current else {
|
||
return ClientOutcome::Missing;
|
||
};
|
||
let normalized = current.replace("\r\n", "\n");
|
||
let outside_block = match (
|
||
normalized.find(CODEX_MCP_MANAGED_START),
|
||
normalized.find(CODEX_MCP_MANAGED_END),
|
||
) {
|
||
(Some(start), Some(end)) if end >= start => format!(
|
||
"{}{}",
|
||
&normalized[..start],
|
||
&normalized[end + CODEX_MCP_MANAGED_END.len()..]
|
||
),
|
||
(None, None) => normalized.clone(),
|
||
_ => {
|
||
return ClientOutcome::NeedsHuman {
|
||
reason: "炼境 Codex 管理块不完整或顺序错误".into(),
|
||
}
|
||
}
|
||
};
|
||
let manual = has_manual_codex_entry(&outside_block);
|
||
let has_block = normalized.contains(CODEX_MCP_MANAGED_START);
|
||
match render_codex_block(server_url, header_helper) {
|
||
// 托管块与块外手写同名表并存:Codex 加载配置会因重复表失败
|
||
_ if has_block && manual => ClientOutcome::NeedsHuman {
|
||
reason: format!("项目 .codex/config.toml 托管块外另有手写 [mcp_servers.{MCP_KEY}],重复定义会使 Codex 配置加载失败"),
|
||
},
|
||
Ok(block) if normalized.contains(&block) => ClientOutcome::Converged,
|
||
_ if manual => ClientOutcome::ManualKept,
|
||
_ => ClientOutcome::Missing,
|
||
}
|
||
}
|
||
|
||
/// 写入或原位更新 Codex 托管块。内容不变记 converged;块外已手工定义同名 server 记 manual_kept(不覆盖)。
|
||
fn upsert_codex_mcp_config(
|
||
path: &PathBuf,
|
||
server_url: &str,
|
||
header_helper: &str,
|
||
) -> Result<ClientOutcome, String> {
|
||
upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink)
|
||
}
|
||
|
||
pub(crate) fn upsert_codex_mcp_config_with(
|
||
path: &std::path::Path,
|
||
server_url: &str,
|
||
header_helper: &str,
|
||
sink: &dyn crate::WriteSink,
|
||
) -> Result<ClientOutcome, String> {
|
||
let block = render_codex_block(server_url, header_helper)?;
|
||
let current = sink
|
||
.read(path)
|
||
.map_err(|error| format!("读取 Codex config.toml 失败: {error}"))?
|
||
.unwrap_or_default();
|
||
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()
|
||
))
|
||
}
|
||
// 已手工定义同名 server:炼境不覆盖(原为整体报错,会连带阻断 Claude 与 DSH 的接入)
|
||
(None, None) if has_manual_codex_entry(¤t) => return Ok(ClientOutcome::ManualKept),
|
||
(None, None) if current.trim().is_empty() => block,
|
||
(None, None) => format!("{}\n{}", current.trim_end(), block),
|
||
};
|
||
|
||
sink.write(path, &next)
|
||
.map(|written| {
|
||
if written {
|
||
ClientOutcome::Applied
|
||
} else {
|
||
ClientOutcome::Converged
|
||
}
|
||
})
|
||
.map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
|
||
}
|
||
|
||
/// 条目 URL 指向别的炼境项目路由:同路径重复登记时,删除一条登记不得连带清掉另一条的接入。
|
||
/// 不是炼境项目路由的旧地址(全局 /mcp、组级地址)视为本项目遗留,照旧清理。
|
||
fn owned_by_other_project(url: &str, project_id: &str) -> bool {
|
||
lianjing_project_of(url).is_some_and(|owner| owner != project_id)
|
||
}
|
||
|
||
fn remove_codex_mcp_config(path: &PathBuf, project_id: &str) -> 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 block_url = current[start..end]
|
||
.lines()
|
||
.find_map(|line| line.trim().strip_prefix("url = \"")?.strip_suffix('"'));
|
||
if block_url.is_some_and(|url| owned_by_other_project(url, project_id)) {
|
||
return Ok(());
|
||
}
|
||
|
||
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}"))
|
||
}
|
||
|
||
// ── 公开接口 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// 向项目注入单一 lian-jing MCP 配置(canonical context、Codex 托管块、Claude 兼容投影与 local 条目、
|
||
/// DSH 白名单),并清理旧组级 key(迁移内建于重注入)。只有 Codex 托管块写入失败算整体失败(沿用旧语义),
|
||
/// Claude 与 DSH 一直是 best-effort;需要逐端结果时用 inject_project_mcp_report。
|
||
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||
inject_project_mcp_report(project_id).and_then(|reports| match hard_failure(&reports) {
|
||
Some(error) => Err(error),
|
||
None => Ok(()),
|
||
})
|
||
}
|
||
|
||
/// 注入并返回三端逐端结果(agent-client-parity P8b)。
|
||
pub fn inject_project_mcp_report(project_id: &str) -> Result<Vec<ClientReport>, String> {
|
||
let _guard = mcp_op_lock();
|
||
inject_project_mcp_locked(project_id, true)
|
||
}
|
||
|
||
/// 项目文件里的 Codex 托管块写入失败沿用旧语义作为整体失败。
|
||
fn hard_failure(reports: &[ClientReport]) -> Option<String> {
|
||
reports
|
||
.iter()
|
||
.find_map(|report| match (report.client, &report.outcome) {
|
||
(ClientId::Codex, ClientOutcome::Failed { reason }) => Some(reason.clone()),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,
|
||
/// 由调用方在末尾统一刷新一次。项目身份与兼容投影写失败整体报错;三端客户端配置经适配器注册表逐端收敛,
|
||
/// 一端失败不中断其他端。
|
||
fn inject_project_mcp_locked(
|
||
project_id: &str,
|
||
refresh_dsh: bool,
|
||
) -> Result<Vec<ClientReport>, 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);
|
||
let root = project_root(project_id)?;
|
||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||
write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?;
|
||
|
||
let facts = project_facts(project_id, &root);
|
||
let desired = DesiredMcp {
|
||
port,
|
||
server_url,
|
||
header_helper,
|
||
refresh_dsh,
|
||
};
|
||
let reports: Vec<ClientReport> = crate::onboard::clients::registry()
|
||
.iter()
|
||
.map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired)))
|
||
.collect();
|
||
// 诊断按 project_id 取最后一条 mcp_inject 事件:成功日志须在三端收敛之后、且只在没有整体失败时写
|
||
let ctx = json!({ "project_id": project_id }).to_string();
|
||
match hard_failure(&reports) {
|
||
None => log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)),
|
||
Some(error) => log_event(
|
||
"mcp_inject",
|
||
"warn",
|
||
&format!("注入失败: {error}"),
|
||
Some(&ctx),
|
||
),
|
||
}
|
||
Ok(reports)
|
||
}
|
||
|
||
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
|
||
ProjectFacts {
|
||
project_id,
|
||
root,
|
||
control_plane: project_wants_global_scope(root),
|
||
wsl: crate::onboard::clients::is_wsl_root(root),
|
||
}
|
||
}
|
||
|
||
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
|
||
/// 根目录与注入同源(project_root 校验登记路径),避免两边对同一项目算出不同的键。
|
||
pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
||
let port = crate::mcp_server::read_port();
|
||
let resolved = crate::credential_store::mcp_auth_digest(project_id)
|
||
.and_then(|digest| project_root(project_id).map(|root| (digest, root)));
|
||
let (digest, root) = match resolved {
|
||
Ok(resolved) => resolved,
|
||
Err(reason) => {
|
||
return crate::onboard::clients::registry()
|
||
.iter()
|
||
.map(|adapter| {
|
||
ClientReport::new(
|
||
adapter.id(),
|
||
ClientOutcome::Unavailable {
|
||
reason: reason.clone(),
|
||
},
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
};
|
||
let header_helper = shim_helper_command(&digest);
|
||
let facts = project_facts(project_id, &root);
|
||
let desired = DesiredMcp {
|
||
port,
|
||
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
|
||
header_helper,
|
||
refresh_dsh: false,
|
||
};
|
||
crate::onboard::clients::registry()
|
||
.iter()
|
||
.map(|adapter| ClientReport::new(adapter.id(), adapter.static_status(&facts, &desired)))
|
||
.collect()
|
||
}
|
||
|
||
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
|
||
|
||
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status
|
||
/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
||
#[allow(dead_code)]
|
||
pub(crate) fn plan_onboarding(
|
||
project_id: &str,
|
||
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||
let root = project_root(project_id)?;
|
||
let port = crate::mcp_server::read_port();
|
||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
||
let header_helper = shim_helper_command(&digest);
|
||
let project = crate::onboard::plan::ProjectWrite {
|
||
project_id,
|
||
port,
|
||
server_url: &server_url,
|
||
header_helper: &header_helper,
|
||
};
|
||
Ok(crate::onboard::plan::plan_onboarding(
|
||
&root,
|
||
&project,
|
||
plan_machine_steps(project_id),
|
||
codex_trust(&root),
|
||
))
|
||
}
|
||
|
||
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
|
||
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
|
||
#[allow(dead_code)]
|
||
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
|
||
let mut steps = vec![
|
||
token_step(crate::credential_store::mcp_auth_token_state(
|
||
&crate::credential_store::WindowsCredentialStore,
|
||
project_id,
|
||
)),
|
||
shim_step(),
|
||
];
|
||
for report in client_static_status(project_id) {
|
||
let name = match report.client {
|
||
ClientId::Claude => "claude",
|
||
ClientId::Codex => "codex",
|
||
ClientId::Dsh => "dsh",
|
||
};
|
||
steps.push(crate::onboard::plan::MachineStep {
|
||
step: name.into(),
|
||
status: outcome_status(&report.outcome).into(),
|
||
detail: report.outcome.describe(),
|
||
});
|
||
}
|
||
steps
|
||
}
|
||
|
||
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
|
||
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
|
||
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
|
||
#[allow(dead_code)]
|
||
fn token_step(
|
||
state: crate::credential_store::McpAuthTokenState,
|
||
) -> crate::onboard::plan::MachineStep {
|
||
use crate::credential_store::McpAuthTokenState;
|
||
let (status, detail) = match state {
|
||
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
|
||
McpAuthTokenState::Missing => (
|
||
"apply",
|
||
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
|
||
),
|
||
McpAuthTokenState::NeedsRepair(reason) => (
|
||
"needs_human",
|
||
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
|
||
),
|
||
};
|
||
crate::onboard::plan::MachineStep {
|
||
step: "token".into(),
|
||
status: status.into(),
|
||
detail,
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn shim_step() -> crate::onboard::plan::MachineStep {
|
||
let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) {
|
||
(Ok(dir), Ok(exe)) => {
|
||
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
||
let expected = render_header_shim(&exe);
|
||
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
||
("noop", format!("稳定入口已是最新:{}", path.display()))
|
||
} else {
|
||
("apply", format!("将写入稳定入口:{}", path.display()))
|
||
}
|
||
}
|
||
(Err(error), _) => ("unavailable", error),
|
||
(_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()),
|
||
};
|
||
crate::onboard::plan::MachineStep {
|
||
step: "shim".into(),
|
||
status: status.into(),
|
||
detail,
|
||
}
|
||
}
|
||
|
||
/// 只读判定结果 → 计划里的本机动作状态。
|
||
#[allow(dead_code)]
|
||
fn outcome_status(outcome: &ClientOutcome) -> &'static str {
|
||
match outcome {
|
||
ClientOutcome::Converged => "noop",
|
||
ClientOutcome::Missing
|
||
| ClientOutcome::Applied
|
||
| ClientOutcome::Refreshed
|
||
| ClientOutcome::Removed
|
||
| ClientOutcome::Deferred => "apply",
|
||
ClientOutcome::NotInstalled => "not_installed",
|
||
ClientOutcome::NotApplicable { .. } => "not_applicable",
|
||
ClientOutcome::ManualKept => "manual_kept",
|
||
ClientOutcome::OtherProject { .. } => "other_project",
|
||
ClientOutcome::NeedsHuman { .. } => "needs_human",
|
||
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
|
||
}
|
||
}
|
||
|
||
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。
|
||
fn codex_home() -> Option<PathBuf> {
|
||
std::env::var_os("CODEX_HOME")
|
||
.map(PathBuf::from)
|
||
.or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")))
|
||
}
|
||
|
||
/// Codex 是否信任项目目录(只读预判,P4-0 H-P8-1:未信任目录不加载项目 .codex/config.toml)。
|
||
/// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任,
|
||
/// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。
|
||
/// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。
|
||
#[allow(dead_code)]
|
||
fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
|
||
use crate::onboard::plan::CodexTrust;
|
||
let Some(home) = codex_home().filter(|dir| dir.is_dir()) else {
|
||
return CodexTrust::NotInstalled;
|
||
};
|
||
let config = match std::fs::read_to_string(home.join("config.toml")) {
|
||
Ok(text) => text,
|
||
// 装了 Codex 但还没有用户配置:没有任何目录受信任
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||
Err(_) => return CodexTrust::Unknown,
|
||
};
|
||
let mut candidates = vec![root.to_path_buf()];
|
||
candidates.extend(git_main_worktree_root(root));
|
||
if codex_config_trusts(&config, &candidates) {
|
||
CodexTrust::Trusted
|
||
} else {
|
||
CodexTrust::Untrusted
|
||
}
|
||
}
|
||
|
||
/// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根,
|
||
/// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。
|
||
#[allow(dead_code)]
|
||
fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
|
||
let output = crate::silent_cmd("git")
|
||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||
.current_dir(root)
|
||
.output()
|
||
.ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
let common = String::from_utf8(output.stdout).ok()?;
|
||
PathBuf::from(common.trim()).parent().map(PathBuf::from)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool {
|
||
let trusted = codex_trusted_projects(config);
|
||
candidates
|
||
.iter()
|
||
.any(|candidate| trusted.contains(&normalize_trust_path(&candidate.to_string_lossy())))
|
||
}
|
||
|
||
/// Windows 路径比较口径:去 \\?\ 前缀、统一反斜杠、去尾部分隔符、不区分大小写。
|
||
fn normalize_trust_path(path: &str) -> String {
|
||
path.strip_prefix(r"\\?\")
|
||
.unwrap_or(path)
|
||
.replace('/', "\\")
|
||
.trim_end_matches('\\')
|
||
.to_lowercase()
|
||
}
|
||
|
||
/// ~/.codex/config.toml 中 trust_level = "trusted" 的项目路径(已归一化)。不引入 TOML 依赖的最小解析:
|
||
/// 认 `[projects.'<路径>']`(字面量字符串)与 `[projects."<路径>"]`(基本字符串,按 TOML 规则反转义)段,
|
||
/// 以及 `[projects]` 段内的内联表 `'<路径>' = { trust_level = "trusted" }` 与点号键写法。
|
||
/// 键里有非法转义(TOML 本身不合法,Codex 也加载不了)时忽略该项。
|
||
fn codex_trusted_projects(config: &str) -> Vec<String> {
|
||
// None:不在 projects 段;Some(None):[projects];Some(Some(key)):[projects.<key>]
|
||
let mut section: Option<Option<String>> = None;
|
||
let mut trusted = Vec::new();
|
||
for line in config.lines().map(str::trim) {
|
||
if line.starts_with('[') {
|
||
section = parse_projects_header(line);
|
||
continue;
|
||
}
|
||
match §ion {
|
||
Some(Some(key)) if is_trusted_assignment(line) => {
|
||
trusted.push(normalize_trust_path(key));
|
||
}
|
||
Some(None) => {
|
||
let Some((key, rest)) = parse_toml_key(line) else {
|
||
continue;
|
||
};
|
||
let rest = rest.trim_start();
|
||
let here = if let Some(dotted) = rest.strip_prefix('.') {
|
||
is_trusted_assignment(dotted.trim_start())
|
||
} else if let Some(value) = rest.strip_prefix('=') {
|
||
value
|
||
.trim()
|
||
.strip_prefix('{')
|
||
.and_then(|inner| inner.split('}').next())
|
||
.is_some_and(|inner| {
|
||
inner
|
||
.split(',')
|
||
.any(|pair| is_trusted_assignment(pair.trim()))
|
||
})
|
||
} else {
|
||
false
|
||
};
|
||
if here {
|
||
trusted.push(normalize_trust_path(&key));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
trusted
|
||
}
|
||
|
||
/// `[projects]` → Some(None);`[projects.<键>]` → Some(Some(键));其他段(含数组表)→ None。
|
||
fn parse_projects_header(line: &str) -> Option<Option<String>> {
|
||
let inner = line.strip_prefix('[')?.trim_start();
|
||
if inner.starts_with('[') {
|
||
return None;
|
||
}
|
||
let rest = inner.strip_prefix("projects")?.trim_start();
|
||
if rest.starts_with(']') {
|
||
return Some(None);
|
||
}
|
||
let (key, after) = parse_toml_key(rest.strip_prefix('.')?.trim_start())?;
|
||
after.trim_start().starts_with(']').then_some(Some(key))
|
||
}
|
||
|
||
/// `trust_level = "trusted"`(允许单引号与行尾注释)。
|
||
fn is_trusted_assignment(text: &str) -> bool {
|
||
text.split_once('=').is_some_and(|(name, value)| {
|
||
let value = value.trim_start();
|
||
name.trim() == "trust_level"
|
||
&& (value.starts_with("\"trusted\"") || value.starts_with("'trusted'"))
|
||
})
|
||
}
|
||
|
||
/// 解析行首的一个 TOML 键(字面量字符串、基本字符串或裸键),返回(键,剩余文本)。
|
||
/// 基本字符串按 TOML 规则反转义;非法转义或未闭合返回 None。
|
||
fn parse_toml_key(text: &str) -> Option<(String, &str)> {
|
||
if let Some(rest) = text.strip_prefix('\'') {
|
||
let end = rest.find('\'')?;
|
||
return Some((rest[..end].to_string(), &rest[end + 1..]));
|
||
}
|
||
if let Some(rest) = text.strip_prefix('"') {
|
||
let mut key = String::new();
|
||
let mut chars = rest.char_indices();
|
||
while let Some((index, ch)) = chars.next() {
|
||
match ch {
|
||
'"' => return Some((key, &rest[index + 1..])),
|
||
'\\' => {
|
||
let (_, escaped) = chars.next()?;
|
||
let decoded = match escaped {
|
||
'\\' => '\\',
|
||
'"' => '"',
|
||
'b' => '\u{8}',
|
||
't' => '\t',
|
||
'n' => '\n',
|
||
'f' => '\u{c}',
|
||
'r' => '\r',
|
||
'e' => '\u{1b}',
|
||
'u' | 'U' => {
|
||
let len = if escaped == 'u' { 4 } else { 8 };
|
||
let hex = (0..len)
|
||
.map(|_| chars.next().map(|(_, c)| c))
|
||
.collect::<Option<String>>()?;
|
||
char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?
|
||
}
|
||
_ => return None,
|
||
};
|
||
key.push(decoded);
|
||
}
|
||
_ => key.push(ch),
|
||
}
|
||
}
|
||
return None;
|
||
}
|
||
let end = text
|
||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
|
||
.unwrap_or(text.len());
|
||
(end > 0).then(|| (text[..end].to_string(), &text[end..]))
|
||
}
|
||
|
||
// ── 三端客户端适配器(注册表见 onboard::clients::registry)──────────────────────
|
||
|
||
pub(crate) struct ClaudeAdapter;
|
||
pub(crate) struct CodexAdapter;
|
||
pub(crate) struct DshAdapter;
|
||
pub(crate) static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
|
||
pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter;
|
||
pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter;
|
||
|
||
const NA_CONTROL_PLANE: ClientOutcome = ClientOutcome::NotApplicable {
|
||
reason: NotApplicableReason::ControlPlane,
|
||
};
|
||
const NA_WSL: ClientOutcome = ClientOutcome::NotApplicable {
|
||
reason: NotApplicableReason::Wsl,
|
||
};
|
||
|
||
impl ClientAdapter for ClaudeAdapter {
|
||
fn id(&self) -> ClientId {
|
||
ClientId::Claude
|
||
}
|
||
|
||
/// ~/.claude.json(或 CLAUDE_CONFIG_DIR 下的配置)存在即视为已装;不起子进程。
|
||
fn detect(&self) -> bool {
|
||
claude_config_path().is_some_and(|path| path.is_file())
|
||
}
|
||
|
||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
if project.wsl {
|
||
return NA_WSL;
|
||
}
|
||
if !self.detect() {
|
||
return ClientOutcome::NotInstalled;
|
||
}
|
||
let Some(config) = read_claude_config() else {
|
||
return ClientOutcome::Unavailable {
|
||
reason: "无法读取 Claude 全局配置".into(),
|
||
};
|
||
};
|
||
if project.control_plane {
|
||
return claude_control_plane_outcome(&config, project.project_id, project.root);
|
||
}
|
||
let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper);
|
||
claude_static_outcome(&config, project.project_id, project.root, &entry)
|
||
}
|
||
|
||
/// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
|
||
/// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
|
||
/// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。
|
||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
try_register_globally(desired.port);
|
||
if project.wsl {
|
||
return NA_WSL;
|
||
}
|
||
// 未装 Claude(无全局配置、也找不到 CLI)与 DSH 未装同口径记 not_installed,不计失败
|
||
if !self.detect() && find_claude_bin().is_none() {
|
||
return ClientOutcome::NotInstalled;
|
||
}
|
||
let wanted = (!project.control_plane)
|
||
.then(|| (desired.server_url.clone(), desired.header_helper.clone()));
|
||
finalize_for_project(
|
||
project,
|
||
sync_claude_local(project.project_id, project.root, wanted),
|
||
)
|
||
}
|
||
|
||
/// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。
|
||
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
|
||
let not_applicable = l1_not_applicable(project);
|
||
let entries = match not_applicable {
|
||
Some(_) => Vec::new(),
|
||
None => {
|
||
let mcp_json = read_optional(&project.root.join(".mcp.json"))
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|text| {
|
||
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok()
|
||
});
|
||
claude_l1_entries(
|
||
&read_claude_config().unwrap_or(Value::Null),
|
||
mcp_json.as_ref(),
|
||
project.root,
|
||
&desired.server_url,
|
||
)
|
||
}
|
||
};
|
||
L1ClientFacts {
|
||
client: ClientId::Claude,
|
||
not_applicable,
|
||
rules: probe_l1::rules_finding(project.root, "CLAUDE.md", true),
|
||
skill: probe_l1::skill_finding(project.root, ".claude/skills/lian-jing/SKILL.md"),
|
||
source: L1Source::Claude { entries },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Claude 与 DSH 的 L1 不适用情形(与各自 static_status 同口径):WSL 路径项目不支持;控制面项目走全局地址。
|
||
fn l1_not_applicable(project: &ProjectFacts) -> Option<NotApplicableReason> {
|
||
if project.wsl {
|
||
Some(NotApplicableReason::Wsl)
|
||
} else if project.control_plane {
|
||
Some(NotApplicableReason::ControlPlane)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// L1 回查 headersHelper 的候选条目:URL 等于本项目地址的 lian-jing 条目(含手写),按 URL 匹配、不复刻
|
||
/// Claude 的项目键推导(与 managed_claude_entries 同一原则:worktree 映射到主仓库根、NFC 归一化的键也能找到)。
|
||
/// 顺序:覆盖本根目录的项目键(最具体在前)→ 其余项目键 → 项目 .mcp.json(project 作用域)→ 顶层 mcpServers
|
||
/// (user 作用域)。生效哪一条由 claude mcp get 报告的作用域决定,顺序只是兜底。
|
||
fn claude_l1_entries(
|
||
config: &Value,
|
||
mcp_json: Option<&Value>,
|
||
root: &std::path::Path,
|
||
url: &str,
|
||
) -> Vec<probe_l1::ClaudeEntry> {
|
||
use probe_l1::{ClaudeEntry, ClaudeEntrySource};
|
||
let root_text = root.display().to_string();
|
||
let lian_jing = |servers: Option<&Value>| {
|
||
servers
|
||
.and_then(|servers| servers.get(MCP_KEY))
|
||
.filter(|entry| entry.get("url").and_then(Value::as_str) == Some(url))
|
||
.cloned()
|
||
};
|
||
// (覆盖本根目录, 键长度) 降序:覆盖者在前、越具体越前
|
||
let mut local: Vec<((bool, usize), Value)> = config
|
||
.get("projects")
|
||
.and_then(Value::as_object)
|
||
.map(|projects| {
|
||
projects
|
||
.iter()
|
||
.filter_map(|(key, project)| {
|
||
let entry = lian_jing(project.get("mcpServers"))?;
|
||
let rank = (
|
||
key_covers_root(key, &root_text),
|
||
normalize_root_key(key).len(),
|
||
);
|
||
Some((rank, entry))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
local.sort_by(|(left, _), (right, _)| right.cmp(left));
|
||
local
|
||
.into_iter()
|
||
.map(|(_, entry)| ClaudeEntry {
|
||
source: ClaudeEntrySource::Local,
|
||
entry,
|
||
})
|
||
.chain(
|
||
lian_jing(mcp_json.and_then(|json| json.get("mcpServers"))).map(|entry| ClaudeEntry {
|
||
source: ClaudeEntrySource::Project,
|
||
entry,
|
||
}),
|
||
)
|
||
.chain(
|
||
lian_jing(config.get("mcpServers")).map(|entry| ClaudeEntry {
|
||
source: ClaudeEntrySource::User,
|
||
entry,
|
||
}),
|
||
)
|
||
.collect()
|
||
}
|
||
|
||
/// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理),
|
||
/// 有残留报 missing,没有才是 not_applicable。
|
||
fn claude_control_plane_outcome(
|
||
config: &Value,
|
||
project_id: &str,
|
||
root: &std::path::Path,
|
||
) -> ClientOutcome {
|
||
let root_text = root.display().to_string();
|
||
let residual = managed_claude_entries(config, project_id)
|
||
.iter()
|
||
.any(|(key, _)| key_covers_root(key, &root_text));
|
||
if residual {
|
||
ClientOutcome::Missing
|
||
} else {
|
||
NA_CONTROL_PLANE
|
||
}
|
||
}
|
||
|
||
/// 机器级或按边界保留的成功结果,在控制面 / WSL 项目上报 not_applicable;未成功的结果原样保留。
|
||
fn finalize_for_project(project: &ProjectFacts, outcome: ClientOutcome) -> ClientOutcome {
|
||
match (project.control_plane, project.wsl, outcome.is_settled()) {
|
||
(true, _, true) => NA_CONTROL_PLANE,
|
||
(_, true, true) => NA_WSL,
|
||
_ => outcome,
|
||
}
|
||
}
|
||
|
||
impl ClientAdapter for CodexAdapter {
|
||
fn id(&self) -> ClientId {
|
||
ClientId::Codex
|
||
}
|
||
|
||
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。
|
||
fn detect(&self) -> bool {
|
||
codex_home().is_some_and(|dir| dir.is_dir())
|
||
}
|
||
|
||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
if !self.detect() {
|
||
return ClientOutcome::NotInstalled;
|
||
}
|
||
let current = match read_optional(&project.root.join(".codex").join("config.toml")) {
|
||
Ok(current) => current,
|
||
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||
};
|
||
codex_static_outcome(
|
||
current.as_deref(),
|
||
&desired.server_url,
|
||
&desired.header_helper,
|
||
)
|
||
}
|
||
|
||
/// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。
|
||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
let path = project.root.join(".codex").join("config.toml");
|
||
let outcome = upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper)
|
||
.unwrap_or_else(|reason| ClientOutcome::Failed { reason });
|
||
if outcome == ClientOutcome::ManualKept {
|
||
// 原为整体报错(调用方记日志);改为保留后仍留痕,诊断里看得到
|
||
db::log_event(
|
||
CLIENT_SYNC_LOG,
|
||
"warn",
|
||
&format!(
|
||
"{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖",
|
||
path.display()
|
||
),
|
||
Some(&inject_log_context(project.project_id, "codex")),
|
||
);
|
||
}
|
||
outcome
|
||
}
|
||
|
||
/// 生效配置以 codex mcp get --json 为准(L1 执行时取);它把 helper 脱敏,helper 只能回查项目配置。
|
||
fn l1_facts(&self, project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts {
|
||
let config = read_optional(&project.root.join(".codex").join("config.toml"))
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default();
|
||
L1ClientFacts {
|
||
client: ClientId::Codex,
|
||
// 控制面与 WSL 项目的 Codex 同样写项目地址(P8b),照常检查
|
||
not_applicable: None,
|
||
rules: probe_l1::rules_finding(project.root, "AGENTS.md", false),
|
||
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
|
||
source: L1Source::Codex {
|
||
project_entry: has_manual_codex_entry(&config),
|
||
project_url: probe_l1::codex_table_url(&config),
|
||
project_helper: probe_l1::codex_table_helper(&config),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。
|
||
fn read_optional(path: &std::path::Path) -> Result<Option<String>, String> {
|
||
match std::fs::read_to_string(path) {
|
||
Ok(text) => Ok(Some(text)),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||
Err(error) => Err(format!("读取 {} 失败: {error}", path.display())),
|
||
}
|
||
}
|
||
|
||
impl ClientAdapter for DshAdapter {
|
||
fn id(&self) -> ClientId {
|
||
ClientId::Dsh
|
||
}
|
||
|
||
fn detect(&self) -> bool {
|
||
dsh_home().is_ok_and(|home| home.is_dir())
|
||
}
|
||
|
||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
if project.control_plane {
|
||
return NA_CONTROL_PLANE;
|
||
}
|
||
if project.wsl {
|
||
return NA_WSL;
|
||
}
|
||
let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else {
|
||
return ClientOutcome::NotInstalled;
|
||
};
|
||
let patch = match read_optional(&home.join("cordis.patch.yml")) {
|
||
Ok(patch) => patch.unwrap_or_default(),
|
||
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||
};
|
||
let key = normalize_dsh_root(&project.root.display().to_string());
|
||
let canonical = std::fs::canonicalize(project.root)
|
||
.ok()
|
||
.map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string())));
|
||
let keys: Vec<String> = std::iter::once(key).chain(canonical).collect();
|
||
dsh_static_outcome(&patch, &keys, project.project_id, desired.port)
|
||
}
|
||
|
||
/// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。
|
||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||
if !desired.refresh_dsh {
|
||
return ClientOutcome::Deferred;
|
||
}
|
||
finalize_for_project(project, refresh_dsh_patch(None))
|
||
}
|
||
|
||
/// DSH 无零模型自省入口:白名单复用 static_status,另核对会话级插件;helper 按托管块 !!js 的方式执行。
|
||
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
|
||
let not_applicable = l1_not_applicable(project);
|
||
let (whitelist, session_plugin) = match not_applicable {
|
||
Some(reason) => (
|
||
ClientOutcome::NotApplicable { reason },
|
||
StaticFinding {
|
||
ok: true,
|
||
detail: "不适用".into(),
|
||
},
|
||
),
|
||
None => (
|
||
self.static_status(project, desired),
|
||
dsh_session_plugin_finding(),
|
||
),
|
||
};
|
||
L1ClientFacts {
|
||
client: ClientId::Dsh,
|
||
not_applicable,
|
||
rules: probe_l1::rules_finding(project.root, "AGENTS.md", false),
|
||
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
|
||
source: L1Source::Dsh {
|
||
whitelist,
|
||
session_plugin,
|
||
// 与托管块 !!js 同源:%USERPROFILE%\.lian-jing\bin 下的稳定入口 + sha256(project_id)
|
||
helper: HelperCommand::DshShim {
|
||
shim: helper_bin_dir()
|
||
.map(|dir| dir.join(MCP_HEADER_SHIM_NAME))
|
||
.unwrap_or_else(|_| PathBuf::from(MCP_HEADER_SHIM_NAME)),
|
||
digest: crate::credential_store::mcp_auth_digest(project.project_id)
|
||
.unwrap_or_default(),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// DSH 会话级插件(P9,桌面 GUI 按工作区连项目地址)的完整性:托管块引用了当前版本的插件条目,
|
||
/// 且插件文件与炼境内置版本的 sha256 一致(忽略行尾)。只读。
|
||
fn dsh_session_plugin_finding() -> StaticFinding {
|
||
use sha2::Digest;
|
||
let hash = |text: &str| -> String {
|
||
sha2::Sha256::digest(text.replace("\r\n", "\n").as_bytes())
|
||
.iter()
|
||
.map(|byte| format!("{byte:02x}"))
|
||
.collect()
|
||
};
|
||
let patch = dsh_home()
|
||
.ok()
|
||
.and_then(|home| read_optional(&home.join("cordis.patch.yml")).ok().flatten())
|
||
.unwrap_or_default();
|
||
if !patch.contains(&format!("- id: {DSH_SESSION_PLUGIN_ID}")) {
|
||
return StaticFinding {
|
||
ok: false,
|
||
detail: format!("DSH 托管块未引用会话级插件 {DSH_SESSION_PLUGIN_ID};重跑 inject_mcp"),
|
||
};
|
||
}
|
||
let expected = hash(DSH_SESSION_PLUGIN_SOURCE);
|
||
let path = helper_bin_dir().ok().and_then(|bin| {
|
||
bin.parent()
|
||
.map(|dir| dir.join("dsh").join(DSH_SESSION_PLUGIN_FILE))
|
||
});
|
||
match path
|
||
.as_ref()
|
||
.map(|path| (path, std::fs::read_to_string(path)))
|
||
{
|
||
Some((_, Ok(content))) if hash(&content) == expected => StaticFinding {
|
||
ok: true,
|
||
detail: format!(
|
||
"会话级插件 {DSH_SESSION_PLUGIN_FILE} 与炼境内置版本一致(sha256 {}…)",
|
||
&expected[..12]
|
||
),
|
||
},
|
||
Some((path, Ok(_))) => StaticFinding {
|
||
ok: false,
|
||
detail: format!(
|
||
"会话级插件 {} 与炼境内置版本不一致;重启炼境或重跑 inject_mcp",
|
||
path.display()
|
||
),
|
||
},
|
||
_ => StaticFinding {
|
||
ok: false,
|
||
detail: "会话级插件文件缺失;重启炼境或重跑 inject_mcp".into(),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── L1 零模型三端检查(P4a):本体入口与本机客户端定位 ─────────────────────────
|
||
|
||
/// 三端对等 L1 零模型检查的本体入口(MCP probe_client_parity 与 Tauri command 共用,边界只在这里)。
|
||
/// 只读三端配置,起零模型的客户端自省进程,经稳定 shim 取认证头后在项目地址上重放(认证头不出本进程)。
|
||
pub fn probe_client_parity(
|
||
project_id: &str,
|
||
tier: Option<&str>,
|
||
) -> Result<probe_l1::L1Report, String> {
|
||
match tier.unwrap_or("l1") {
|
||
"l1" => {}
|
||
other => {
|
||
return Err(format!(
|
||
"tier={other} 不支持:目前只有 l1(零模型检查);L3 无头会话由后续 start_parity_probe 提供"
|
||
))
|
||
}
|
||
}
|
||
let root = project_root(project_id)?;
|
||
probe_client_parity_at(project_id, &root, crate::mcp_server::read_port())
|
||
}
|
||
|
||
/// probe_client_parity 去掉登记查询的部分(根目录与端口由调用方给出;实机冒烟测试直接调用)。
|
||
fn probe_client_parity_at(
|
||
project_id: &str,
|
||
root: &std::path::Path,
|
||
port: u16,
|
||
) -> Result<probe_l1::L1Report, String> {
|
||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||
let facts = project_facts(project_id, root);
|
||
let desired = DesiredMcp {
|
||
port,
|
||
server_url: server_url.clone(),
|
||
header_helper: shim_helper_command(&digest),
|
||
refresh_dsh: false,
|
||
};
|
||
let clients: Vec<L1ClientFacts> = crate::onboard::clients::registry()
|
||
.iter()
|
||
.map(|adapter| adapter.l1_facts(&facts, &desired))
|
||
.collect();
|
||
let expected_tools = crate::mcp_tools::tools_list_result_for_project(project_id)["tools"]
|
||
.as_array()
|
||
.map(|tools| {
|
||
tools
|
||
.iter()
|
||
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let project = probe_l1::L1Project {
|
||
project_id,
|
||
root,
|
||
control_plane: facts.control_plane,
|
||
expected_url: server_url,
|
||
expected_tools,
|
||
managed_helpers: vec![
|
||
shim_helper_command(&digest),
|
||
legacy_shim_helper_command(&digest),
|
||
],
|
||
};
|
||
let env = |name: &str| std::env::var(name).ok();
|
||
let deps = probe_l1::L1Deps {
|
||
runner: &probe_l1::SystemRunner,
|
||
http: &probe_l1::UreqHttp,
|
||
locator: &SystemLocator,
|
||
env: &env,
|
||
budget: probe_l1::L1_BUDGET,
|
||
};
|
||
// 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连项目地址,服务端无法与
|
||
// 真实会话区分:检查期间本项目的 initialize 不计入「真实客户端已连接」,免得抹掉 reload_required 证据(审查 ③)
|
||
let _quiet = crate::mcp_server::quiet_project_initializes(project_id);
|
||
Ok(probe_l1::run_l1(&project, &clients, &deps))
|
||
}
|
||
|
||
/// 本机客户端定位(R15:客户端私有安装位置集中在这里,格式变化归因 adapter_drift)。
|
||
struct SystemLocator;
|
||
|
||
impl probe_l1::ClientLocator for SystemLocator {
|
||
fn locate(&self, client: ClientId) -> Option<Located> {
|
||
match client {
|
||
ClientId::Claude => find_claude_bin().map(|bin| Located {
|
||
program: Some(PathBuf::from(bin)),
|
||
version: None,
|
||
}),
|
||
ClientId::Codex => find_codex_bin().map(|bin| Located {
|
||
program: Some(bin),
|
||
version: None,
|
||
}),
|
||
ClientId::Dsh => DSH_ADAPTER.detect().then(|| Located {
|
||
program: None,
|
||
version: dsh_install_dir().and_then(|dir| {
|
||
probe_l1::read_asar_package_version(&dir.join("resources").join("app.asar"))
|
||
}),
|
||
}),
|
||
}
|
||
}
|
||
|
||
fn git_bash(&self) -> Option<PathBuf> {
|
||
find_git_bash().map(PathBuf::from)
|
||
}
|
||
}
|
||
|
||
/// codex 可执行文件:CODEX_BIN → Codex 桌面版自带的 %LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe
|
||
/// (本机可能有多个哈希目录,取最新)→ PATH 中的 codex.exe。npm 全局安装的 codex.cmd 不在此列。
|
||
fn find_codex_bin() -> Option<PathBuf> {
|
||
if let Some(path) = std::env::var_os("CODEX_BIN")
|
||
.map(PathBuf::from)
|
||
.filter(|path| path.is_file())
|
||
{
|
||
return Some(path);
|
||
}
|
||
let bundled = std::env::var_os("LOCALAPPDATA")
|
||
.map(|base| PathBuf::from(base).join("OpenAI").join("Codex").join("bin"))
|
||
.and_then(|bin| std::fs::read_dir(bin).ok())
|
||
.into_iter()
|
||
.flatten()
|
||
.flatten()
|
||
.map(|entry| entry.path().join("codex.exe"))
|
||
.filter(|exe| exe.is_file())
|
||
.max_by_key(|exe| exe.metadata().and_then(|meta| meta.modified()).ok());
|
||
if bundled.is_some() {
|
||
return bundled;
|
||
}
|
||
let output = crate::silent_cmd("where").arg("codex.exe").output().ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
String::from_utf8_lossy(&output.stdout)
|
||
.lines()
|
||
.next()
|
||
.map(|line| PathBuf::from(line.trim()))
|
||
.filter(|path| path.is_file())
|
||
}
|
||
|
||
/// DSH 桌面版安装目录(读版本用):DSH_INSTALL_DIR → %LOCALAPPDATA%\Programs\DeepSeek Harness。
|
||
fn dsh_install_dir() -> Option<PathBuf> {
|
||
std::env::var_os("DSH_INSTALL_DIR")
|
||
.map(PathBuf::from)
|
||
.or_else(|| {
|
||
std::env::var_os("LOCALAPPDATA").map(|base| {
|
||
PathBuf::from(base)
|
||
.join("Programs")
|
||
.join("DeepSeek Harness")
|
||
})
|
||
})
|
||
.filter(|dir| dir.is_dir())
|
||
}
|
||
|
||
/// 从项目的 .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)?, project_id)?;
|
||
remove_settings_mcp_entry(&settings_path(project_id)?, project_id)
|
||
}
|
||
|
||
fn remove_settings_mcp_entry(path: &PathBuf, project_id: &str) -> Result<(), String> {
|
||
if !path.exists() {
|
||
return Ok(());
|
||
}
|
||
let mut settings = read_json(path)?;
|
||
if let Some(servers) = settings
|
||
.get_mut("mcpServers")
|
||
.and_then(|v| v.as_object_mut())
|
||
{
|
||
let foreign = servers
|
||
.get(MCP_KEY)
|
||
.and_then(|entry| entry.get("url"))
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|url| owned_by_other_project(url, project_id));
|
||
if !foreign {
|
||
servers.remove(MCP_KEY);
|
||
}
|
||
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||
}
|
||
write_json(path, &settings)
|
||
}
|
||
|
||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 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 认证 token。
|
||
/// token 由注入创建、由 remove_project_mcp 最先删除;DSH 白名单、根目录迁移、批量重注入与
|
||
/// list_all_projects 都以此为准(P8a)。项目里的 .claude/liangjing.json 只是兼容投影,可能残留或缺失,不作判据。
|
||
pub(crate) fn is_project_injected(
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
project_id: &str,
|
||
) -> bool {
|
||
crate::credential_store::mcp_auth_digest(project_id)
|
||
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
|
||
.is_ok()
|
||
}
|
||
|
||
/// 批量重注入的目标:产品组成员(历史上入组即注入),加上已接入(有 token)但不在任何组的登记项目。
|
||
/// 后者此前被漏掉——独立接入的项目在端口变更后不会被重注入,项目配置仍指向旧端口。
|
||
fn reinject_targets(
|
||
conn: &rusqlite::Connection,
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
) -> Result<Vec<String>, String> {
|
||
let query_ids = |sql: &str| -> Result<Vec<String>, String> {
|
||
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
|
||
let ids = stmt
|
||
.query_map([], |row| row.get(0))
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(Result::ok)
|
||
.collect();
|
||
Ok(ids)
|
||
};
|
||
let mut targets: std::collections::BTreeSet<String> =
|
||
query_ids("SELECT DISTINCT project_id FROM project_group_map")?
|
||
.into_iter()
|
||
.collect();
|
||
for id in query_ids("SELECT id FROM project_workspaces")? {
|
||
if is_project_injected(store, &id) {
|
||
targets.insert(id);
|
||
}
|
||
}
|
||
Ok(targets.into_iter().collect())
|
||
}
|
||
|
||
/// 锁内复查:目标快照取自锁外,期间被移出组且 token 已删的项目不再注入(避免重建刚删除的 token)。
|
||
fn is_reinject_target(
|
||
conn: &rusqlite::Connection,
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
project_id: &str,
|
||
) -> Result<bool, String> {
|
||
let member = 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())?;
|
||
Ok(member || is_project_injected(store, project_id))
|
||
}
|
||
|
||
/// 端口变更后重新注入所有已接入项目(产品组成员 + 有 token 的独立项目)的 MCP 配置
|
||
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
||
pub fn reinject_all_mcp() -> Vec<String> {
|
||
let store = crate::credential_store::WindowsCredentialStore;
|
||
let project_ids = match db::pool()
|
||
.get()
|
||
.map_err(|e| e.to_string())
|
||
.and_then(|conn| reinject_targets(&conn, &store))
|
||
{
|
||
Ok(ids) => ids,
|
||
Err(e) => return vec![e],
|
||
};
|
||
|
||
let mut errors = Vec::new();
|
||
for project_id in project_ids {
|
||
let _guard = mcp_op_lock();
|
||
let still_target = db::pool()
|
||
.get()
|
||
.map_err(|error| error.to_string())
|
||
.and_then(|conn| is_reinject_target(&conn, &store, &project_id));
|
||
match still_target {
|
||
Ok(true) => {}
|
||
Ok(false) => continue,
|
||
Err(error) => {
|
||
errors.push(format!("项目 {project_id}: 复查接入状态失败: {error}"));
|
||
continue;
|
||
}
|
||
}
|
||
let result = inject_project_mcp_locked(&project_id, false)
|
||
.and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err));
|
||
if let Err(e) = result {
|
||
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);
|
||
}
|
||
if !is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) {
|
||
refresh_dsh_patch(None);
|
||
return;
|
||
}
|
||
let result = inject_project_mcp_locked(project_id, true)
|
||
.and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err));
|
||
if let Err(error) = result {
|
||
// 属于注入结果,归入 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_json_with(&path, &value, "settings.json", &crate::DiskSink).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, "project one").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();
|
||
// 手写同名 server:记 manual_kept 不覆盖(P8b 起不再整体报错,不连带阻断 Claude 与 DSH)
|
||
assert_eq!(
|
||
upsert_codex_mcp_config(
|
||
&path,
|
||
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||
helper,
|
||
),
|
||
Ok(ClientOutcome::ManualKept)
|
||
);
|
||
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// 2026-10-03 真机:同一目录登记了 dev-manager-tauri 与 2c5b54ef 两条,删 UUID 那条会把
|
||
/// 指向规范 ID 的 Codex 管理块一并删掉。删除只清 URL 属于本项目(或旧格式)的条目。
|
||
#[test]
|
||
fn remove_keeps_entries_owned_by_another_project_at_same_root() {
|
||
let base = std::env::temp_dir().join(format!(
|
||
"lianjing_remove_scoped_test_{}",
|
||
std::process::id()
|
||
));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
std::fs::create_dir_all(base.join(".codex")).unwrap();
|
||
std::fs::create_dir_all(base.join(".claude")).unwrap();
|
||
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
|
||
let codex = base.join(".codex/config.toml");
|
||
let other_url = "http://127.0.0.1:27190/mcp/projects/canonical-id";
|
||
|
||
std::fs::write(&codex, "model = \"gpt-6\"\n").unwrap();
|
||
upsert_codex_mcp_config(&codex, other_url, helper).unwrap();
|
||
let before = std::fs::read_to_string(&codex).unwrap();
|
||
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
|
||
assert_eq!(
|
||
std::fs::read_to_string(&codex).unwrap(),
|
||
before,
|
||
"别的项目的 Codex 管理块必须原样保留"
|
||
);
|
||
remove_codex_mcp_config(&codex, "canonical-id").unwrap();
|
||
assert_eq!(
|
||
std::fs::read_to_string(&codex).unwrap(),
|
||
"model = \"gpt-6\"\n"
|
||
);
|
||
|
||
// 旧格式全局地址不属于任何项目路由,视为遗留照旧清理
|
||
upsert_codex_mcp_config(&codex, "http://127.0.0.1:27190/mcp", helper).unwrap();
|
||
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
|
||
assert_eq!(
|
||
std::fs::read_to_string(&codex).unwrap(),
|
||
"model = \"gpt-6\"\n"
|
||
);
|
||
|
||
let settings = base.join(".claude/settings.json");
|
||
let write_settings = |url: &str| {
|
||
std::fs::write(
|
||
&settings,
|
||
json!({ "mcpServers": {
|
||
MCP_KEY: { "type": "http", "url": url },
|
||
"lian-jing:group-old": { "type": "http", "url": "http://127.0.0.1:27190/mcp/group/g" },
|
||
"other": { "command": "x" }
|
||
} })
|
||
.to_string(),
|
||
)
|
||
.unwrap();
|
||
};
|
||
let servers = || read_json(&settings).unwrap()["mcpServers"].clone();
|
||
|
||
write_settings(other_url);
|
||
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
|
||
let kept = servers();
|
||
assert_eq!(
|
||
kept[MCP_KEY]["url"], other_url,
|
||
"别的项目的 settings 条目必须保留"
|
||
);
|
||
assert!(
|
||
kept.get("lian-jing:group-old").is_none(),
|
||
"旧组级 key 照旧清理"
|
||
);
|
||
assert!(kept.get("other").is_some());
|
||
|
||
write_settings("http://127.0.0.1:27190/mcp/projects/duplicate-uuid");
|
||
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
|
||
let removed = servers();
|
||
assert!(removed.get(MCP_KEY).is_none());
|
||
assert!(removed.get("other").is_some());
|
||
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
// ── P8b:三端结构化结果 ──
|
||
|
||
fn claude_get_local(url: &str) -> String {
|
||
format!("lian-jing:\n Scope: Local config (private to you in this project)\n Type: http\n URL: {url}\n")
|
||
}
|
||
|
||
/// 假 claude CLI:按子命令给出预设结果并记录调用;resolve 被调用时返回它。
|
||
struct FakeClaude {
|
||
get: String,
|
||
remove_ok: bool,
|
||
add_ok: bool,
|
||
calls: std::cell::RefCell<Vec<String>>,
|
||
}
|
||
|
||
impl FakeClaude {
|
||
fn new(get: &str, remove_ok: bool, add_ok: bool) -> Self {
|
||
Self {
|
||
get: get.into(),
|
||
remove_ok,
|
||
add_ok,
|
||
calls: Default::default(),
|
||
}
|
||
}
|
||
fn run(&self, args: &[&str]) -> Result<String, String> {
|
||
self.calls.borrow_mut().push(args[1].to_string());
|
||
match args[1] {
|
||
"get" => Ok(self.get.clone()),
|
||
"remove" if self.remove_ok => Ok(String::new()),
|
||
"add-json" if self.add_ok => Ok(String::new()),
|
||
other => Err(format!("{other} failed")),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn sync_with_fake(
|
||
project_id: &str,
|
||
root: &std::path::Path,
|
||
desired: Option<(String, String)>,
|
||
config: Option<&Value>,
|
||
fake: Option<&FakeClaude>,
|
||
) -> ClientOutcome {
|
||
sync_claude_local_with(project_id, root, desired, config, &|| {
|
||
fake.map(|fake| {
|
||
let cli: ClaudeCli = Box::new(move |args: &[&str]| fake.run(args));
|
||
cli
|
||
})
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn claude_sync_maps_every_branch_to_an_outcome() {
|
||
let root = std::env::temp_dir().join(format!("lj_p8b_claude_{}", std::process::id()));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
let url = "http://127.0.0.1:27190/mcp/projects/p1";
|
||
let helper = "\"C:\\Users\\u\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" abc";
|
||
let desired = || Some((url.to_string(), helper.to_string()));
|
||
let root_key = root.display().to_string().replace('\\', "/");
|
||
let entry = claude_local_server_entry(url, helper);
|
||
let config_with = |entry: Value| json!({ "projects": { root_key.clone(): { "mcpServers": { "lian-jing": entry } } } });
|
||
let empty = json!({ "projects": {} });
|
||
let no_cli: Option<&FakeClaude> = None;
|
||
|
||
// 快路径:已一致 → converged,不解析 CLI
|
||
assert_eq!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
desired(),
|
||
Some(&config_with(entry.clone())),
|
||
no_cli
|
||
),
|
||
ClientOutcome::Converged
|
||
);
|
||
// 移除且确认无托管条目 → converged
|
||
assert_eq!(
|
||
sync_with_fake("p1", &root, None, Some(&empty), no_cli),
|
||
ClientOutcome::Converged
|
||
);
|
||
// 缺 CLI → unavailable
|
||
assert!(matches!(
|
||
sync_with_fake("p1", &root, desired(), Some(&empty), no_cli),
|
||
ClientOutcome::Unavailable { reason } if reason.contains("claude CLI")
|
||
));
|
||
// 目录不存在 → unavailable:CLI 在也不起(审查修复:原用例缺 CLI,删掉目录守卫也会通过)
|
||
let not_local = "No MCP server found with name: \"lian-jing\".";
|
||
let untouched = FakeClaude::new(not_local, true, true);
|
||
assert!(matches!(
|
||
sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), Some(&untouched)),
|
||
ClientOutcome::Unavailable { reason } if reason.contains("项目目录不存在")
|
||
));
|
||
assert!(
|
||
untouched.calls.borrow().is_empty(),
|
||
"目录不存在时不得起 CLI"
|
||
);
|
||
// get 输出无法识别 → unavailable
|
||
let garbled = FakeClaude::new("???", true, true);
|
||
assert!(matches!(
|
||
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)),
|
||
ClientOutcome::Unavailable { reason } if reason.contains("无法识别")
|
||
));
|
||
// 本目录无 local 条目:写入成功 → applied,失败 → failed
|
||
assert_eq!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
desired(),
|
||
Some(&empty),
|
||
Some(&FakeClaude::new(not_local, true, true))
|
||
),
|
||
ClientOutcome::Applied
|
||
);
|
||
assert!(matches!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
desired(),
|
||
Some(&empty),
|
||
Some(&FakeClaude::new(not_local, true, false))
|
||
),
|
||
ClientOutcome::Failed { .. }
|
||
));
|
||
// 本项目旧托管条目:刷新 → refreshed;移除 → removed;移除失败 → failed
|
||
let stale = config_with(claude_local_server_entry(
|
||
"http://127.0.0.1:1/mcp/projects/p1",
|
||
helper,
|
||
));
|
||
let own = claude_get_local("http://127.0.0.1:1/mcp/projects/p1");
|
||
assert_eq!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
desired(),
|
||
Some(&stale),
|
||
Some(&FakeClaude::new(&own, true, true))
|
||
),
|
||
ClientOutcome::Refreshed
|
||
);
|
||
assert_eq!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
None,
|
||
Some(&stale),
|
||
Some(&FakeClaude::new(&own, true, true))
|
||
),
|
||
ClientOutcome::Removed
|
||
);
|
||
assert!(matches!(
|
||
sync_with_fake("p1", &root, desired(), Some(&stale), Some(&FakeClaude::new(&own, false, true))),
|
||
ClientOutcome::Failed { reason } if reason.contains("旧条目移除失败")
|
||
));
|
||
// 新条目写入失败:用原条目回滚并记 failed
|
||
let rollback = FakeClaude::new(&own, true, false);
|
||
assert!(matches!(
|
||
sync_with_fake("p1", &root, desired(), Some(&stale), Some(&rollback)),
|
||
ClientOutcome::Failed { reason } if reason.contains("新条目写入失败")
|
||
));
|
||
assert_eq!(
|
||
*rollback.calls.borrow(),
|
||
vec!["get", "remove", "add-json", "add-json"]
|
||
);
|
||
// 共享项目键属于他项目 → other_project;手写条目 → manual_kept,均不写
|
||
let other = FakeClaude::new(
|
||
&claude_get_local("http://127.0.0.1:27190/mcp/projects/p2"),
|
||
true,
|
||
true,
|
||
);
|
||
assert_eq!(
|
||
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&other)),
|
||
ClientOutcome::OtherProject { owner: "p2".into() }
|
||
);
|
||
assert_eq!(*other.calls.borrow(), vec!["get"]);
|
||
let manual = FakeClaude::new(&claude_get_local("http://example.com/mcp"), true, true);
|
||
assert_eq!(
|
||
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&manual)),
|
||
ClientOutcome::ManualKept
|
||
);
|
||
// 移除时本目录无条目、但他处项目键残留本项目托管条目 → needs_human
|
||
let elsewhere =
|
||
json!({ "projects": { "D:/other": { "mcpServers": { "lian-jing": entry } } } });
|
||
assert!(matches!(
|
||
sync_with_fake(
|
||
"p1",
|
||
&root,
|
||
None,
|
||
Some(&elsewhere),
|
||
Some(&FakeClaude::new(not_local, true, true))
|
||
),
|
||
ClientOutcome::NeedsHuman { .. }
|
||
));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn static_outcomes_are_read_only_judgments() {
|
||
let url = "http://127.0.0.1:27190/mcp/projects/p1";
|
||
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||
let root = std::path::Path::new("E:\\code\\p1");
|
||
let entry = claude_local_server_entry(url, helper);
|
||
let config = |entry: Value| json!({ "projects": { "E:/code/p1": { "mcpServers": { "lian-jing": entry } } } });
|
||
assert_eq!(
|
||
claude_static_outcome(&config(entry.clone()), "p1", root, &entry),
|
||
ClientOutcome::Converged
|
||
);
|
||
assert_eq!(
|
||
claude_static_outcome(&json!({}), "p1", root, &entry),
|
||
ClientOutcome::Missing
|
||
);
|
||
let stale = claude_local_server_entry("http://127.0.0.1:1/mcp/projects/p1", helper);
|
||
assert_eq!(
|
||
claude_static_outcome(&config(stale), "p1", root, &entry),
|
||
ClientOutcome::Missing
|
||
);
|
||
let other = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p2", helper);
|
||
assert_eq!(
|
||
claude_static_outcome(&config(other), "p1", root, &entry),
|
||
ClientOutcome::OtherProject { owner: "p2".into() }
|
||
);
|
||
let manual = json!({ "type": "http", "url": "http://example.com/mcp" });
|
||
assert_eq!(
|
||
claude_static_outcome(&config(manual), "p1", root, &entry),
|
||
ClientOutcome::ManualKept
|
||
);
|
||
|
||
let block = render_codex_block(url, helper).unwrap();
|
||
assert_eq!(
|
||
codex_static_outcome(Some(&block.replace('\n', "\r\n")), url, helper),
|
||
ClientOutcome::Converged
|
||
);
|
||
assert_eq!(
|
||
codex_static_outcome(None, url, helper),
|
||
ClientOutcome::Missing
|
||
);
|
||
assert_eq!(
|
||
codex_static_outcome(Some("[mcp_servers.lian-jing]\nurl = \"x\"\n"), url, helper),
|
||
ClientOutcome::ManualKept
|
||
);
|
||
assert_eq!(
|
||
codex_static_outcome(Some(&block.replace("27190", "1")), url, helper),
|
||
ClientOutcome::Missing
|
||
);
|
||
|
||
let keys = vec![normalize_dsh_root("E:\\code\\p1")];
|
||
let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None);
|
||
assert_eq!(
|
||
dsh_static_outcome(&dsh, &keys, "p1", 27190),
|
||
ClientOutcome::Converged
|
||
);
|
||
assert_eq!(
|
||
dsh_static_outcome(&dsh, &keys, "p1", 1),
|
||
ClientOutcome::Missing,
|
||
"端口已变"
|
||
);
|
||
let without = render_dsh_block(27190, &[], None);
|
||
assert_eq!(
|
||
dsh_static_outcome(&without, &keys, "p1", 27190),
|
||
ClientOutcome::Missing
|
||
);
|
||
assert_eq!(
|
||
dsh_static_outcome("", &keys, "p1", 27190),
|
||
ClientOutcome::Missing
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dsh_patch_reports_not_installed_applied_converged_and_manual_kept() {
|
||
let base = std::env::temp_dir().join(format!("lj_p8b_dsh_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let conn = crate::db::conn_with_schema();
|
||
let store = crate::credential_store::MemoryCredentialStore::new();
|
||
let home = base.join(".dsh");
|
||
assert_eq!(
|
||
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
|
||
Ok(ClientOutcome::NotInstalled)
|
||
);
|
||
std::fs::create_dir_all(&home).unwrap();
|
||
assert_eq!(
|
||
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
|
||
Ok(ClientOutcome::Applied)
|
||
);
|
||
assert_eq!(
|
||
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
|
||
Ok(ClientOutcome::Converged)
|
||
);
|
||
let manual = "- insert:\n - id: mcp-lian-jing\n name: x\n";
|
||
std::fs::write(home.join("cordis.patch.yml"), manual).unwrap();
|
||
assert_eq!(
|
||
ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None),
|
||
Ok(ClientOutcome::ManualKept)
|
||
);
|
||
assert_eq!(
|
||
std::fs::read_to_string(home.join("cordis.patch.yml")).unwrap(),
|
||
manual,
|
||
"手写条目不被覆盖"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[test]
|
||
fn adapters_skip_control_plane_and_wsl_without_touching_disk() {
|
||
let root = std::path::Path::new("E:\\nowhere\\ctrl");
|
||
let desired = DesiredMcp {
|
||
port: 27190,
|
||
server_url: "http://127.0.0.1:27190/mcp/projects/ctrl".into(),
|
||
header_helper: "h".into(),
|
||
refresh_dsh: false,
|
||
};
|
||
let control = ProjectFacts {
|
||
project_id: "ctrl",
|
||
root,
|
||
control_plane: true,
|
||
wsl: false,
|
||
};
|
||
let skip_ctrl = ClientOutcome::NotApplicable {
|
||
reason: NotApplicableReason::ControlPlane,
|
||
};
|
||
// Claude 的控制面判定要读本机配置,改测纯函数(见 control_plane_claude_residual_is_not_hidden)
|
||
assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl);
|
||
let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p");
|
||
let wsl = ProjectFacts {
|
||
project_id: "w",
|
||
root: wsl_root,
|
||
control_plane: false,
|
||
wsl: true,
|
||
};
|
||
let skip_wsl = ClientOutcome::NotApplicable {
|
||
reason: NotApplicableReason::Wsl,
|
||
};
|
||
assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
||
assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
||
// 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新
|
||
assert_eq!(DSH_ADAPTER.apply(&wsl, &desired), ClientOutcome::Deferred);
|
||
assert!(
|
||
[skip_ctrl.clone(), skip_wsl.clone()]
|
||
.iter()
|
||
.all(ClientOutcome::is_settled),
|
||
"不适用不计失败"
|
||
);
|
||
// apply 的结果映射:成功态在控制面 / WSL 上报 not_applicable,失败原样保留
|
||
let failed = ClientOutcome::Failed { reason: "x".into() };
|
||
assert_eq!(
|
||
finalize_for_project(&control, ClientOutcome::Removed),
|
||
skip_ctrl
|
||
);
|
||
assert_eq!(
|
||
finalize_for_project(&control, ClientOutcome::NotInstalled),
|
||
skip_ctrl
|
||
);
|
||
assert_eq!(finalize_for_project(&control, failed.clone()), failed);
|
||
assert_eq!(
|
||
finalize_for_project(&wsl, ClientOutcome::Converged),
|
||
skip_wsl
|
||
);
|
||
let normal = ProjectFacts {
|
||
project_id: "p",
|
||
root,
|
||
control_plane: false,
|
||
wsl: false,
|
||
};
|
||
assert_eq!(
|
||
finalize_for_project(&normal, ClientOutcome::Applied),
|
||
ClientOutcome::Applied
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn control_plane_claude_residual_is_not_hidden() {
|
||
let root = std::path::Path::new("E:\\code\\ctrl");
|
||
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||
let residual =
|
||
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/ctrl", helper);
|
||
let with =
|
||
json!({ "projects": { "E:/code/ctrl": { "mcpServers": { "lian-jing": residual } } } });
|
||
assert_eq!(
|
||
claude_control_plane_outcome(&with, "ctrl", root),
|
||
ClientOutcome::Missing
|
||
);
|
||
assert_eq!(
|
||
claude_control_plane_outcome(&json!({ "projects": {} }), "ctrl", root),
|
||
ClientOutcome::NotApplicable {
|
||
reason: NotApplicableReason::ControlPlane
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_static_prefers_own_git_root_over_ancestor_entry() {
|
||
let base = std::env::temp_dir().join(format!("lj_p8b_nested_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let child = base.join("sub").join("child");
|
||
std::fs::create_dir_all(child.join(".git")).unwrap();
|
||
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||
let desired =
|
||
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/child", helper);
|
||
let parent_entry =
|
||
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/parent", helper);
|
||
let key = |path: &std::path::Path| path.display().to_string().replace('\\', "/");
|
||
// 子项目是独立 git 根、自己还没有条目:祖先目录的条目不在本目录生效 → missing(apply 会写入)
|
||
let only_parent =
|
||
json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } } } });
|
||
assert_eq!(
|
||
claude_static_outcome(&only_parent, "child", &child, &desired),
|
||
ClientOutcome::Missing
|
||
);
|
||
// 子项目自己的条目已一致 → converged(不被祖先条目干扰)
|
||
let both = json!({ "projects": {
|
||
key(&base): { "mcpServers": { "lian-jing": parent_entry } },
|
||
key(&child): { "mcpServers": { "lian-jing": desired } }
|
||
} });
|
||
assert_eq!(
|
||
claude_static_outcome(&both, "child", &child, &desired),
|
||
ClientOutcome::Converged
|
||
);
|
||
// 非 git 根的子目录(共享外层项目键):外层条目生效 → other_project
|
||
let plain = base.join("plain");
|
||
std::fs::create_dir_all(&plain).unwrap();
|
||
assert_eq!(
|
||
claude_static_outcome(&only_parent, "plain", &plain, &desired),
|
||
ClientOutcome::OtherProject {
|
||
owner: "parent".into()
|
||
}
|
||
);
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[test]
|
||
fn static_outcomes_flag_coexisting_manual_entries_and_unreadable_patch() {
|
||
let url = "http://127.0.0.1:27190/mcp/projects/p1";
|
||
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||
let block = render_codex_block(url, helper).unwrap();
|
||
let duplicated = format!("{block}\n[mcp_servers.lian-jing]\nurl = \"x\"\n");
|
||
assert!(matches!(
|
||
codex_static_outcome(Some(&duplicated), url, helper),
|
||
ClientOutcome::NeedsHuman { .. }
|
||
));
|
||
let keys = vec![normalize_dsh_root("E:\\code\\p1")];
|
||
let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None);
|
||
let with_manual = format!("- insert:\n - id: mcp-lian-jing\n name: x\n{dsh}");
|
||
assert_eq!(
|
||
dsh_static_outcome(&with_manual, &keys, "p1", 27190),
|
||
ClientOutcome::ManualKept
|
||
);
|
||
assert!(matches!(
|
||
dsh_static_outcome("- [unclosed", &keys, "p1", 27190),
|
||
ClientOutcome::Unavailable { .. }
|
||
));
|
||
let misordered = format!("{DSH_MANAGED_END}\n{DSH_MANAGED_START}\n");
|
||
assert!(matches!(
|
||
dsh_static_outcome(&misordered, &keys, "p1", 27190),
|
||
ClientOutcome::NeedsHuman { .. }
|
||
));
|
||
}
|
||
|
||
/// Codex 信任表解析:字面量键、按 TOML 规则转义的基本字符串键、[projects] 段内的内联表与点号键;
|
||
/// 只认精确目录(及调用方给出的 git 主仓库根候选),不逐级查祖先目录。
|
||
#[test]
|
||
fn codex_trust_matches_exact_dirs_with_real_toml_keys() {
|
||
let config = r#"model = "x"
|
||
|
||
[projects.'e:\code-project\enterprise-system']
|
||
trust_level = "trusted"
|
||
|
||
[projects."D:\\Work"]
|
||
trust_level = "trusted" # 基本字符串里的反斜杠必须转义
|
||
|
||
[projects."D:\Bad"]
|
||
trust_level = "trusted"
|
||
|
||
[projects.'e:\other']
|
||
trust_level = "untrusted"
|
||
|
||
[projects]
|
||
'f:\inline' = { trust_level = "trusted" }
|
||
"f:\\dotted".trust_level = 'trusted'
|
||
'f:\inline-untrusted' = { trust_level = "untrusted" }
|
||
|
||
[[profiles]]
|
||
trust_level = "trusted"
|
||
"#;
|
||
let trusts = |paths: &[&str]| {
|
||
codex_config_trusts(config, &paths.iter().map(PathBuf::from).collect::<Vec<_>>())
|
||
};
|
||
assert!(trusts(&[r"E:\code-project\enterprise-system"]));
|
||
assert!(trusts(&["E:/code-project/enterprise-system/"]));
|
||
assert!(trusts(&[r"\\?\E:\code-project\enterprise-system"]));
|
||
assert!(trusts(&[r"D:\Work"]), "转义的基本字符串键按反转义后匹配");
|
||
assert!(
|
||
!trusts(&[r"D:\Work\sub\proj"]),
|
||
"受信任的祖先目录不覆盖子目录(只认精确目录与 git 主仓库根)"
|
||
);
|
||
assert!(!trusts(&[r"D:\Bad"]), "非法转义的键忽略");
|
||
assert!(!trusts(&[r"E:\other"]), "untrusted 不算");
|
||
assert!(
|
||
!trusts(&[r"E:\code-project\enterprise-system-wt"]),
|
||
"前缀不是同一目录"
|
||
);
|
||
assert!(trusts(&[r"F:\inline"]), "[projects] 内联表");
|
||
assert!(trusts(&[r"F:\dotted"]), "[projects] 点号键");
|
||
assert!(!trusts(&[r"F:\inline-untrusted"]));
|
||
assert!(!trusts(&[r"C:\new"]));
|
||
assert!(
|
||
trusts(&[r"C:\wt", r"E:\code-project\enterprise-system"]),
|
||
"任一候选(git 主仓库根)受信任即可"
|
||
);
|
||
assert!(!codex_config_trusts(
|
||
"",
|
||
&[PathBuf::from(r"E:\code-project\enterprise-system")]
|
||
));
|
||
}
|
||
|
||
/// git 主仓库根:普通仓库为仓库根,worktree 为主仓库根(继承信任),仓库子目录为所在仓库根。
|
||
#[test]
|
||
fn git_main_worktree_root_resolves_worktree_and_subdir() {
|
||
let base = std::env::temp_dir().join(format!("lj_p8c_gitroot_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let main = base.join("main");
|
||
std::fs::create_dir_all(main.join("sub")).unwrap();
|
||
let git = |dir: &std::path::Path, args: &[&str]| {
|
||
let out = std::process::Command::new("git")
|
||
.args([
|
||
"-c",
|
||
"user.name=t",
|
||
"-c",
|
||
"user.email=t@t",
|
||
"-c",
|
||
"commit.gpgsign=false",
|
||
])
|
||
.args(args)
|
||
.current_dir(dir)
|
||
.output()
|
||
.expect("git 可执行");
|
||
assert!(
|
||
out.status.success(),
|
||
"git {args:?}: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
};
|
||
git(&main, &["init", "-q"]);
|
||
std::fs::write(main.join("sub").join("a.txt"), "a\n").unwrap();
|
||
git(&main, &["add", "-A"]);
|
||
git(&main, &["commit", "-q", "--no-verify", "-m", "init"]);
|
||
git(&main, &["worktree", "add", "-q", "../wt", "-b", "wt"]);
|
||
|
||
let canonical = |path: PathBuf| std::fs::canonicalize(path).unwrap();
|
||
let expected = canonical(main.clone());
|
||
assert_eq!(canonical(git_main_worktree_root(&main).unwrap()), expected);
|
||
assert_eq!(
|
||
canonical(git_main_worktree_root(&base.join("wt")).unwrap()),
|
||
expected,
|
||
"worktree 取主仓库根"
|
||
);
|
||
assert_eq!(
|
||
canonical(git_main_worktree_root(&main.join("sub")).unwrap()),
|
||
expected,
|
||
"仓库子目录取所在仓库根"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[test]
|
||
fn token_step_tells_missing_from_needs_repair() {
|
||
use crate::credential_store::McpAuthTokenState;
|
||
use crate::onboard::plan::{machine_step_blocker, BlockReason};
|
||
let present = token_step(McpAuthTokenState::Present);
|
||
assert_eq!(
|
||
(present.step.as_str(), present.status.as_str()),
|
||
("token", "noop")
|
||
);
|
||
assert!(machine_step_blocker(&present).is_none());
|
||
let missing = token_step(McpAuthTokenState::Missing);
|
||
assert_eq!(missing.status, "apply", "只有未配置才会生成");
|
||
assert!(machine_step_blocker(&missing).is_none());
|
||
let repair = token_step(McpAuthTokenState::NeedsRepair(
|
||
"MCP 认证材料 needs_repair".into(),
|
||
));
|
||
assert_eq!(repair.status, "needs_human");
|
||
assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成"));
|
||
let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认");
|
||
assert_eq!(
|
||
(blocked.target.as_str(), blocked.reason),
|
||
("token", BlockReason::NeedsHuman)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hard_failure_is_only_codex_failed() {
|
||
let report = |client, outcome| ClientReport::new(client, outcome);
|
||
let failed = || ClientOutcome::Failed {
|
||
reason: "boom".into(),
|
||
};
|
||
assert_eq!(
|
||
hard_failure(&[report(ClientId::Codex, failed())]),
|
||
Some("boom".into())
|
||
);
|
||
assert_eq!(
|
||
hard_failure(&[
|
||
report(ClientId::Claude, failed()),
|
||
report(ClientId::Dsh, failed()),
|
||
report(ClientId::Codex, ClientOutcome::ManualKept),
|
||
]),
|
||
None,
|
||
"Claude 与 DSH 一直是 best-effort,不算整体失败"
|
||
);
|
||
}
|
||
|
||
#[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 reinject_targets_follow_single_injected_judgment() {
|
||
let conn = crate::db::conn_with_schema();
|
||
let store = crate::credential_store::MemoryCredentialStore::new();
|
||
conn.execute(
|
||
"INSERT INTO product_groups (id, name) VALUES ('g1', 'G')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
for id in ["grouped", "standalone", "removed"] {
|
||
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"), format!("C:\\qa\\{id}")],
|
||
)
|
||
.unwrap();
|
||
}
|
||
conn.execute(
|
||
"INSERT INTO project_group_map (project_id, group_id) VALUES ('grouped', 'g1')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
// standalone:独立接入(有 token、不在任何组),此前被批量重注入漏掉;removed:从未接入或已移除
|
||
crate::credential_store::get_or_create_mcp_auth_token(&store, "standalone").unwrap();
|
||
assert!(is_project_injected(&store, "standalone"));
|
||
assert!(!is_project_injected(&store, "removed"));
|
||
assert_eq!(
|
||
reinject_targets(&conn, &store).unwrap(),
|
||
vec!["grouped".to_string(), "standalone".to_string()]
|
||
);
|
||
// 锁内复查:组成员仍是目标;独立项目的 token 先被 remove_project_mcp 删掉后不再注入
|
||
assert!(is_reinject_target(&conn, &store, "grouped").unwrap());
|
||
assert!(is_reinject_target(&conn, &store, "standalone").unwrap());
|
||
crate::credential_store::delete_mcp_auth_token_for_project(&store, "standalone").unwrap();
|
||
assert!(!is_reinject_target(&conn, &store, "standalone").unwrap());
|
||
assert_eq!(
|
||
reinject_targets(&conn, &store).unwrap(),
|
||
vec!["grouped".to_string()]
|
||
);
|
||
}
|
||
|
||
#[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 l1_facts_skip_control_plane_and_wsl_and_codex_helper_round_trips() {
|
||
let desired = DesiredMcp {
|
||
port: 27190,
|
||
server_url: "http://127.0.0.1:27190/mcp/projects/l1".into(),
|
||
header_helper: shim_helper_command("abc"),
|
||
refresh_dsh: false,
|
||
};
|
||
let control = ProjectFacts {
|
||
project_id: "l1",
|
||
root: std::path::Path::new("E:\\nowhere\\l1"),
|
||
control_plane: true,
|
||
wsl: false,
|
||
};
|
||
// 不适用时不读客户端配置(Claude、DSH 的判定都不碰 ~/.claude.json 与 ~/.dsh)
|
||
for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] {
|
||
assert_eq!(
|
||
adapter.l1_facts(&control, &desired).not_applicable,
|
||
Some(NotApplicableReason::ControlPlane)
|
||
);
|
||
}
|
||
let wsl = ProjectFacts {
|
||
project_id: "w",
|
||
root: std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"),
|
||
control_plane: false,
|
||
wsl: true,
|
||
};
|
||
for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] {
|
||
assert_eq!(
|
||
adapter.l1_facts(&wsl, &desired).not_applicable,
|
||
Some(NotApplicableReason::Wsl)
|
||
);
|
||
}
|
||
assert_eq!(
|
||
CODEX_ADAPTER.l1_facts(&control, &desired).not_applicable,
|
||
None
|
||
);
|
||
|
||
// Codex 的 helper 被 codex mcp get 脱敏:L1 从项目托管块回查,须与写入时逐字一致
|
||
let root = std::env::temp_dir().join(format!("lj-l1-codex-{}", uuid::Uuid::new_v4()));
|
||
let config = root.join(".codex").join("config.toml");
|
||
upsert_codex_mcp_config_with(
|
||
&config,
|
||
&desired.server_url,
|
||
&desired.header_helper,
|
||
&crate::DiskSink,
|
||
)
|
||
.unwrap();
|
||
let normal = ProjectFacts {
|
||
project_id: "l1",
|
||
root: &root,
|
||
control_plane: false,
|
||
wsl: false,
|
||
};
|
||
let facts = CODEX_ADAPTER.l1_facts(&normal, &desired);
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
match facts.source {
|
||
L1Source::Codex {
|
||
project_entry,
|
||
project_url,
|
||
project_helper,
|
||
} => {
|
||
assert!(project_entry);
|
||
assert_eq!(project_url.as_deref(), Some(desired.server_url.as_str()));
|
||
assert_eq!(
|
||
project_helper.as_deref(),
|
||
Some(desired.header_helper.as_str())
|
||
);
|
||
}
|
||
other => panic!("Codex 应给出 Codex 来源:{other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn claude_l1_entries_match_project_url_in_every_scope() {
|
||
use probe_l1::ClaudeEntrySource::{Local, Project, User};
|
||
let url = "http://127.0.0.1:27190/mcp/projects/p";
|
||
let entry = |helper: &str| claude_local_server_entry(url, helper);
|
||
let config = json!({
|
||
"mcpServers": { "lian-jing": entry("user") },
|
||
"projects": {
|
||
"E:/code": { "mcpServers": { "lian-jing": entry("ancestor") } },
|
||
"E:/code/proj": { "mcpServers": { "lian-jing": entry("exact") } },
|
||
// worktree 被 Claude 映射到主仓库根:键不覆盖本根目录,但 URL 是本项目地址
|
||
"E:/code/proj-main": { "mcpServers": { "lian-jing": entry("main-repo") } },
|
||
// 覆盖本根目录、却指向别处:不是本项目地址,不作候选
|
||
"E:/": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:9/mcp" } } }
|
||
}
|
||
});
|
||
let mcp_json = json!({ "mcpServers": { "lian-jing": entry("project") } });
|
||
let entries = claude_l1_entries(
|
||
&config,
|
||
Some(&mcp_json),
|
||
std::path::Path::new("E:\\code\\proj"),
|
||
url,
|
||
);
|
||
let got: Vec<(probe_l1::ClaudeEntrySource, &str)> = entries
|
||
.iter()
|
||
.map(|candidate| {
|
||
(
|
||
candidate.source,
|
||
candidate.entry["headersHelper"].as_str().unwrap(),
|
||
)
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
got,
|
||
vec![
|
||
(Local, "exact"),
|
||
(Local, "ancestor"),
|
||
(Local, "main-repo"),
|
||
(Project, "project"),
|
||
(User, "user"),
|
||
]
|
||
);
|
||
// ~/.claude.json 读不到时仍回查 .mcp.json
|
||
assert_eq!(
|
||
claude_l1_entries(
|
||
&Value::Null,
|
||
Some(&mcp_json),
|
||
std::path::Path::new("E:\\x"),
|
||
url
|
||
)
|
||
.len(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn managed_helpers_cover_current_and_legacy_shim_forms() {
|
||
assert!(legacy_shim_helper_command("d1").starts_with("\"%LOCALAPPDATA%\\lian-jing\\bin\\"));
|
||
assert!(legacy_shim_helper_command("d1").ends_with(&format!("{MCP_HEADER_SHIM_NAME}\" d1")));
|
||
assert_ne!(legacy_shim_helper_command("d1"), shim_helper_command("d1"));
|
||
}
|
||
|
||
/// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端):
|
||
/// LJ_L1_LIVE="<project_id>|<项目根目录>" cargo test l1_live -- --ignored --nocapture
|
||
/// 只打印矩阵(不含认证头),由人或统筹会话判读。
|
||
#[test]
|
||
#[ignore = "实机冒烟:起真实客户端自省进程并连运行中的炼境"]
|
||
fn l1_live_probe_against_running_lianjing() {
|
||
let spec = std::env::var("LJ_L1_LIVE").expect("设置 LJ_L1_LIVE=<project_id>|<root>");
|
||
let (project_id, root) = spec.split_once('|').expect("格式 <project_id>|<root>");
|
||
let started = std::time::Instant::now();
|
||
let report = probe_client_parity_at(project_id, std::path::Path::new(root), 27190).unwrap();
|
||
println!("{}", serde_json::to_string_pretty(&report).unwrap());
|
||
println!("elapsed_ms={}", started.elapsed().as_millis());
|
||
}
|
||
|
||
#[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 路径应通过"
|
||
);
|
||
}
|
||
}
|