feat(mcp/parity): DSH 桌面 GUI 按工作区连项目地址(会话级插件,P9) #26
@ -157,9 +157,10 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
||||
- 为什么:2026-10-01 人终验确认 DSH 桌面版会话只连全局地址。只读调研(两路独立 + 对抗复核)结论:DSH 桌面版只有一个 Host 进程,启动目录固定为 profile 目录,!!js 只在启动时求值、拿不到会话信息,mcp-client 的地址在实例化时固定,也不读任何项目级 MCP 配置,所以只改托管块做不到。可行路径是 DSH 自带的 Agent 作用域:每个 GUI 会话的 session.header.cwd 即工作区路径,会话创建时串行发出 agent/created;在 agent.ctx 上挂 dsh-mcp-client,工具只对该会话可见并覆盖同名全局工具(DSH 内置的 ACP 就这样按会话挂 MCP)。
|
||||
- 风险(实施前需人确认是否值得):依赖 DSH 内部接口(agent/created、agent.ctx.plugin、ctx.loader.import、按 scope 分账),DSH 升级可能失效,须特性探测并降级为全局;Host 有 installFailLoud,插件任何未处理异常都会打崩桌面 Host;agent/created 是串行事件,挂载须加短超时,炼境离线时静默降级;Node 按 URL 缓存 ESM,插件升级须换文件名;子代理看不到父会话作用域的工具,是否给子代理单独挂载需人定;属于往 DSH 进程注入代码,信任边界与现有 !!js + execFileSync 相同,但需按「管家准入」论证。
|
||||
- 可行性实测(2026-10-02,用户确认「先实测再实现」):临时 DSH_HOME + 记录请求的桩 MCP 服务 + 原型插件(%TEMP%\lj-p9-spike),经 ACP 在同一进程内新建两个不同 cwd 的会话。DSH 0.2.0-rc.2(桌面版内置)与 0.1.5-rc.2(enterprise 本地)结果一致:插件按绝对路径从 home patch 加载;ctx.loader.import('@deepseek-ai/dsh-mcp-client') 成功;每个会话触发 agent/created 且带 session.header.cwd;项目内会话挂载后桩服务收到 /mcp/projects/<id> 的 initialize 与 tools/list(带认证头),另一会话无任何请求。0.1.x 没有 profileContext,事件里也没有 source 字段。未实测:挂载工具对该会话模型的可见性、桌面 GUI 热加载(只有代码阅读结论)。实现需在 PR #24 合并后从 master 开分支。
|
||||
- 实现(2026-10-02,分支 feat/agent-client-parity-p9):插件随二进制打包(include_str!),ensure_dsh_patch 刷新时写到 %USERPROFILE%\.lian-jing\dsh\(内容不变不写),托管块在全局行之后追加 lianjing-session-mcp 条目(port、shim、roots 以 JSON 写入,路径含引号空格也合法);插件文件写失败时只保留全局行。插件行为:headless 交给全局行(不重复挂载);特性探测 agents/on/loader.import 与 mcp-client 的 Config/apply,不符即降级为全局;按会话 cwd 匹配白名单,命中才在 agent.ctx 挂 serverName=lian-jing 的 streamable-http 客户端,经稳定入口异步取认证(≤3 秒,失败则不带认证头挂载),整个挂载 ≤5 秒;所有回调吞掉异常;会话销毁时释放;子代理同样按 cwd 挂载(子代理看不到父会话作用域的工具)。
|
||||
- verification: "2026-10-02:cargo test 246 passed(托管块双条目结构与插件内容契约);vitest 6 条插件用例(匹配/null 哨兵、挂载 URL 与无认证降级、销毁释放、headless 不注册、接口缺失与 mcp-client 加载失败降级、挂载异常不外抛)通过,typecheck 通过。端到端(正式插件文件 + 炼境同款托管块 + 临时 DSH_HOME + 桩服务,认证经真实稳定入口):DSH 0.2.0-rc.2 与 0.1.5-rc.2 上,项目子目录会话产生 /mcp/projects/<enterprise id> 的 initialize 与 tools/list 且带认证头,其他目录会话无任何请求;0.2 在 session/new 返回前工具已就绪(167ms),0.1.5 约在返回后 70ms 就绪。"
|
||||
- known_limits: "未实测桌面 GUI 内:挂载工具对会话模型的可见性(DSH 的 scope 覆盖只有代码阅读结论)、改托管块后的热加载(不生效时需重启 DSH)。0.1.x 上工具在会话创建后约几十毫秒才就绪,极端情况下首轮请求可能只看到全局工具。依赖 DSH 内部接口,升级后可能失效(插件会降级并在 stderr 打 [lian-jing] 提示)。"
|
||||
- 实现(2026-10-02,分支 feat/agent-client-parity-p9):插件随二进制打包(include_str!),ensure_dsh_patch 刷新时写到 %USERPROFILE%\.lian-jing\dsh\(内容不变不写),托管块在全局行之后追加 lianjing-session-mcp 条目(port、shim、roots 以 JSON 写入,路径含引号空格也合法);插件文件写失败时只保留全局行。插件行为:headless 交给全局行(不重复挂载);特性探测 agents/on/loader.import 与 mcp-client 的 Config/apply,不符即降级为全局;按会话 cwd 匹配白名单,命中才在 agent.ctx 挂 serverName=lian-jing 的 streamable-http 客户端,经稳定入口异步取认证(≤3 秒,失败则不带认证头挂载),整个挂载 ≤5 秒;所有回调吞掉异常;会话销毁时释放;子代理(header.origin=subagent)按 cwd 挂载,但带工具限制(toolFilter)或无法判断时不挂——挂在子代理自己作用域的工具不受限制约束。
|
||||
- 对抗审查(11 个 agent,6 条成立均为 low、已修):受限子代理会绕过 toolFilter → 经 tools.view 判断,受限即不挂;版本号只编进文件名时运行中的 DSH 不会重载 → 条目 id 同带版本号(lianjing-session-mcp-v1),并加内容哈希测试拦漏升版本;插件文件缺失时 DSH 0.1.x 启动失败 → 条目加 `disabled: !!js` 文件存在性守卫;GUI 工作目录为 realpath → 白名单同时写入登记路径的规范形态(junction/8.3 短名);补分层停止哨兵、取认证失败/超时用例;验收记录更正(首轮端到端的补丁只有插件行,见下)。
|
||||
- verification: "2026-10-02:cargo test 247 passed(托管块双条目、版本化 id 与存在性守卫、插件内容哈希、规范路径键);vitest 8 条插件用例通过(含分层哨兵、取认证失败与超时、受限/未受限/无法判断的子代理),typecheck 通过。端到端第一轮的补丁只有插件一行;审查后补测完整托管块(从真实 ~/.dsh 复制的全局行 + 插件行 + 存在性守卫 + 停止哨兵,临时 DSH_HOME + 桩服务,认证经真实稳定入口):DSH 0.2.0-rc.2 与 0.1.5-rc.2 上项目子目录会话产生 /mcp/projects/<enterprise id> 的 initialize 与 tools/list 且带认证头,其他目录会话无任何请求;插件文件缺失时两个版本都正常启动、会话正常、插件条目不加载。审查者另测:全局行与插件共存时 headless 只有一组连接;项目地址 tools/list 很慢时会话创建在约 5 秒上限内返回。"
|
||||
- known_limits: "未实测桌面 GUI 内:挂载工具对会话模型的可见性(DSH 的 scope 覆盖只有代码阅读与 ACP 探针结论)、改托管块后的热加载(审查者在 ACP 开启 hmr 实测 id 变化会重载;GUI 不生效时重启 DSH)。0.1.x 上工具在会话创建后约几十毫秒才就绪,极端情况下首轮请求可能只看到全局工具。依赖 DSH 内部接口,升级后可能失效(插件会降级并在 stderr 打 [lian-jing] 提示)。flaky:mcp_server 的 scoped_mcp_create_pull_request_uses_synthetic_credential 在全量并行测试中偶发失败(单跑通过,与本卡无关)。"
|
||||
|
||||
### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行
|
||||
- status: done
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
// 炼境托管的 DSH 插件:会话创建时按该会话的工作目录匹配炼境登记的项目根目录,命中则只在这个会话的
|
||||
// 作用域挂一个连项目专用地址的 mcp-client(同名工具覆盖全局 lian-jing),未命中不做任何事。
|
||||
// 由炼境写入 %USERPROFILE%\.lian-jing\dsh\ 并在 ~/.dsh/cordis.patch.yml 托管块中引用,勿手改,会被覆盖。
|
||||
// 内容变化时文件名里的版本号必须递增:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重新读取。
|
||||
// 内容变化时文件名与托管块条目 id 的版本号必须一起递增:DSH 只在条目 id 变化时才卸载旧条目、import 新模块
|
||||
// (同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM);mcp_inject.rs 的内容哈希测试会拦住漏升版本。
|
||||
//
|
||||
// 约束:DSH 桌面 Host 对未处理异常会整体崩溃,agent/created 又是串行事件(监听器失败会让会话创建失败),
|
||||
// 所以这里所有回调都吞掉异常、挂载有时间上限,炼境离线或 DSH 接口变化时静默降级为只有全局工具。
|
||||
@ -11,7 +12,7 @@ import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const name = "lianjing-session-mcp";
|
||||
export const inject = ["agents", "loader"];
|
||||
export const inject = ["agents", "loader", "tools"];
|
||||
|
||||
const MCP_KEY = "lian-jing";
|
||||
/** 整个挂载(取认证 + 等工具就绪)的上限:agent/created 是串行事件,不能拖慢会话创建太久。 */
|
||||
@ -44,6 +45,24 @@ export function isHeadless(ctx) {
|
||||
return profile === "headless";
|
||||
}
|
||||
|
||||
/**
|
||||
* 子代理是否带工具限制(toolFilter)。插件挂在子代理自己作用域里的工具不受限制约束,所以受限子代理不挂载,
|
||||
* 只保留经限制过滤后的全局 lian-jing 工具;无法判断时同样不挂(宁可少给)。根会话与未受限子代理照常挂载。
|
||||
*/
|
||||
export function isRestrictedSubagent(ctx, agent) {
|
||||
if (agent?.session?.header?.origin !== "subagent") return false;
|
||||
try {
|
||||
const view = ctx.tools?.view?.(agent);
|
||||
if (!view?.restrictableNames || typeof view.visible?.has !== "function") return true;
|
||||
for (const toolName of view.restrictableNames) {
|
||||
if (!view.visible.has(toolName)) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 经炼境稳定入口取认证头;入口缺失、失败或超时都返回 undefined(只影响需认证的工具),不输出认证值。 */
|
||||
export function readAuthorization(shim, projectId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
@ -104,6 +123,7 @@ export async function apply(ctx, config) {
|
||||
const attach = async (agent) => {
|
||||
const id = findProjectId(config?.roots, agent?.session?.header?.cwd);
|
||||
if (!id || mounted.has(agent) || typeof agent?.ctx?.plugin !== "function") return;
|
||||
if (isRestrictedSubagent(ctx, agent)) return;
|
||||
mounted.set(agent, undefined);
|
||||
const authorization = await readAuthorization(config?.shim, id, AUTH_TIMEOUT_MS);
|
||||
if (!authorization) warn(`未取到项目 ${id} 的认证头,需认证的开发变量工具将不可用`);
|
||||
|
||||
@ -715,11 +715,15 @@ fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
// (resources/dsh/,写到 %USERPROFILE%\.lian-jing\dsh\)在 agent/created 时按会话工作目录挂项目地址,
|
||||
// 只对该会话可见并覆盖同名全局工具(蓝图 agent-client-parity P9)。
|
||||
|
||||
/// 会话级插件文件名。插件内容变化时必须递增版本号:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重读。
|
||||
/// 会话级插件的文件名与托管块条目 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";
|
||||
const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp-v1";
|
||||
/// 任意版本的会话级插件条目 id 前缀(判断块外手写条目用)。
|
||||
const DSH_SESSION_PLUGIN_ID_PREFIX: &str = "lianjing-session-mcp";
|
||||
|
||||
/// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.dsh 配置,不进项目仓库)。
|
||||
struct DshSessionPlugin {
|
||||
@ -796,20 +800,42 @@ fn dsh_project_roots(
|
||||
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)))
|
||||
let value = if context.get("mcp_scope").and_then(Value::as_str) == Some("global") {
|
||||
None
|
||||
} else {
|
||||
let has_token = crate::credential_store::mcp_auth_digest(&id)
|
||||
.and_then(|digest| {
|
||||
crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)
|
||||
})
|
||||
.is_ok();
|
||||
if !has_token {
|
||||
return None;
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
Some((win_path, root, value))
|
||||
})
|
||||
.flat_map(|(win_path, root, value)| {
|
||||
// DSH GUI 会话的工作目录是 realpath;登记路径若经 junction/符号链接或 8.3 短名,两种键都写进白名单
|
||||
let key = normalize_dsh_root(win_path.trim());
|
||||
let canonical = std::fs::canonicalize(&root)
|
||||
.ok()
|
||||
.map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string())))
|
||||
.filter(|canonical| canonical != &key);
|
||||
std::iter::once((key, value.clone()))
|
||||
.chain(canonical.map(|canonical| (canonical, value)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。
|
||||
fn strip_verbatim_prefix(path: &str) -> String {
|
||||
match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(rest) => format!(r"\\{rest}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dsh_block(
|
||||
port: u16,
|
||||
roots: &[(String, Option<String>)],
|
||||
@ -897,6 +923,12 @@ fn render_dsh_block(
|
||||
// 路径与白名单都以 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)));
|
||||
@ -924,14 +956,17 @@ fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
|
||||
.and_then(serde_yaml::Value::as_sequence)
|
||||
.is_some_and(|entries| {
|
||||
entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry.get("id").and_then(serde_yaml::Value::as_str),
|
||||
Some("mcp-lian-jing" | DSH_SESSION_PLUGIN_ID)
|
||||
) || entry
|
||||
.get("config")
|
||||
.and_then(|config| config.get("serverName"))
|
||||
entry
|
||||
.get("id")
|
||||
.and_then(serde_yaml::Value::as_str)
|
||||
== Some(MCP_KEY)
|
||||
.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)
|
||||
})
|
||||
})
|
||||
}))
|
||||
@ -1720,14 +1755,37 @@ mod tests {
|
||||
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,
|
||||
vec![(key("ctrl"), None), (key("p1"), Some("p1".to_string()))],
|
||||
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");
|
||||
@ -1942,6 +2000,14 @@ mod tests {
|
||||
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!(
|
||||
@ -1962,9 +2028,28 @@ mod tests {
|
||||
#[test]
|
||||
fn dsh_session_plugin_source_matches_versioned_contract() {
|
||||
// 插件内容随二进制打包;文件名带版本号,内容变化须同步递增(Node 按 URL 缓存 ESM)
|
||||
assert!(DSH_SESSION_PLUGIN_FILE.starts_with("lianjing-session-mcp.v"));
|
||||
assert!(DSH_SESSION_PLUGIN_SOURCE
|
||||
.contains(&format!("export const name = \"{DSH_SESSION_PLUGIN_ID}\"")));
|
||||
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/"),
|
||||
@ -2002,6 +2087,7 @@ mod tests {
|
||||
"- 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(),
|
||||
|
||||
3
src/lib/__fixtures__/shim-fail.cmd
Normal file
3
src/lib/__fixtures__/shim-fail.cmd
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
rem test fixture: the stable header helper fails (non-zero exit)
|
||||
exit /b 1
|
||||
4
src/lib/__fixtures__/shim-hang.cmd
Normal file
4
src/lib/__fixtures__/shim-hang.cmd
Normal file
@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
rem test fixture: the stable header helper hangs (ping instead of timeout /t, which exits at once when stdin is redirected)
|
||||
ping -n 6 127.0.0.1 >nul
|
||||
exit /b 0
|
||||
@ -11,25 +11,36 @@ interface MountOptions {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
interface FakeAgent {
|
||||
session: { header: { cwd: string } };
|
||||
session: { header: { cwd: string; origin?: string } };
|
||||
ctx: { plugin: (client: unknown, options: MountOptions) => { dispose: () => void } };
|
||||
}
|
||||
interface ToolView {
|
||||
visible: Set<string>;
|
||||
restrictableNames: Set<string>;
|
||||
}
|
||||
type Handler = (event: { agent: FakeAgent }) => unknown;
|
||||
interface PluginModule {
|
||||
name: string;
|
||||
findProjectId: (roots: Roots | undefined, cwd: unknown) => string | undefined;
|
||||
readAuthorization: (shim: string, projectId: string, timeoutMs: number) => Promise<string | undefined>;
|
||||
apply: (ctx: unknown, config: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
const pluginUrl = new URL("../../src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs", import.meta.url).href;
|
||||
const loadPlugin = async (): Promise<PluginModule> =>
|
||||
(await import(/* @vite-ignore */ pluginUrl)) as PluginModule;
|
||||
const fixturePath = (name: string) =>
|
||||
decodeURIComponent(new URL(`./__fixtures__/${name}`, import.meta.url).pathname)
|
||||
.replace(/^\/([A-Za-z]:)/, "$1")
|
||||
.replace(/\//g, "\\");
|
||||
|
||||
const roots: Roots = { "e:\\code-project": null, "e:\\code-project\\demo": "p 1" };
|
||||
const config = { port: 27190, shim: "Z:\\lianjing-test-missing\\lianjing-mcp-header.cmd", roots };
|
||||
const fakeClient = { Config: (options: MountOptions) => options, apply: () => undefined };
|
||||
|
||||
function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?: boolean } = {}) {
|
||||
function fakeDsh(
|
||||
options: { profile?: string; importFails?: boolean; agentsApi?: boolean; toolView?: (agent: FakeAgent) => ToolView } = {},
|
||||
) {
|
||||
const handlers = new Map<string, Handler[]>();
|
||||
const ctx = {
|
||||
get: (key: string) => (key === "profileContext" && options.profile ? { name: options.profile } : undefined),
|
||||
@ -40,6 +51,7 @@ function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?:
|
||||
return fakeClient;
|
||||
},
|
||||
},
|
||||
tools: options.toolView ? { view: options.toolView } : undefined,
|
||||
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
|
||||
effect: () => undefined,
|
||||
};
|
||||
@ -49,14 +61,18 @@ function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?:
|
||||
return { ctx, handlers, emit };
|
||||
}
|
||||
|
||||
function fakeAgent(cwd: string, mounts: MountOptions[], disposed: string[] = [], throws = false): FakeAgent {
|
||||
function fakeAgent(
|
||||
cwd: string,
|
||||
mounts: MountOptions[],
|
||||
options: { disposed?: string[]; throws?: boolean; origin?: string } = {},
|
||||
): FakeAgent {
|
||||
return {
|
||||
session: { header: { cwd } },
|
||||
session: { header: { cwd, origin: options.origin } },
|
||||
ctx: {
|
||||
plugin: (_client, options) => {
|
||||
if (throws) throw new Error("serverName already in use");
|
||||
mounts.push(options);
|
||||
return { dispose: () => disposed.push(options.url) };
|
||||
plugin: (_client, mountOptions) => {
|
||||
if (options.throws) throw new Error("serverName already in use");
|
||||
mounts.push(mountOptions);
|
||||
return { dispose: () => options.disposed?.push(mountOptions.url) };
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -72,6 +88,11 @@ describe("DSH 会话级插件", () => {
|
||||
expect(plugin.findProjectId(roots, "D:\\elsewhere")).toBeUndefined();
|
||||
expect(plugin.findProjectId(roots, "")).toBeUndefined();
|
||||
expect(plugin.findProjectId(undefined, "E:\\code-project\\demo")).toBeUndefined();
|
||||
// 分层:控制面(null)位于已登记项目之下时,命中即停,不越过它继承上级项目
|
||||
const layered: Roots = { "e:\\top": "p-top", "e:\\top\\cp": null };
|
||||
expect(plugin.findProjectId(layered, "E:\\top\\cp\\other")).toBeUndefined();
|
||||
expect(plugin.findProjectId(layered, "E:\\top\\cp")).toBeUndefined();
|
||||
expect(plugin.findProjectId(layered, "E:\\top\\x")).toBe("p-top");
|
||||
});
|
||||
|
||||
it("只给命中项目的会话挂项目专用地址;取不到认证时仍挂载但不带认证头", async () => {
|
||||
@ -88,13 +109,47 @@ describe("DSH 会话级插件", () => {
|
||||
expect(mounts[0].headers).toEqual({});
|
||||
});
|
||||
|
||||
it("取认证失败或超时都返回 undefined,且受超时上限约束", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
await expect(plugin.readAuthorization(fixturePath("shim-fail.cmd"), "p1", 3000)).resolves.toBeUndefined();
|
||||
const started = Date.now();
|
||||
await expect(plugin.readAuthorization(fixturePath("shim-hang.cmd"), "p1", 300)).resolves.toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it("子代理:带工具限制或无法判断时不挂载,未受限时照常挂载", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const allTools = new Set(["job_output", "mcp__lian-jing__list_all_projects"]);
|
||||
const restricted: ToolView = { visible: new Set(["job_output"]), restrictableNames: allTools };
|
||||
const unrestricted: ToolView = { visible: new Set(allTools), restrictableNames: allTools };
|
||||
const cwd = "E:\\code-project\\demo";
|
||||
|
||||
for (const [view, expected] of [
|
||||
[restricted, 0],
|
||||
[unrestricted, 1],
|
||||
] as const) {
|
||||
const dsh = fakeDsh({ profile: "desktop", toolView: () => view });
|
||||
await plugin.apply(dsh.ctx, config);
|
||||
const mounts: MountOptions[] = [];
|
||||
await dsh.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" }));
|
||||
expect(mounts).toHaveLength(expected);
|
||||
}
|
||||
|
||||
const noToolsApi = fakeDsh({ profile: "desktop" });
|
||||
await plugin.apply(noToolsApi.ctx, config);
|
||||
const mounts: MountOptions[] = [];
|
||||
await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" }));
|
||||
await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts));
|
||||
expect(mounts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("会话销毁时释放挂载", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const dsh = fakeDsh({ profile: "web" });
|
||||
await plugin.apply(dsh.ctx, config);
|
||||
const mounts: MountOptions[] = [];
|
||||
const disposed: string[] = [];
|
||||
const agent = fakeAgent("E:\\code-project\\demo", mounts, disposed);
|
||||
const agent = fakeAgent("E:\\code-project\\demo", mounts, { disposed });
|
||||
await dsh.emit("agent/created", agent);
|
||||
await dsh.emit("agent/disposed", agent);
|
||||
expect(disposed).toEqual(["http://127.0.0.1:27190/mcp/projects/p%201"]);
|
||||
@ -122,7 +177,7 @@ describe("DSH 会话级插件", () => {
|
||||
await plugin.apply(dsh.ctx, config);
|
||||
const mounts: MountOptions[] = [];
|
||||
await expect(
|
||||
dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo", mounts, [], true)),
|
||||
dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo", mounts, { throws: true })),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user