Compare commits

..

5 Commits

Author SHA1 Message Date
cf2b5f0dc2 Merge pull request 'feat(onboard/parity): L1 零模型三端检查 probe_client_parity,处置独立审查(P4a)' (#38) from feat/agent-client-parity-p4a into master
All checks were successful
Push & PR Check / check (push) Successful in 1m5s
2026-10-03 12:53:31 +08:00
lanrtop
a79bcb7fca chore(merge): 合并 master(含 P8f register_project)到 P4a,解决 mcp_tools.rs 与 project-context.yaml 相邻插入冲突
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
Push & PR Check / check (pull_request) Successful in 1m2s
两边各自新增全局路由工具(probe_client_parity / register_project):清单插入、
is_project_scoped_tool 排除、STRUCTURED_TOOLS 与摘要分支均保留双方;契约文档合并两段说明。
合并结果 cargo test 377 passed / 0 failed / 6 ignored,pnpm typecheck 通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:48:28 +09:00
lanrtop
f0237da3c3 fix(onboard/parity): P4a 处置独立审查——超时不可被持续输出绕过、读超时归因、探针不抹 reload 证据等(P4a 审查)
独立审查 11 条发现处置(修复 agent 在分类器拦截前完成并暂存,统筹会话接手提交):
- SystemRunner 每轮按剩余时间等待,收到行也查超时;单行与 stderr 限长
- 读超时沿 error source 链按 io::ErrorKind 判定,不依赖英文子串
- probe 期间 quiet_project_initializes 守卫,探针的 initialize 不计入真实客户端
- F 格绑定真实 read_test_account 文案;Codex enabled/disabled_tools 过滤
- Claude 作用域回查 helper、Codex 项目表与生效地址不一致记 untrusted、
  helper 无 Authorization 记 auth_header_missing、Claude 未连上时 D–F 记 blocked
- init 加 --settings disableAllHooks;--mcp-config 只带 type/url/headersHelper;
  只执行托管写法 helper,其余记 unknown(manual_config)
- 改断言报备:Claude 未连上时 F 由 ready 改为 blocked(审查要求的口径),已写入卡片

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:40:00 +09:00
lanrtop
5b804bb967 feat(onboard/parity): L1 零模型三端检查 probe_client_parity(P4a)
All checks were successful
Push & PR Check / check (push) Successful in 1m3s
新增 onboard::probe_l1:不调用模型、零额度,按 Claude Code、Codex、DSH
各自实际生效的配置给出 A–F × 三端矩阵,每格带状态、证据等级与归因。

- 客户端自省:Claude 在项目目录 claude mcp get 取生效条目,再以坏模型名
  跑 claude -p(只挂该条目、读到 system/init 即止)取 skills/工具/连接状态;
  Codex 用 codex mcp get --json(看不到条目而项目有配置 → codex_project_untrusted);
  DSH 读托管块白名单并核对会话插件 sha256,版本读安装包 app.asar。
- 认证链重放:按客户端方式执行稳定 shim 取认证头(只在进程内使用),
  在该端配置的地址上 initialize(clientInfo=lian-jing-l1-probe)、tools/list
  (与 tools_list_result_for_project 比对)、get_diagnostics、
  list_project_dev_variables、read_project_test_account(LJ_PROBE_<nonce>)。
  归因 shim_missing / helper_failed / auth_failed / unreachable / route_global 等;
  未安装记 not_installed,控制面 Claude 与 DSH 记 not_applicable,均不计失败。
- 进程、HTTP、客户端定位经 ProcessRunner / McpHttp / ClientLocator 注入;
  ClientAdapter 新增 l1_facts;本体入口 mcp_inject::probe_client_parity。
- 投影:MCP probe_client_parity(仅全局路由,readOnly)、Tauri async command、
  commands.ts 类型与封装;mcp_server 不把 l1 探针计入真实客户端初始化。
- T1:project-context.yaml 补 MCP 工具与 Tauri command 契约;蓝图 P4a 记实现与验证。

cargo test 349 passed / 0 failed / 6 ignored;pnpm typecheck 通过;
实机冒烟 enterprise 三端 18 格 ready(1.4–1.6 秒)。

Rules-Applied: R01
Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:29:56 +09:00
lanrtop
a3bb274ed0 docs(blueprint/agent-client-parity): P4a 开工标 🔵(P4a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:29:56 +09:00
13 changed files with 4795 additions and 26 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/ 一键接入本体(客户端适配器注册表与结构化结果;不依赖 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

View File

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

View File

@ -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,

View File

@ -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());

View File

@ -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();

View File

@ -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(

View File

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

View File

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