feat(onboard/parity): L1 零模型三端检查 probe_client_parity,处置独立审查(P4a) #38
File diff suppressed because one or more lines are too long
@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具
|
||||
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
|
||||
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
||||
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口)
|
||||
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
|
||||
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
|
||||
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -515,6 +515,20 @@ pub fn apply_onboarding_pack(
|
||||
})
|
||||
}
|
||||
|
||||
/// 三端对等 L1 零模型检查(agent-client-parity P4a)的 UI 投影,与 MCP probe_client_parity 同一本体。
|
||||
/// 会起零模型的客户端自省进程并回调本机 MCP 服务(约 5–10 秒),放到阻塞线程池执行,不占界面线程。
|
||||
#[tauri::command]
|
||||
pub async fn probe_client_parity(
|
||||
project_id: String,
|
||||
tier: Option<String>,
|
||||
) -> Result<crate::onboard::probe_l1::L1Report, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::mcp_inject::probe_client_parity(&project_id, tier.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("L1 检查线程异常: {error}"))?
|
||||
}
|
||||
|
||||
// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
|
||||
|
||||
/// 单项验收结果。
|
||||
|
||||
@ -442,6 +442,7 @@ pub fn run() {
|
||||
list_buffed_projects,
|
||||
// onboarding pack
|
||||
commands::onboarding::apply_onboarding_pack,
|
||||
commands::onboarding::probe_client_parity,
|
||||
// agent infrastructure
|
||||
commands::agent_infra::scan_agent_infra_stack,
|
||||
commands::agent_infra::generate_agent_infra_files,
|
||||
|
||||
@ -2,6 +2,9 @@ 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;
|
||||
@ -102,6 +105,11 @@ 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}")
|
||||
}
|
||||
|
||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
|
||||
@ -637,22 +645,13 @@ enum ClaudeEffective {
|
||||
}
|
||||
|
||||
/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。
|
||||
/// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。
|
||||
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
|
||||
if output.contains("No MCP server found") {
|
||||
return Some(ClaudeEffective::NotLocal);
|
||||
}
|
||||
let field = |name: &str| {
|
||||
output.lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix(name)
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
};
|
||||
let scope = field("Scope:")?;
|
||||
if scope.starts_with("Local config") {
|
||||
Some(ClaudeEffective::Local(field("URL:")?))
|
||||
} else {
|
||||
Some(ClaudeEffective::NotLocal)
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1438,8 +1437,7 @@ fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, S
|
||||
}
|
||||
|
||||
fn has_manual_codex_entry(text: &str) -> bool {
|
||||
text.lines()
|
||||
.any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]"))
|
||||
probe_l1::codex_has_table(text)
|
||||
}
|
||||
|
||||
/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定
|
||||
@ -2063,6 +2061,104 @@ impl ClientAdapter for ClaudeAdapter {
|
||||
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 会清理),
|
||||
@ -2136,6 +2232,26 @@ impl ClientAdapter for CodexAdapter {
|
||||
}
|
||||
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。
|
||||
@ -2185,6 +2301,239 @@ impl ClientAdapter for DshAdapter {
|
||||
}
|
||||
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)。
|
||||
@ -3757,6 +4106,155 @@ trust_level = "trusted"
|
||||
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());
|
||||
|
||||
@ -50,7 +50,11 @@ fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str
|
||||
.and_then(|info| info.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if client_name == "lian-jing-readiness" {
|
||||
// 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据
|
||||
if client_name == "lian-jing-readiness"
|
||||
|| client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME
|
||||
|| project_initializes_quiet(project_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(mut clients) = initialized_project_clients().write() {
|
||||
@ -58,6 +62,50 @@ fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str
|
||||
}
|
||||
}
|
||||
|
||||
/// 正在跑 L1 检查的项目 → 并发检查数。
|
||||
static QUIET_PROJECTS: OnceLock<RwLock<HashMap<String, usize>>> = OnceLock::new();
|
||||
|
||||
fn quiet_projects() -> &'static RwLock<HashMap<String, usize>> {
|
||||
QUIET_PROJECTS.get_or_init(|| RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn project_initializes_quiet(project_id: &str) -> bool {
|
||||
quiet_projects()
|
||||
.read()
|
||||
.map(|projects| projects.contains_key(project_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// L1 检查期间(守卫存活时)本项目路由上的 initialize 一律不计入「真实客户端已连接」。
|
||||
/// 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连接,服务端无法与真实会话
|
||||
/// 区分;检查期间(≤10 秒)恰好新开的真实会话也因此不计入——reload_required 宁可多提示一次重启,
|
||||
/// 也不在客户端仍用旧配置时报「已重载」(agent-client-parity P4a 审查 ③)。
|
||||
pub(crate) struct QuietInitializeGuard {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn quiet_project_initializes(project_id: &str) -> QuietInitializeGuard {
|
||||
if let Ok(mut projects) = quiet_projects().write() {
|
||||
*projects.entry(project_id.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
QuietInitializeGuard {
|
||||
project_id: project_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QuietInitializeGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut projects) = quiet_projects().write() {
|
||||
if let Some(count) = projects.get_mut(&self.project_id) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
projects.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_initialized_project_client(project_id: &str) -> bool {
|
||||
initialized_project_clients()
|
||||
.read()
|
||||
@ -1524,6 +1572,14 @@ mod tests {
|
||||
process_rpc(&readiness, Some(project_id)).await.unwrap();
|
||||
assert!(!has_initialized_project_client(project_id));
|
||||
|
||||
// L1 零模型探针(P4a)的认证链重放同样不算真实客户端
|
||||
let mut l1_probe = rpc("initialize");
|
||||
l1_probe.params = Some(json!({
|
||||
"clientInfo": { "name": crate::onboard::probe_l1::PROBE_CLIENT_NAME, "version": "1" }
|
||||
}));
|
||||
process_rpc(&l1_probe, Some(project_id)).await.unwrap();
|
||||
assert!(!has_initialized_project_client(project_id));
|
||||
|
||||
let mut codex = rpc("initialize");
|
||||
codex.params = Some(json!({
|
||||
"clientInfo": { "name": "codex", "version": "test" }
|
||||
@ -1532,6 +1588,37 @@ mod tests {
|
||||
assert!(has_initialized_project_client(project_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn l1_quiet_window_hides_claude_self_introspection_from_reload_evidence() {
|
||||
let project_id = "reload-evidence-quiet-fixture";
|
||||
let other = "reload-evidence-quiet-other";
|
||||
let claude = |project: &'static str| async move {
|
||||
let mut init = rpc("initialize");
|
||||
init.params = Some(json!({
|
||||
"clientInfo": { "name": "claude-code", "version": "2.1.173" }
|
||||
}));
|
||||
process_rpc(&init, Some(project)).await.unwrap();
|
||||
};
|
||||
{
|
||||
// 两次并发检查:先结束的那次不能提前解除静默
|
||||
let first = quiet_project_initializes(project_id);
|
||||
let second = quiet_project_initializes(project_id);
|
||||
claude(project_id).await;
|
||||
drop(first);
|
||||
claude(project_id).await;
|
||||
assert!(
|
||||
!has_initialized_project_client(project_id),
|
||||
"L1 检查期间 claude mcp get / init 的连接不得算作真实客户端"
|
||||
);
|
||||
// 静默只针对正在检查的项目
|
||||
claude(other).await;
|
||||
assert!(has_initialized_project_client(other));
|
||||
drop(second);
|
||||
}
|
||||
claude(project_id).await;
|
||||
assert!(has_initialized_project_client(project_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_route_rejects_unregistered_project() {
|
||||
db::init_test_pool();
|
||||
|
||||
@ -400,6 +400,11 @@ pub fn tools_list_result() -> Value {
|
||||
if let Value::Array(envoy_tools) = envoy_tool_schemas() {
|
||||
tools.splice(after_list..after_list, envoy_tools);
|
||||
}
|
||||
let after_verify = tools
|
||||
.iter()
|
||||
.position(|tool| tool["name"] == "verify_onboarding")
|
||||
.map_or(tools.len(), |index| index + 1);
|
||||
tools.insert(after_verify, parity_probe_tool_schema());
|
||||
let after_scan = tools
|
||||
.iter()
|
||||
.position(|tool| tool["name"] == "scan_unregistered_projects")
|
||||
@ -410,6 +415,23 @@ pub fn tools_list_result() -> Value {
|
||||
result
|
||||
}
|
||||
|
||||
/// 三端对等 L1 零模型检查(agent-client-parity P4a)。单独成函数:并入主清单会超出 json! 宏递归上限。
|
||||
/// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。
|
||||
fn parity_probe_tool_schema() -> Value {
|
||||
json!({
|
||||
"name": "probe_client_parity",
|
||||
"description": "三端对等 L1 零模型检查(只读、不调用模型、零额度,10 秒内返回):在项目目录对 Claude Code 执行 claude mcp get 与坏模型名的 init 自省、对 Codex 执行 codex mcp get --json、读 DSH 托管块白名单与会话插件哈希;再按各客户端方式执行稳定 helper,在其配置的项目地址上依次 initialize、tools/list、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(需求名 LJ_PROBE_<nonce>,认证通过即返回「开发需求未登记」)重放认证链。返回 A–F × 三端矩阵,每格带状态(ready/fail/unknown/not_installed/not_applicable/blocked/timeout;客户端自己没连上或看不到某工具时,协议重放通过的格子记 blocked 或 tools_mismatch)、证据等级(static < client_introspection < protocol)与归因(如 shim_missing、auth_failed、unreachable、route_global、codex_project_untrusted)。认证头只在炼境进程内使用,不进入结果;只执行炼境托管写法的 helper,其余记 unknown(manual_config);零模型 init 关闭全部 hooks。控制面项目上 Claude 与 DSH 记 not_applicable;未安装的客户端记 not_installed、不计失败。L1 是配置与协议重放,绿灯不等于真实会话可用。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects)" },
|
||||
"tier": { "type": "string", "enum": ["l1"], "description": "检查层级,目前只有 l1(零模型),省略即 l1" }
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 登记项目(P8f,单独成函数:并入主清单会超出 json! 宏递归上限)。只开放在全局地址。
|
||||
fn register_project_schema() -> Value {
|
||||
json!({
|
||||
@ -477,10 +499,10 @@ fn is_project_scoped_tool(tool: &Value) -> bool {
|
||||
if name == "get_diagnostics" {
|
||||
return true;
|
||||
}
|
||||
// 登记与删除登记属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id)
|
||||
// 登记与删除登记、三端检查属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id)
|
||||
if matches!(
|
||||
name,
|
||||
"register_project" | "unregister_project" | "setup_gitea_repo"
|
||||
"register_project" | "unregister_project" | "setup_gitea_repo" | "probe_client_parity"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@ -614,6 +636,7 @@ fn apply_tool_annotations(result: &mut Value) {
|
||||
|| name.starts_with("plan_")
|
||||
|| name.starts_with("predict_")
|
||||
|| name == "verify_onboarding"
|
||||
|| name == "probe_client_parity"
|
||||
|| name == "generate_blueprint_prompt";
|
||||
let destructive = matches!(
|
||||
name,
|
||||
@ -772,6 +795,7 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
|
||||
.as_str()
|
||||
.unwrap_or("接入验收结构化结果已返回")
|
||||
.to_string(),
|
||||
"probe_client_parity" => parity_probe_summary(value),
|
||||
"register_project" => {
|
||||
let summary = format!(
|
||||
"已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project",
|
||||
@ -788,12 +812,67 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// L1 矩阵的一行摘要:每端「ready 格数/适用格数」与非 ready 格的归因(结构化结果在 structuredContent)。
|
||||
fn parity_probe_summary(value: &Value) -> String {
|
||||
let columns: Vec<String> = value["clients"]
|
||||
.as_array()
|
||||
.map(|clients| {
|
||||
clients
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let client = column["client"].as_str().unwrap_or("?");
|
||||
let cells = column["cells"].as_object();
|
||||
let states = |state: &str| {
|
||||
cells.map_or(0, |cells| {
|
||||
cells.values().filter(|cell| cell["state"] == state).count()
|
||||
})
|
||||
};
|
||||
if states("not_applicable") == 6 {
|
||||
return format!("{client} 不适用");
|
||||
}
|
||||
if states("not_installed") == 6 {
|
||||
return format!("{client} 未安装");
|
||||
}
|
||||
let issues: Vec<String> = cells
|
||||
.map(|cells| {
|
||||
cells
|
||||
.iter()
|
||||
.filter(|(_, cell)| cell["state"] != "ready")
|
||||
.map(|(check, cell)| {
|
||||
format!("{check}={}", cell["cause"].as_str().unwrap_or("?"))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let version = column["version"].as_str().unwrap_or("版本未知");
|
||||
if issues.is_empty() {
|
||||
format!("{client} {version} 6/6 ready")
|
||||
} else {
|
||||
format!("{client} {version} {}", issues.join(" "))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"L1 三端检查:project_id={},{};{}",
|
||||
value["project_id"].as_str().unwrap_or("?"),
|
||||
if value["all_applicable_ready"] == true {
|
||||
"适用格全部 ready"
|
||||
} else {
|
||||
"有未就绪格"
|
||||
},
|
||||
columns.join(";")
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_success_response(tool_name: &str, text: String) -> Value {
|
||||
const STRUCTURED_TOOLS: &[&str] = &[
|
||||
"get_delivery_status",
|
||||
"get_toolchain_status",
|
||||
"get_project_resource_status",
|
||||
"verify_onboarding",
|
||||
"probe_client_parity",
|
||||
"register_project",
|
||||
];
|
||||
if STRUCTURED_TOOLS.contains(&tool_name) {
|
||||
@ -1455,6 +1534,12 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
||||
)
|
||||
})
|
||||
}
|
||||
"probe_client_parity" => {
|
||||
let pid = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let tier = args.get("tier").and_then(Value::as_str);
|
||||
crate::mcp_inject::probe_client_parity(pid, tier)
|
||||
.and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string()))
|
||||
}
|
||||
"verify_onboarding" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let root = resolve_project_root(pid)?;
|
||||
@ -2560,6 +2645,82 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parity_probe_is_global_read_only_and_rejects_unknown_tier_before_io() {
|
||||
let global = tools_list_result();
|
||||
let probe = global["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "probe_client_parity")
|
||||
.expect("全局路由应有 probe_client_parity");
|
||||
assert_eq!(probe["annotations"]["readOnlyHint"], true);
|
||||
assert_eq!(probe["annotations"]["destructiveHint"], false);
|
||||
assert_eq!(probe["inputSchema"]["required"], json!(["project_id"]));
|
||||
assert_eq!(
|
||||
probe["inputSchema"]["properties"]["tier"]["enum"],
|
||||
json!(["l1"])
|
||||
);
|
||||
// 全局目录不得出现敏感读取工具名(scoped_dev_variable_http_pilot 有同一断言)
|
||||
for sensitive in ["read_project_test_account", "read_project_dev_variable"] {
|
||||
assert!(!probe.to_string().contains(sensitive), "{sensitive}");
|
||||
}
|
||||
let scoped = tools_list_result_for_project("p1");
|
||||
assert!(!scoped["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "probe_client_parity"));
|
||||
let from_project = tools_call_for_project(
|
||||
Some(&json!({"name": "probe_client_parity", "arguments": {}})),
|
||||
"p1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(from_project["structuredContent"]["code"], "scope_violation");
|
||||
// tier 校验在本体入口最前面:不查登记、不起任何进程
|
||||
let l3 = tools_call(Some(&json!({
|
||||
"name": "probe_client_parity",
|
||||
"arguments": { "project_id": "p1", "tier": "l3" }
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(l3["isError"], true);
|
||||
assert!(l3["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("tier=l3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_probe_summary_lists_versions_and_causes() {
|
||||
let cell = |state: &str, cause: Option<&str>| match cause {
|
||||
Some(cause) => {
|
||||
json!({ "state": state, "cause": cause, "evidence": "protocol", "detail": "" })
|
||||
}
|
||||
None => json!({ "state": state, "evidence": "protocol", "detail": "" }),
|
||||
};
|
||||
let ready = json!({
|
||||
"A": cell("ready", None), "B": cell("ready", None), "C": cell("ready", None),
|
||||
"D": cell("ready", None), "E": cell("ready", None), "F": cell("ready", None)
|
||||
});
|
||||
let mut auth = ready.clone();
|
||||
auth["F"] = cell("fail", Some("auth_failed"));
|
||||
let na = cell("not_applicable", Some("control_plane"));
|
||||
let summary = parity_probe_summary(&json!({
|
||||
"project_id": "p1",
|
||||
"all_applicable_ready": false,
|
||||
"clients": [
|
||||
{ "client": "claude", "version": null, "cells": {
|
||||
"A": na, "B": na, "C": na, "D": na, "E": na, "F": na } },
|
||||
{ "client": "codex", "version": "0.155.0", "cells": auth },
|
||||
{ "client": "dsh", "version": "0.2.0", "cells": ready }
|
||||
]
|
||||
}));
|
||||
assert!(summary.contains("有未就绪格"), "{summary}");
|
||||
assert!(summary.contains("claude 不适用"), "{summary}");
|
||||
assert!(summary.contains("codex 0.155.0 F=auth_failed"), "{summary}");
|
||||
assert!(summary.contains("dsh 0.2.0 6/6 ready"), "{summary}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_calls_reject_cross_project_and_global_write_before_io() {
|
||||
let readiness_mismatch = tools_call_for_project(
|
||||
|
||||
@ -129,6 +129,13 @@ pub trait ClientAdapter: Sync {
|
||||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome;
|
||||
/// 收敛到期望状态;调用方须持有接入锁。失败返回 failed / unavailable,不 panic、不中断其他端。
|
||||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome;
|
||||
/// L1 零模型检查(P4a)要的本端配置事实:只读文件、不起子进程、不读凭据。客户端自省与认证链重放
|
||||
/// 由 onboard::probe_l1 按这里声明的来源与 helper 执行方式进行;不适用(控制面 / WSL)时不读任何配置。
|
||||
fn l1_facts(
|
||||
&self,
|
||||
project: &ProjectFacts,
|
||||
desired: &DesiredMcp,
|
||||
) -> crate::onboard::probe_l1::L1ClientFacts;
|
||||
}
|
||||
|
||||
/// 三端适配器注册表;顺序即报告顺序。
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
pub mod clients;
|
||||
pub mod plan;
|
||||
pub mod probe_l1;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fitness_tests {
|
||||
|
||||
2201
src-tauri/src/onboard/probe_l1.rs
Normal file
2201
src-tauri/src/onboard/probe_l1.rs
Normal file
File diff suppressed because it is too large
Load Diff
1709
src-tauri/src/onboard/probe_l1/tests.rs
Normal file
1709
src-tauri/src/onboard/probe_l1/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1644,6 +1644,88 @@ export interface OnboardingReport {
|
||||
export const applyOnboardingPack = (projectPath: string, projectId: string) =>
|
||||
invoke<OnboardingReport>("apply_onboarding_pack", { projectPath, projectId });
|
||||
|
||||
// ── 三端对等 L1 零模型检查(agent-client-parity P4a)────────────────────────────
|
||||
// 类型与 src-tauri/src/onboard/probe_l1.rs 的 L1Report 对应;MCP probe_client_parity 返回同一结构。
|
||||
|
||||
/** A 项目规则 / B lian-jing skill / C MCP 工具集 / D get_diagnostics / E 列出开发变量 / F 敏感读取认证链 */
|
||||
export type ParityCheck = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
|
||||
/** 证据等级,从弱到强;L1 至多到 protocol */
|
||||
export type ParityEvidence =
|
||||
| "static"
|
||||
| "client_introspection"
|
||||
| "protocol"
|
||||
| "session_event"
|
||||
| "server_trace";
|
||||
|
||||
/** not_installed 与 not_applicable 不计失败 */
|
||||
export type ParityCellState =
|
||||
| "ready"
|
||||
| "fail"
|
||||
| "unknown"
|
||||
| "not_installed"
|
||||
| "not_applicable"
|
||||
| "blocked"
|
||||
| "timeout";
|
||||
|
||||
export type ParityCause =
|
||||
| "control_plane"
|
||||
| "wsl"
|
||||
| "timeout"
|
||||
| "unreachable"
|
||||
| "client_error"
|
||||
| "not_configured"
|
||||
| "route_global"
|
||||
| "route_mismatch"
|
||||
| "not_connected"
|
||||
| "codex_project_untrusted"
|
||||
| "server_disabled"
|
||||
| "manual_config"
|
||||
| "rules_not_loaded"
|
||||
| "skill_not_visible"
|
||||
| "session_plugin_mismatch"
|
||||
| "shim_missing"
|
||||
| "helper_failed"
|
||||
| "auth_header_missing"
|
||||
| "auth_failed"
|
||||
| "tools_mismatch"
|
||||
| "tool_error"
|
||||
| "adapter_drift";
|
||||
|
||||
export interface ParityCell {
|
||||
state: ParityCellState;
|
||||
cause?: ParityCause;
|
||||
evidence: ParityEvidence;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ParityClientColumn {
|
||||
client: "claude" | "codex" | "dsh";
|
||||
version: string | null;
|
||||
/** 该端对本项目实际会连的地址;未得出为 null */
|
||||
route: string | null;
|
||||
cells: Record<ParityCheck, ParityCell>;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface ClientParityL1Report {
|
||||
project_id: string;
|
||||
tier: "l1";
|
||||
probe_spec_version: number;
|
||||
profile: "project" | "control_plane";
|
||||
expected_route: string;
|
||||
expected_tools: number;
|
||||
nonce: string;
|
||||
clients: ParityClientColumn[];
|
||||
all_applicable_ready: boolean;
|
||||
duration_ms: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** 约 5–10 秒:起零模型客户端自省并重放认证链(认证头不出炼境进程) */
|
||||
export const probeClientParity = (projectId: string, tier: "l1" = "l1") =>
|
||||
invoke<ClientParityL1Report>("probe_client_parity", { projectId, tier });
|
||||
|
||||
// ── Agent Infrastructure ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DetectedContract {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user