fix(mcp/parity): 第二轮审查修复——串行同步队列、白名单持久剔除、诊断分类
- Claude local 同步统一投递到单个后台串行队列(注册与移除同队列、按提交顺序), 消除并发 claude CLI 竞态与「注入后立即移除」留下孤儿条目;移除路径不再阻塞界面 - DSH 白名单只收有项目 token 的项目,退组/移除后剔除持久;控制面项目写 null 停止哨兵, 不再越过它匹配到已登记的上级目录;Rust 与 JS 两端键规范化对齐(重复分隔符、盘符根、UNC) - 客户端侧同步日志改为 mcp_client_sync 分类,不覆盖诊断中每个项目的注入结果 - 批量重注入只在末尾刷新一次 DSH;启动刷新移到后台线程 - 项目根目录/平台变更时迁移客户端配置(清旧根 Claude 条目,已接入项目在新根重新注入) - SSE 消息路由仅在会话属于该项目时才移除;注入模块的进程调用统一 silent_cmd Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0bf5bd6994
commit
e4214cd839
@ -276,7 +276,12 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 2. 更新 workspace
|
||||
// 2. 更新 workspace(路径或平台变化时,先记下旧根目录,供客户端侧配置迁移)
|
||||
let root_may_change =
|
||||
input.win_path.is_some() || input.wsl_path.is_some() || input.platform.is_some();
|
||||
let old_root = root_may_change
|
||||
.then(|| crate::mcp_inject::project_root_with_conn(&conn, &id).ok())
|
||||
.flatten();
|
||||
conn.execute(
|
||||
"UPDATE project_workspaces SET
|
||||
wsl_path = COALESCE(?1, wsl_path),
|
||||
@ -297,6 +302,16 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
|
||||
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
|
||||
if let Some(old_root) = old_root {
|
||||
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
|
||||
if new_root.as_ref() != Some(&old_root) {
|
||||
drop(conn);
|
||||
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -126,7 +126,8 @@ pub fn run() {
|
||||
Some(r#"{"client":"shim"}"#),
|
||||
);
|
||||
}
|
||||
mcp_inject::refresh_dsh_patch(None);
|
||||
// 刷新 DSH 白名单要读所有项目根目录的 context.json,放到后台线程,不拖慢窗口启动
|
||||
std::thread::spawn(|| mcp_inject::refresh_dsh_patch(None));
|
||||
|
||||
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
||||
std::thread::spawn(|| {
|
||||
|
||||
@ -214,7 +214,7 @@ fn find_claude_bin() -> Option<String> {
|
||||
}
|
||||
|
||||
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
|
||||
if let Ok(out) = std::process::Command::new("where")
|
||||
if let Ok(out) = crate::silent_cmd("where")
|
||||
.arg("claude.exe")
|
||||
.output()
|
||||
{
|
||||
@ -252,7 +252,7 @@ fn find_git_bash() -> Option<String> {
|
||||
}
|
||||
|
||||
// 从 git.exe 推导:<root>\cmd\git.exe / <root>\mingw64\bin\git.exe → <root>\bin\bash.exe
|
||||
if let Ok(out) = std::process::Command::new("where").arg("git.exe").output() {
|
||||
if let Ok(out) = crate::silent_cmd("where").arg("git.exe").output() {
|
||||
if out.status.success() {
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let git = std::path::Path::new(line.trim());
|
||||
@ -292,7 +292,7 @@ fn try_register_globally(port: u16) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
match std::process::Command::new(&claude_bin)
|
||||
match crate::silent_cmd(&claude_bin)
|
||||
.args([
|
||||
"mcp",
|
||||
"add",
|
||||
@ -442,22 +442,49 @@ fn run_claude_cli(
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
|
||||
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
|
||||
|
||||
type ClaudeSyncJob = (String, PathBuf, Option<(String, String)>);
|
||||
static CLAUDE_SYNC_QUEUE: std::sync::OnceLock<std::sync::Mutex<std::sync::mpsc::Sender<ClaudeSyncJob>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// 所有 Claude local 同步投递到同一个后台线程,按提交顺序串行执行:避免多个 claude CLI 并发
|
||||
/// 读改写 ~/.claude.json,也保证「注入后立刻移除」时移除不会被更早的注册越过。调用方不阻塞。
|
||||
fn enqueue_claude_sync(project_id: &str, root: PathBuf, desired: Option<(String, String)>) {
|
||||
let queue = CLAUDE_SYNC_QUEUE.get_or_init(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<ClaudeSyncJob>();
|
||||
std::thread::spawn(move || {
|
||||
for (project_id, root, desired) in receiver {
|
||||
sync_claude_local(&project_id, &root, desired);
|
||||
}
|
||||
});
|
||||
std::sync::Mutex::new(sender)
|
||||
});
|
||||
if let Ok(sender) = queue.lock() {
|
||||
let _ = sender.send((project_id.to_string(), root, desired));
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
||||
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。
|
||||
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。
|
||||
fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) {
|
||||
let context = inject_log_context(project_id, "claude");
|
||||
let state = read_claude_user_config()
|
||||
.map(|config| classify_claude_local_entry(&config, root))
|
||||
.unwrap_or(ClaudeLocalEntry::Absent);
|
||||
if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) {
|
||||
return;
|
||||
}
|
||||
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步",
|
||||
Some(&context),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let state = read_claude_user_config()
|
||||
.map(|config| classify_claude_local_entry(&config, root))
|
||||
.unwrap_or(ClaudeLocalEntry::Absent);
|
||||
let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"];
|
||||
let outcome = match (state, desired) {
|
||||
(ClaudeLocalEntry::Manual, _) => {
|
||||
@ -474,7 +501,7 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
|
||||
let registered = if let ClaudeLocalEntry::Managed(old) = &previous {
|
||||
if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("Claude 项目级 MCP 旧条目移除失败: {error}"),
|
||||
Some(&context),
|
||||
@ -498,9 +525,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)),
|
||||
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
|
||||
Err(error) => db::log_event(
|
||||
"mcp_inject",
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("Claude 项目级 MCP 同步失败: {error}"),
|
||||
Some(&context),
|
||||
@ -514,16 +541,33 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
|
||||
// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址,
|
||||
// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。
|
||||
|
||||
/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。
|
||||
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
|
||||
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
|
||||
fn normalize_dsh_root(path: &str) -> String {
|
||||
path.replace('/', "\\").trim_end_matches('\\').to_lowercase()
|
||||
let unified = path.replace('/', "\\");
|
||||
let (prefix, rest) = match unified.strip_prefix("\\\\") {
|
||||
Some(rest) => ("\\\\", rest),
|
||||
None => ("", unified.as_str()),
|
||||
};
|
||||
let mut collapsed = String::with_capacity(rest.len());
|
||||
for ch in rest.chars() {
|
||||
if ch == '\\' && collapsed.ends_with('\\') {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(ch);
|
||||
}
|
||||
format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase()
|
||||
}
|
||||
|
||||
/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。
|
||||
/// DSH 白名单:已登记、非 WSL、根目录 context.json 的 project_id 与登记一致的 Windows 项目。
|
||||
/// - 普通项目须已有项目 token(helper 能工作的前提)才进入白名单——退组或移除时 token 被删,剔除即持久;
|
||||
/// - 控制面项目(mcp_scope=global)写入 None 作为停止哨兵:命中即停止向上查找,回退全局地址,
|
||||
/// 不会越过它匹配到已登记的上级目录。
|
||||
fn dsh_project_roots(
|
||||
conn: &rusqlite::Connection,
|
||||
store: &impl crate::credential_store::CredentialStore,
|
||||
exclude: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let rows: Vec<(String, String)> = conn
|
||||
.prepare(
|
||||
"SELECT id, win_path FROM project_workspaces
|
||||
@ -539,21 +583,31 @@ fn dsh_project_roots(
|
||||
.filter(|(id, _)| Some(id.as_str()) != exclude)
|
||||
.filter_map(|(id, win_path)| {
|
||||
let root = validate_registered_path(&win_path, "win_path").ok()?;
|
||||
// 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope
|
||||
let context =
|
||||
read_context_object(&root.join(".lian-jing").join("context.json"), "canonical 项目上下文")
|
||||
.ok()?;
|
||||
let bound = context.get("project_id").and_then(Value::as_str) == Some(id.as_str());
|
||||
(bound && !project_wants_global_scope(&root))
|
||||
.then(|| (normalize_dsh_root(&win_path), id))
|
||||
if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let key = normalize_dsh_root(&win_path);
|
||||
if context.get("mcp_scope").and_then(Value::as_str) == Some("global") {
|
||||
return Some((key, None));
|
||||
}
|
||||
let has_token = crate::credential_store::mcp_auth_digest(&id)
|
||||
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
|
||||
.is_ok();
|
||||
has_token.then(|| (key, Some(id)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
|
||||
fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
|
||||
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
||||
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
||||
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
|
||||
}
|
||||
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
|
||||
let roots_map: serde_json::Map<String, Value> = roots
|
||||
.iter()
|
||||
.map(|(root, id)| (root.clone(), json!(id)))
|
||||
@ -564,8 +618,8 @@ fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
|
||||
roots_line.as_str(),
|
||||
"let id;",
|
||||
"for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {",
|
||||
" const hit = roots[d.toLowerCase()];",
|
||||
" if (hit) { id = hit; break; }",
|
||||
" const key = d.toLowerCase().replace(/\\\\+$/, '');",
|
||||
" if (Object.prototype.hasOwnProperty.call(roots, key)) { id = roots[key] || undefined; break; }",
|
||||
"}",
|
||||
];
|
||||
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
||||
@ -696,7 +750,10 @@ pub fn ensure_dsh_patch(
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")),
|
||||
};
|
||||
let block = render_dsh_block(port, &dsh_project_roots(conn, exclude));
|
||||
let block = render_dsh_block(
|
||||
port,
|
||||
&dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude),
|
||||
);
|
||||
let next = upsert_dsh_block(¤t, &block)?;
|
||||
if next != current {
|
||||
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
|
||||
@ -712,7 +769,7 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) {
|
||||
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
|
||||
if let Err(error) = result {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("DSH 托管块刷新失败: {error}"),
|
||||
Some(&json!({ "client": "dsh" }).to_string()),
|
||||
@ -759,7 +816,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
||||
names.push(format!("lian-jing-{}", &gid[..8]));
|
||||
}
|
||||
for name in names {
|
||||
let ok = std::process::Command::new(&claude_bin)
|
||||
let ok = crate::silent_cmd(&claude_bin)
|
||||
.args(["mcp", "remove", "-s", "user", &name])
|
||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||
.output()
|
||||
@ -891,6 +948,11 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
|
||||
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
|
||||
/// 并清理旧组级 key(迁移内建于重注入)
|
||||
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
inject_project_mcp_with(project_id, true)
|
||||
}
|
||||
|
||||
/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。
|
||||
fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> {
|
||||
let port = crate::mcp_server::read_port();
|
||||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
||||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||||
@ -937,20 +999,23 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
try_register_globally(port);
|
||||
let root = project_root(project_id)?;
|
||||
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
|
||||
let owned_id = project_id.to_string();
|
||||
// claude CLI 每次调用约 1-2 秒,放到后台线程,避免同步命令阻塞界面
|
||||
std::thread::spawn(move || sync_claude_local(&owned_id, &root, desired));
|
||||
refresh_dsh_patch(None);
|
||||
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态
|
||||
enqueue_claude_sync(project_id, root, desired);
|
||||
if refresh_dsh {
|
||||
refresh_dsh_patch(None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
// 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
|
||||
// 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
|
||||
// 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。
|
||||
if let Ok(root) = project_root(project_id) {
|
||||
sync_claude_local(project_id, &root, None);
|
||||
enqueue_claude_sync(project_id, root, None);
|
||||
}
|
||||
// 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单
|
||||
refresh_dsh_patch(Some(project_id));
|
||||
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
||||
let path = settings_path(project_id)?;
|
||||
@ -1043,15 +1108,43 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for project_id in project_ids {
|
||||
if let Err(e) = inject_project_mcp(&project_id) {
|
||||
if let Err(e) = inject_project_mcp_with(&project_id, false) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
}
|
||||
}
|
||||
// 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志
|
||||
// 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志
|
||||
refresh_dsh_patch(None);
|
||||
errors
|
||||
}
|
||||
|
||||
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目
|
||||
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。
|
||||
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
|
||||
if let Some(root) = old_root {
|
||||
enqueue_claude_sync(project_id, root, None);
|
||||
}
|
||||
let injected = crate::credential_store::mcp_auth_digest(project_id)
|
||||
.and_then(|digest| {
|
||||
crate::credential_store::read_mcp_auth_token_for_digest(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&digest,
|
||||
)
|
||||
})
|
||||
.is_ok();
|
||||
if !injected {
|
||||
refresh_dsh_patch(None);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = inject_project_mcp(project_id) {
|
||||
db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!("项目根目录变更后重新注入失败: {error}"),
|
||||
Some(&inject_log_context(project_id, "all")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -1207,7 +1300,12 @@ mod tests {
|
||||
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let conn = crate::db::conn_with_schema();
|
||||
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true)] {
|
||||
let store = crate::credential_store::MemoryCredentialStore::new();
|
||||
for id in ["p1", "drift", "gone"] {
|
||||
crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap();
|
||||
}
|
||||
// notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单
|
||||
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true), ("notoken", None, true)] {
|
||||
let root = base.join(id);
|
||||
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
|
||||
let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 });
|
||||
@ -1227,13 +1325,24 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
assert!(project_wants_global_scope(&base.join("ctrl")));
|
||||
let roots = dsh_project_roots(&conn, Some("gone"));
|
||||
let ids: Vec<&str> = roots.iter().map(|(_, id)| id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["p1"], "只保留已绑定、非控制面、未被剔除的项目");
|
||||
assert_eq!(roots[0].0, normalize_dsh_root(&base.join("p1").display().to_string()));
|
||||
let roots = dsh_project_roots(&conn, &store, Some("gone"));
|
||||
let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string());
|
||||
assert_eq!(
|
||||
roots,
|
||||
vec![(key("ctrl"), None), (key("p1"), Some("p1".to_string()))],
|
||||
"控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsh_root_keys_match_js_normalization() {
|
||||
assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo");
|
||||
assert_eq!(normalize_dsh_root("E:/Code//Demo/"), "e:\\code\\demo", "正斜杠、重复与尾部分隔符");
|
||||
assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致");
|
||||
assert_eq!(normalize_dsh_root("\\\\Server\\Share\\x"), "\\\\server\\share\\x", "保留 UNC 前缀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_local_entry_uses_scoped_url_and_shim() {
|
||||
let entry: Value = serde_json::from_str(&claude_local_server_json(
|
||||
@ -1248,15 +1357,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
|
||||
let roots = vec![("e:\\code-project\\demo".to_string(), "p1".to_string())];
|
||||
let roots = vec![
|
||||
("e:\\code-project".to_string(), None),
|
||||
("e:\\code-project\\demo".to_string(), Some("p1".to_string())),
|
||||
];
|
||||
let block = render_dsh_block(27190, &roots);
|
||||
assert!(
|
||||
block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#),
|
||||
"控制面以 null 作停止哨兵"
|
||||
);
|
||||
assert!(block.contains("hasOwnProperty.call(roots, key)") && block.contains(r"replace(/\\+$/, '')"));
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
||||
let entry = &parsed[0]["insert"][0];
|
||||
assert_eq!(entry["id"], "mcp-lian-jing");
|
||||
assert_eq!(entry["config"]["serverName"], MCP_KEY);
|
||||
assert_eq!(entry["config"]["transport"], "streamable-http");
|
||||
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
|
||||
assert!(block.contains(r#"const roots = {"e:\\code-project\\demo":"p1"};"#), "白名单内嵌");
|
||||
assert!(!block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json");
|
||||
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
|
||||
assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址");
|
||||
|
||||
@ -1603,9 +1603,17 @@ async fn handle_scoped_sse_message(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
// 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。
|
||||
// 握手后项目可能被删除:每条消息复核登记。只移除确实属于该项目的会话,
|
||||
// 不能凭请求里任意给出的 session_id 删掉别的会话。
|
||||
if !scoped_project_registered(&project_id) {
|
||||
sessions.lock().await.remove(&query.session_id);
|
||||
let mut guard = sessions.lock().await;
|
||||
let owned = guard
|
||||
.get(&query.session_id)
|
||||
.and_then(|entry| entry.project_scope.as_deref())
|
||||
== Some(project_id.as_str());
|
||||
if owned {
|
||||
guard.remove(&query.session_id);
|
||||
}
|
||||
return unknown_project_response(&project_id, req.id.clone());
|
||||
}
|
||||
if authorize_sensitive_request(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user