Compare commits

..

No commits in common. "cf2b5f0dc22e48a87d39f53fcc9d93aeddcd06d0" and "f438d7103908bb60e12e04472f0cef54e4aa9b77" have entirely different histories.

13 changed files with 26 additions and 4795 deletions

File diff suppressed because one or more lines are too long

View File

@ -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/ 一键接入本体(客户端适配器注册表、写入前计划 plan、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表与结构化结果;不依赖 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

View File

@ -515,20 +515,6 @@ 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 检查链,判定逻辑纯函数化可单测)──────
/// 单项验收结果。

View File

@ -442,7 +442,6 @@ 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,

View File

@ -2,9 +2,6 @@ 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;
@ -105,11 +102,6 @@ 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}")
}
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
@ -645,13 +637,22 @@ enum ClaudeEffective {
}
/// 纯函数:解析 `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),
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)
}
}
@ -1437,7 +1438,8 @@ fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, S
}
fn has_manual_codex_entry(text: &str) -> bool {
probe_l1::codex_has_table(text)
text.lines()
.any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]"))
}
/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定
@ -2061,104 +2063,6 @@ 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 会清理),
@ -2232,26 +2136,6 @@ 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。
@ -2301,239 +2185,6 @@ 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)。
@ -4106,155 +3757,6 @@ 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());

View File

@ -50,11 +50,7 @@ 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();
// 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据
if client_name == "lian-jing-readiness"
|| client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME
|| project_initializes_quiet(project_id)
{
if client_name == "lian-jing-readiness" {
return;
}
if let Ok(mut clients) = initialized_project_clients().write() {
@ -62,50 +58,6 @@ 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()
@ -1572,14 +1524,6 @@ 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" }
@ -1588,37 +1532,6 @@ 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();

View File

@ -400,11 +400,6 @@ 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")
@ -415,23 +410,6 @@ 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!({
@ -499,10 +477,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" | "probe_client_parity"
"register_project" | "unregister_project" | "setup_gitea_repo"
) {
return false;
}
@ -636,7 +614,6 @@ 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,
@ -795,7 +772,6 @@ 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",
@ -812,67 +788,12 @@ 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) {
@ -1534,12 +1455,6 @@ 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)?;
@ -2645,82 +2560,6 @@ 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(

View File

@ -129,13 +129,6 @@ 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;
}
/// 三端适配器注册表;顺序即报告顺序。

View File

@ -7,7 +7,6 @@
pub mod clients;
pub mod plan;
pub mod probe_l1;
#[cfg(test)]
mod fitness_tests {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1644,88 +1644,6 @@ 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 {