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/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
|
||||||
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
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/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)
|
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||||
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ contracts:
|
|||||||
- "src-tauri/src/commands/dev_variables.rs"
|
- "src-tauri/src/commands/dev_variables.rs"
|
||||||
- "src-tauri/src/envoy.rs"
|
- "src-tauri/src/envoy.rs"
|
||||||
- "src-tauri/src/onboard/probe_l1.rs"
|
- "src-tauri/src/onboard/probe_l1.rs"
|
||||||
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_<nonce>);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client。"
|
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_<nonce>);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client;检查期间本项目路由上的全部 initialize(含探针的 claude mcp get 健康检查与零模型 init,均以 claude-code 身份连接)也不计入,宁可多提示一次重启。协议重放只证明服务端视角:客户端自己未连上时 D–F 记 blocked,客户端看不到某工具(Claude init 工具名、Codex enabled_tools/disabled_tools)时对应格记 tools_mismatch;配置里的 helper 只有逐字等于炼境托管写法(当前或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest)才执行,否则记 unknown(manual_config);零模型 init 带 --settings {\"disableAllHooks\":true},只挂 type/url/headersHelper。"
|
||||||
|
|
||||||
- label: 被管理项目身份上下文
|
- label: 被管理项目身份上下文
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -105,6 +105,11 @@ fn shim_helper_command(digest: &str) -> String {
|
|||||||
format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}")
|
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}")
|
||||||
|
}
|
||||||
|
|
||||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
|
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
|
||||||
@ -2057,13 +2062,24 @@ impl ClientAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。
|
/// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。
|
||||||
fn l1_facts(&self, project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts {
|
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
|
||||||
let not_applicable = l1_not_applicable(project);
|
let not_applicable = l1_not_applicable(project);
|
||||||
let entries = match not_applicable {
|
let entries = match not_applicable {
|
||||||
Some(_) => Vec::new(),
|
Some(_) => Vec::new(),
|
||||||
None => read_claude_config()
|
None => {
|
||||||
.map(|config| claude_entries_covering(&config, project.root))
|
let mcp_json = read_optional(&project.root.join(".mcp.json"))
|
||||||
.unwrap_or_default(),
|
.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 {
|
L1ClientFacts {
|
||||||
client: ClientId::Claude,
|
client: ClientId::Claude,
|
||||||
@ -2086,25 +2102,62 @@ fn l1_not_applicable(project: &ProjectFacts) -> Option<NotApplicableReason> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ~/.claude.json 中覆盖本根目录的项目键下的 lian-jing 条目(含手写),最具体(最长)的键在前。
|
/// L1 回查 headersHelper 的候选条目:URL 等于本项目地址的 lian-jing 条目(含手写),按 URL 匹配、不复刻
|
||||||
fn claude_entries_covering(config: &Value, root: &std::path::Path) -> Vec<Value> {
|
/// 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 root_text = root.display().to_string();
|
||||||
let mut entries: Vec<(usize, Value)> = config
|
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")
|
.get("projects")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.map(|projects| {
|
.map(|projects| {
|
||||||
projects
|
projects
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, _)| key_covers_root(key, &root_text))
|
|
||||||
.filter_map(|(key, project)| {
|
.filter_map(|(key, project)| {
|
||||||
let entry = project.get("mcpServers")?.get(MCP_KEY)?;
|
let entry = lian_jing(project.get("mcpServers"))?;
|
||||||
Some((normalize_root_key(key).len(), entry.clone()))
|
let rank = (
|
||||||
|
key_covers_root(key, &root_text),
|
||||||
|
normalize_root_key(key).len(),
|
||||||
|
);
|
||||||
|
Some((rank, entry))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
entries.sort_by_key(|(length, _)| std::cmp::Reverse(*length));
|
local.sort_by(|(left, _), (right, _)| right.cmp(left));
|
||||||
entries.into_iter().map(|(_, entry)| entry).collect()
|
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 会清理),
|
/// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理),
|
||||||
@ -2193,6 +2246,7 @@ impl ClientAdapter for CodexAdapter {
|
|||||||
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
|
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
|
||||||
source: L1Source::Codex {
|
source: L1Source::Codex {
|
||||||
project_entry: has_manual_codex_entry(&config),
|
project_entry: has_manual_codex_entry(&config),
|
||||||
|
project_url: probe_l1::codex_table_url(&config),
|
||||||
project_helper: probe_l1::codex_table_helper(&config),
|
project_helper: probe_l1::codex_table_helper(&config),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -2388,6 +2442,10 @@ fn probe_client_parity_at(
|
|||||||
control_plane: facts.control_plane,
|
control_plane: facts.control_plane,
|
||||||
expected_url: server_url,
|
expected_url: server_url,
|
||||||
expected_tools,
|
expected_tools,
|
||||||
|
managed_helpers: vec![
|
||||||
|
shim_helper_command(&digest),
|
||||||
|
legacy_shim_helper_command(&digest),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
let env = |name: &str| std::env::var(name).ok();
|
let env = |name: &str| std::env::var(name).ok();
|
||||||
let deps = probe_l1::L1Deps {
|
let deps = probe_l1::L1Deps {
|
||||||
@ -2397,6 +2455,9 @@ fn probe_client_parity_at(
|
|||||||
env: &env,
|
env: &env,
|
||||||
budget: probe_l1::L1_BUDGET,
|
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))
|
Ok(probe_l1::run_l1(&project, &clients, &deps))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4103,9 +4164,11 @@ trust_level = "trusted"
|
|||||||
match facts.source {
|
match facts.source {
|
||||||
L1Source::Codex {
|
L1Source::Codex {
|
||||||
project_entry,
|
project_entry,
|
||||||
|
project_url,
|
||||||
project_helper,
|
project_helper,
|
||||||
} => {
|
} => {
|
||||||
assert!(project_entry);
|
assert!(project_entry);
|
||||||
|
assert_eq!(project_url.as_deref(), Some(desired.server_url.as_str()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
project_helper.as_deref(),
|
project_helper.as_deref(),
|
||||||
Some(desired.header_helper.as_str())
|
Some(desired.header_helper.as_str())
|
||||||
@ -4116,17 +4179,65 @@ trust_level = "trusted"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_entries_covering_root_prefer_most_specific_key() {
|
fn claude_l1_entries_match_project_url_in_every_scope() {
|
||||||
let managed = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p", "h");
|
use probe_l1::ClaudeEntrySource::{Local, Project, User};
|
||||||
let manual = json!({ "type": "http", "url": "http://127.0.0.1:9/mcp" });
|
let url = "http://127.0.0.1:27190/mcp/projects/p";
|
||||||
let config = json!({ "projects": {
|
let entry = |helper: &str| claude_local_server_entry(url, helper);
|
||||||
"E:/code": { "mcpServers": { "lian-jing": manual } },
|
let config = json!({
|
||||||
"E:/code/proj": { "mcpServers": { "lian-jing": managed } },
|
"mcpServers": { "lian-jing": entry("user") },
|
||||||
"E:/other": { "mcpServers": { "lian-jing": { "url": "x" } } },
|
"projects": {
|
||||||
"E:/code/proj/sub": { "mcpServers": { "lian-jing": { "url": "y" } } }
|
"E:/code": { "mcpServers": { "lian-jing": entry("ancestor") } },
|
||||||
} });
|
"E:/code/proj": { "mcpServers": { "lian-jing": entry("exact") } },
|
||||||
let entries = claude_entries_covering(&config, std::path::Path::new("E:\\code\\proj"));
|
// worktree 被 Claude 映射到主仓库根:键不覆盖本根目录,但 URL 是本项目地址
|
||||||
assert_eq!(entries, vec![managed, manual]);
|
"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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端):
|
/// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端):
|
||||||
|
|||||||
@ -53,6 +53,7 @@ fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str
|
|||||||
// 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据
|
// 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据
|
||||||
if client_name == "lian-jing-readiness"
|
if client_name == "lian-jing-readiness"
|
||||||
|| client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME
|
|| client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME
|
||||||
|
|| project_initializes_quiet(project_id)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -61,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 {
|
pub(crate) fn has_initialized_project_client(project_id: &str) -> bool {
|
||||||
initialized_project_clients()
|
initialized_project_clients()
|
||||||
.read()
|
.read()
|
||||||
@ -1543,6 +1588,37 @@ mod tests {
|
|||||||
assert!(has_initialized_project_client(project_id));
|
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]
|
#[tokio::test]
|
||||||
async fn scoped_route_rejects_unregistered_project() {
|
async fn scoped_route_rejects_unregistered_project() {
|
||||||
db::init_test_pool();
|
db::init_test_pool();
|
||||||
|
|||||||
@ -415,7 +415,7 @@ pub fn tools_list_result() -> Value {
|
|||||||
fn parity_probe_tool_schema() -> Value {
|
fn parity_probe_tool_schema() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"name": "probe_client_parity",
|
"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/timeout)、证据等级(static < client_introspection < protocol)与归因(如 shim_missing、auth_failed、unreachable、route_global、codex_project_untrusted)。认证头只在炼境进程内使用,不进入结果。控制面项目上 Claude 与 DSH 记 not_applicable;未安装的客户端记 not_installed、不计失败。L1 是配置与协议重放,绿灯不等于真实会话可用。",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -11,6 +11,10 @@
|
|||||||
//! read_project_test_account(LJ_PROBE_<nonce>)(F:返回「开发需求未登记」即认证通过)。
|
//! read_project_test_account(LJ_PROBE_<nonce>)(F:返回「开发需求未登记」即认证通过)。
|
||||||
//! - 静态(static):规则文件、skill、DSH 托管块白名单与会话级插件哈希。
|
//! - 静态(static):规则文件、skill、DSH 托管块白名单与会话级插件哈希。
|
||||||
//!
|
//!
|
||||||
|
//! 协议重放只证明服务端视角:客户端自己没连上(Claude init / mcp get)时 D–F 记 blocked,客户端看不到某工具
|
||||||
|
//! (Claude init 的工具名、Codex 的 enabled_tools/disabled_tools)时对应格记 tools_mismatch。配置里的 helper 只有
|
||||||
|
//! 逐字等于炼境托管写法才执行(配置可能来自仓库受跟踪文件),零模型 init 关闭全部 hooks。
|
||||||
|
//!
|
||||||
//! 进程执行、HTTP 调用、客户端定位经 trait 注入(ProcessRunner / McpHttp / ClientLocator),测试用假实现;
|
//! 进程执行、HTTP 调用、客户端定位经 trait 注入(ProcessRunner / McpHttp / ClientLocator),测试用假实现;
|
||||||
//! 各端配置事实由客户端适配器只读取得(ClientAdapter::l1_facts)。本模块不读凭据存储、不取连接池。
|
//! 各端配置事实由客户端适配器只读取得(ClientAdapter::l1_facts)。本模块不读凭据存储、不取连接池。
|
||||||
//! L1 是配置与协议重放,绿灯不等于真实会话可用:报告里每格的证据等级就是为此标注的(设计 §6、风险 R6)。
|
//! L1 是配置与协议重放,绿灯不等于真实会话可用:报告里每格的证据等级就是为此标注的(设计 §6、风险 R6)。
|
||||||
@ -33,8 +37,17 @@ const SERVER_NAME: &str = "lian-jing";
|
|||||||
const ZERO_MODEL: &str = "lj-no-such-model";
|
const ZERO_MODEL: &str = "lj-no-such-model";
|
||||||
/// 读到这一行即结束 claude 进程:init 之后只剩一次必然失败的模型请求(实测 5–8 秒),不必等。
|
/// 读到这一行即结束 claude 进程:init 之后只剩一次必然失败的模型请求(实测 5–8 秒),不必等。
|
||||||
const CLAUDE_INIT_MARKER: &str = "\"subtype\":\"init\"";
|
const CLAUDE_INIT_MARKER: &str = "\"subtype\":\"init\"";
|
||||||
|
/// 零模型 init 关掉全部 hooks:-p 模式不弹信任框,会执行用户级、项目级与插件的 SessionStart /
|
||||||
|
/// UserPromptSubmit 等 hooks(2026-10-03 实测 2.1.90 与 2.1.173:不加时项目 hooks 照常执行,加上后不执行、
|
||||||
|
/// init 照常发出)。
|
||||||
|
const CLAUDE_INIT_SETTINGS: &str = r#"{"disableAllHooks":true}"#;
|
||||||
/// read_project_test_account 对未登记需求名的固定回答:出现即说明认证已通过(零凭据验证法)。
|
/// read_project_test_account 对未登记需求名的固定回答:出现即说明认证已通过(零凭据验证法)。
|
||||||
|
/// 与 commands::dev_variables 的报错文案由 probe_l1 测试在内存库上调用真实本体绑定(文案一改测试即红)。
|
||||||
const UNREGISTERED_NEED: &str = "开发需求未登记";
|
const UNREGISTERED_NEED: &str = "开发需求未登记";
|
||||||
|
/// D、E、F 各自调用的工具:客户端自己的可见工具视角据此判断会话里能否调用。
|
||||||
|
const D_TOOL: &str = "get_diagnostics";
|
||||||
|
const E_TOOL: &str = "list_project_dev_variables";
|
||||||
|
const F_TOOL: &str = "read_project_test_account";
|
||||||
const CLIENT_CLI_TIMEOUT: Duration = Duration::from_secs(6);
|
const CLIENT_CLI_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
const HELPER_TIMEOUT: Duration = Duration::from_secs(5);
|
const HELPER_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(4);
|
const HTTP_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
@ -210,6 +223,10 @@ pub struct L1Project<'a> {
|
|||||||
pub control_plane: bool,
|
pub control_plane: bool,
|
||||||
pub expected_url: String,
|
pub expected_url: String,
|
||||||
pub expected_tools: Vec<String>,
|
pub expected_tools: Vec<String>,
|
||||||
|
/// 炼境托管写法的 helper 命令行(当前稳定入口与旧 %LOCALAPPDATA% 入口,均带本项目 digest)。
|
||||||
|
/// 配置里的 helper 只有逐字等于其中之一才执行:配置可能来自仓库受跟踪文件(.codex/config.toml、.mcp.json),
|
||||||
|
/// 探针又可免审批调用,不能替客户端执行它自己未必会执行的任意命令。
|
||||||
|
pub managed_helpers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 只读判定的一条静态事实。
|
/// 只读判定的一条静态事实。
|
||||||
@ -229,15 +246,35 @@ pub enum HelperCommand {
|
|||||||
DshShim { shim: PathBuf, digest: String },
|
DshShim { shim: PathBuf, digest: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude 配置条目所在的作用域(与 `claude mcp get` 的 Scope 行对应)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ClaudeEntrySource {
|
||||||
|
/// ~/.claude.json 的 projects.<键>.mcpServers
|
||||||
|
Local,
|
||||||
|
/// 项目 .mcp.json
|
||||||
|
Project,
|
||||||
|
/// ~/.claude.json 顶层 mcpServers
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可回查 headersHelper 的一条 Claude 配置条目。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ClaudeEntry {
|
||||||
|
pub source: ClaudeEntrySource,
|
||||||
|
pub entry: Value,
|
||||||
|
}
|
||||||
|
|
||||||
/// 各端取配置的来源(客户端适配器只读填写)。
|
/// 各端取配置的来源(客户端适配器只读填写)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum L1Source {
|
pub enum L1Source {
|
||||||
/// Claude:~/.claude.json 中覆盖本根目录的 lian-jing 条目(最具体的键在前)。生效哪一条以
|
/// Claude:各作用域里 URL 为本项目地址的 lian-jing 条目(按 URL 匹配,不复刻 Claude 的项目键推导)。
|
||||||
/// `claude mcp get` 的回答为准(不复刻 Claude 的项目键推导);该命令不输出 headersHelper,按 URL 回查。
|
/// 生效哪一条以 `claude mcp get` 的回答为准;该命令不输出 headersHelper,按 URL 与作用域回查。
|
||||||
Claude { entries: Vec<Value> },
|
Claude { entries: Vec<ClaudeEntry> },
|
||||||
/// Codex:生效配置以 `codex mcp get --json` 为准;它会把 helper 脱敏,helper 取项目 .codex/config.toml 同名表。
|
/// Codex:生效配置以 `codex mcp get --json` 为准;它会把 helper 脱敏,helper 取项目 .codex/config.toml 同名表。
|
||||||
Codex {
|
Codex {
|
||||||
project_entry: bool,
|
project_entry: bool,
|
||||||
|
/// 项目 .codex/config.toml 同名表的 url:与 Codex 生效地址不同说明项目配置未加载(多半未受信任)
|
||||||
|
project_url: Option<String>,
|
||||||
project_helper: Option<String>,
|
project_helper: Option<String>,
|
||||||
},
|
},
|
||||||
/// DSH:没有零模型自省入口,只有静态判定——托管块白名单(P8b dsh_static_outcome)与会话级插件哈希。
|
/// DSH:没有零模型自省入口,只有静态判定——托管块白名单(P8b dsh_static_outcome)与会话级插件哈希。
|
||||||
@ -361,10 +398,7 @@ impl Ctx<'_> {
|
|||||||
pub fn run_l1(project: &L1Project, clients: &[L1ClientFacts], deps: &L1Deps) -> L1Report {
|
pub fn run_l1(project: &L1Project, clients: &[L1ClientFacts], deps: &L1Deps) -> L1Report {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let deadline = started + deps.budget;
|
let deadline = started + deps.budget;
|
||||||
let nonce = format!(
|
let nonce = new_nonce();
|
||||||
"LJ_PROBE_{}",
|
|
||||||
uuid::Uuid::new_v4().simple().to_string()[..8].to_ascii_uppercase()
|
|
||||||
);
|
|
||||||
let ctx = Ctx {
|
let ctx = Ctx {
|
||||||
project,
|
project,
|
||||||
deps,
|
deps,
|
||||||
@ -417,6 +451,14 @@ pub fn run_l1(project: &L1Project, clients: &[L1ClientFacts], deps: &L1Deps) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F 项的随机需求名:须通过开发变量名校验(字母开头,大写字母、数字、下划线)才能走到「未登记」分支。
|
||||||
|
fn new_nonce() -> String {
|
||||||
|
format!(
|
||||||
|
"LJ_PROBE_{}",
|
||||||
|
uuid::Uuid::new_v4().simple().to_string()[..8].to_ascii_uppercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn filled_column(client: ClientId, cell: Cell) -> ClientColumn {
|
fn filled_column(client: ClientId, cell: Cell) -> ClientColumn {
|
||||||
ClientColumn {
|
ClientColumn {
|
||||||
client,
|
client,
|
||||||
@ -484,12 +526,16 @@ fn probe_client(ctx: &Ctx, facts: &L1ClientFacts) -> ClientColumn {
|
|||||||
L1Source::Claude { entries } => probe_claude(ctx, &located, entries, &mut column),
|
L1Source::Claude { entries } => probe_claude(ctx, &located, entries, &mut column),
|
||||||
L1Source::Codex {
|
L1Source::Codex {
|
||||||
project_entry,
|
project_entry,
|
||||||
|
project_url,
|
||||||
project_helper,
|
project_helper,
|
||||||
} => probe_codex(
|
} => probe_codex(
|
||||||
ctx,
|
ctx,
|
||||||
&located,
|
&located,
|
||||||
*project_entry,
|
CodexProjectTable {
|
||||||
project_helper.as_deref(),
|
present: *project_entry,
|
||||||
|
url: project_url.as_deref(),
|
||||||
|
helper: project_helper.as_deref(),
|
||||||
|
},
|
||||||
&mut column,
|
&mut column,
|
||||||
),
|
),
|
||||||
L1Source::Dsh {
|
L1Source::Dsh {
|
||||||
@ -552,7 +598,69 @@ fn route_cell(url: &str, expected: &str, source: &str) -> Option<Cell> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], column: &mut ClientColumn) {
|
/// 配置里的 helper 不是炼境托管写法时不执行(C–F 记 unknown(manual_config));DSH 的 helper 由炼境自己构造,不在此列。
|
||||||
|
fn unmanaged_helper_cell(ctx: &Ctx, helper: Option<&HelperCommand>, source: &str) -> Option<Cell> {
|
||||||
|
let HelperCommand::Shell(line) = helper? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if ctx
|
||||||
|
.project
|
||||||
|
.managed_helpers
|
||||||
|
.iter()
|
||||||
|
.any(|managed| managed == line.trim())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Cell::unknown(
|
||||||
|
Cause::ManualConfig,
|
||||||
|
EvidenceLevel::Static,
|
||||||
|
format!(
|
||||||
|
"{source}生效条目的 helper 不是炼境托管写法(手写,或更早的本机 exe 路径写法),L1 不执行配置里的命令;\
|
||||||
|
重跑 inject_mcp 改为托管写法,手写条目由人核对"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端自己的视角(Claude init / Codex 过滤器):协议重放只证明服务端可用,会话里能否调用以客户端为准。
|
||||||
|
enum ClientView {
|
||||||
|
/// 已连上,附该端可见的工具名
|
||||||
|
Connected(Vec<String>),
|
||||||
|
/// 该端明确没连上(附原因)
|
||||||
|
NotConnected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端会话里看不到 D/E/F 对应工具时,把协议重放得到的 ready 改记 tools_mismatch(客户端自省证据)。
|
||||||
|
fn hide_invisible_tools(replayed: &mut Replay, visible: &dyn Fn(&str) -> bool, source: &str) {
|
||||||
|
for (cell, tool) in [
|
||||||
|
(&mut replayed.d, D_TOOL),
|
||||||
|
(&mut replayed.e, E_TOOL),
|
||||||
|
(&mut replayed.f, F_TOOL),
|
||||||
|
] {
|
||||||
|
if cell.state == CellState::Ready && !visible(tool) {
|
||||||
|
*cell = Cell::fail(
|
||||||
|
Cause::ToolsMismatch,
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
format!("{source}看不到 {tool},会话里不可调用(协议重放只证明服务端可用)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端明确未连上时,协议重放通过的 D–F 改记 blocked(沿用 C 的归因):服务端可用,但该端会话里调不到。
|
||||||
|
fn block_calls(replayed: &mut Replay, cause: Cause, reason: &str) {
|
||||||
|
for cell in [&mut replayed.d, &mut replayed.e, &mut replayed.f] {
|
||||||
|
if cell.state == CellState::Ready {
|
||||||
|
*cell = Cell::with(
|
||||||
|
CellState::Blocked,
|
||||||
|
Some(cause),
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
format!("{reason};协议重放(服务端视角)通过,会话里不可调用"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_claude(ctx: &Ctx, located: &Located, entries: &[ClaudeEntry], column: &mut ClientColumn) {
|
||||||
let Some(program) = located.program.as_deref() else {
|
let Some(program) = located.program.as_deref() else {
|
||||||
set_connection_cells(
|
set_connection_cells(
|
||||||
column,
|
column,
|
||||||
@ -652,20 +760,46 @@ fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], column: &mut Cl
|
|||||||
set_connection_cells(column, cell);
|
set_connection_cells(column, cell);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let entry = entries
|
// headersHelper 不在 mcp get 输出里:按 URL 回查候选条目,优先与 Claude 报告的作用域一致的那条
|
||||||
|
let wanted = effective.scope.entry_source();
|
||||||
|
let matching: Vec<&ClaudeEntry> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.find(|entry| entry.get("url").and_then(Value::as_str) == Some(url.as_str()));
|
.filter(|candidate| {
|
||||||
|
candidate.entry.get("url").and_then(Value::as_str) == Some(url.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let entry = matching
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| Some(candidate.source) == wanted)
|
||||||
|
.or_else(|| matching.first())
|
||||||
|
.map(|candidate| &candidate.entry);
|
||||||
let helper = entry
|
let helper = entry
|
||||||
.and_then(|entry| entry.get("headersHelper"))
|
.and_then(|entry| entry.get("headersHelper"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|helper| HelperCommand::Shell(helper.to_string()));
|
.map(|helper| HelperCommand::Shell(helper.to_string()));
|
||||||
|
if let Some(cell) = unmanaged_helper_cell(ctx, helper.as_ref(), "Claude ") {
|
||||||
|
set_connection_cells(column, cell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ② 零模型 init:只挂这一条生效条目(--strict-mcp-config),避免等待无关 MCP 服务器;
|
// ② 零模型 init:只挂这一条生效条目(--strict-mcp-config),避免等待无关 MCP 服务器;
|
||||||
// 读到 init 即结束——此时只有 HTTP 连接,无 stdio 子进程会因提前结束而成为孤儿。
|
// 读到 init 即结束——此时只有 HTTP 连接,无 stdio 子进程会因提前结束而成为孤儿。
|
||||||
|
// 条目只保留 type、url、headersHelper:手写条目若带静态 headers(含认证),不放上进程命令行。
|
||||||
|
// 关掉全部 hooks(-p 不弹信任框,会执行项目与用户的 SessionStart 等 hooks)。
|
||||||
let introspected = match ctx.cap(CLIENT_CLI_TIMEOUT) {
|
let introspected = match ctx.cap(CLIENT_CLI_TIMEOUT) {
|
||||||
None => Err("预算耗尽,未取 init".to_string()),
|
None => Err("预算耗尽,未取 init".to_string()),
|
||||||
Some(timeout) => {
|
Some(timeout) => {
|
||||||
let config = json!({ "mcpServers": { SERVER_NAME: entry.cloned().unwrap_or_else(|| json!({ "type": "http", "url": url })) } });
|
let mut server = json!({
|
||||||
|
"type": entry
|
||||||
|
.and_then(|entry| entry.get("type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("http"),
|
||||||
|
"url": url,
|
||||||
|
});
|
||||||
|
if let Some(HelperCommand::Shell(line)) = &helper {
|
||||||
|
server["headersHelper"] = json!(line);
|
||||||
|
}
|
||||||
|
let config = json!({ "mcpServers": { SERVER_NAME: server } });
|
||||||
let mut spec = client_spec(
|
let mut spec = client_spec(
|
||||||
ctx,
|
ctx,
|
||||||
program,
|
program,
|
||||||
@ -675,6 +809,8 @@ fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], column: &mut Cl
|
|||||||
"--model".into(),
|
"--model".into(),
|
||||||
ZERO_MODEL.into(),
|
ZERO_MODEL.into(),
|
||||||
"--no-session-persistence".into(),
|
"--no-session-persistence".into(),
|
||||||
|
"--settings".into(),
|
||||||
|
CLAUDE_INIT_SETTINGS.into(),
|
||||||
"--strict-mcp-config".into(),
|
"--strict-mcp-config".into(),
|
||||||
"--mcp-config".into(),
|
"--mcp-config".into(),
|
||||||
config.to_string(),
|
config.to_string(),
|
||||||
@ -698,7 +834,7 @@ fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], column: &mut Cl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut introspection_c = None;
|
let mut view = None;
|
||||||
let mut introspection_note = String::new();
|
let mut introspection_note = String::new();
|
||||||
match &introspected {
|
match &introspected {
|
||||||
Ok(init) => {
|
Ok(init) => {
|
||||||
@ -718,63 +854,78 @@ fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], column: &mut Cl
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
column.cells.insert(Check::B, b);
|
column.cells.insert(Check::B, b);
|
||||||
introspection_c = Some(match init.server_status.as_deref() {
|
view = Some(match init.server_status.as_deref() {
|
||||||
Some("connected") => match tools_diff(&init.tools, &ctx.project.expected_tools) {
|
Some("connected") => ClientView::Connected(init.tools.clone()),
|
||||||
None => Cell::ready(
|
Some(status) => {
|
||||||
EvidenceLevel::ClientIntrospection,
|
ClientView::NotConnected(format!("Claude init:lian-jing 状态 {status}"))
|
||||||
format!(
|
}
|
||||||
"Claude init:lian-jing connected,{} 个工具与期望一致",
|
None => {
|
||||||
init.tools.len()
|
ClientView::NotConnected("Claude init 的 mcp_servers 中没有 lian-jing".into())
|
||||||
),
|
}
|
||||||
),
|
|
||||||
Some(diff) => Cell::fail(
|
|
||||||
Cause::ToolsMismatch,
|
|
||||||
EvidenceLevel::ClientIntrospection,
|
|
||||||
format!("Claude init 看到的 lian-jing 工具与期望不一致:{diff}"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Some(status) => Cell::fail(
|
|
||||||
Cause::NotConnected,
|
|
||||||
EvidenceLevel::ClientIntrospection,
|
|
||||||
format!("Claude init:lian-jing 状态 {status}"),
|
|
||||||
),
|
|
||||||
None => Cell::fail(
|
|
||||||
Cause::NotConnected,
|
|
||||||
EvidenceLevel::ClientIntrospection,
|
|
||||||
"Claude init 的 mcp_servers 中没有 lian-jing",
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 取不到 init 时退用 claude mcp get 的 Status 行:只采信「明确未连接」
|
||||||
|
Err(reason) if effective.connected == Some(false) => {
|
||||||
|
view = Some(ClientView::NotConnected(format!(
|
||||||
|
"claude mcp get 显示 lian-jing 未连接(未取得 Claude init 自省:{reason})"
|
||||||
|
)))
|
||||||
|
}
|
||||||
Err(reason) => introspection_note = format!("(未取得 Claude init 自省:{reason})"),
|
Err(reason) => introspection_note = format!("(未取得 Claude init 自省:{reason})"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ③ 认证链重放:Claude 的 helper 失败时连接不建立
|
// ③ 认证链重放:Claude 的 helper 失败时连接不建立
|
||||||
let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort);
|
let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort);
|
||||||
replayed.c = match (replayed.c.state, introspection_c) {
|
let replay_c_ready = replayed.c.state == CellState::Ready;
|
||||||
// 重放失败的归因更具体(shim_missing / auth_failed / unreachable),优先采用
|
match view {
|
||||||
(CellState::Ready, Some(introspected)) if introspected.state != CellState::Ready => {
|
None => replayed.c.detail.push_str(&introspection_note),
|
||||||
introspected
|
Some(ClientView::NotConnected(detail)) => {
|
||||||
|
// 重放失败的归因更具体(shim_missing / auth_failed / unreachable),C 优先采用重放的
|
||||||
|
if replay_c_ready {
|
||||||
|
replayed.c = Cell::fail(
|
||||||
|
Cause::NotConnected,
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
detail.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
block_calls(
|
||||||
|
&mut replayed,
|
||||||
|
Cause::NotConnected,
|
||||||
|
&format!("Claude 自己未连上 lian-jing({detail})"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
(CellState::Ready, Some(_)) => Cell::ready(
|
Some(ClientView::Connected(tools)) => {
|
||||||
EvidenceLevel::Protocol,
|
if replay_c_ready {
|
||||||
format!("{};Claude init 自省一致", replayed.c.detail),
|
replayed.c = match tools_diff(&tools, &ctx.project.expected_tools) {
|
||||||
),
|
None => Cell::ready(
|
||||||
(_, _) => {
|
EvidenceLevel::Protocol,
|
||||||
let mut cell = replayed.c;
|
format!("{};Claude init 自省一致", replayed.c.detail),
|
||||||
cell.detail.push_str(&introspection_note);
|
),
|
||||||
cell
|
Some(diff) => Cell::fail(
|
||||||
|
Cause::ToolsMismatch,
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
format!("Claude init 看到的 lian-jing 工具与期望不一致:{diff}"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
hide_invisible_tools(
|
||||||
|
&mut replayed,
|
||||||
|
&|tool| tools.iter().any(|name| name == tool),
|
||||||
|
"Claude init ",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
replayed.apply(column);
|
replayed.apply(column);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn probe_codex(
|
/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的事实(Codex 是否加载它以 codex mcp get 为准)。
|
||||||
ctx: &Ctx,
|
struct CodexProjectTable<'a> {
|
||||||
located: &Located,
|
present: bool,
|
||||||
project_entry: bool,
|
url: Option<&'a str>,
|
||||||
project_helper: Option<&str>,
|
helper: Option<&'a str>,
|
||||||
column: &mut ClientColumn,
|
}
|
||||||
) {
|
|
||||||
|
fn probe_codex(ctx: &Ctx, located: &Located, table: CodexProjectTable, column: &mut ClientColumn) {
|
||||||
|
let project_entry = table.present;
|
||||||
let Some(program) = located.program.as_deref() else {
|
let Some(program) = located.program.as_deref() else {
|
||||||
set_connection_cells(
|
set_connection_cells(
|
||||||
column,
|
column,
|
||||||
@ -832,7 +983,7 @@ fn probe_codex(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (url, has_helper) = match parse_codex_mcp_get(&output.stdout, &output.stderr) {
|
let (url, has_helper, filter) = match parse_codex_mcp_get(&output.stdout, &output.stderr) {
|
||||||
CodexMcpGet::NotFound if project_entry => {
|
CodexMcpGet::NotFound if project_entry => {
|
||||||
set_connection_cells(
|
set_connection_cells(
|
||||||
column,
|
column,
|
||||||
@ -901,15 +1052,36 @@ fn probe_codex(
|
|||||||
CodexMcpGet::Found {
|
CodexMcpGet::Found {
|
||||||
url: Some(url),
|
url: Some(url),
|
||||||
has_helper,
|
has_helper,
|
||||||
|
enabled_tools,
|
||||||
|
disabled_tools,
|
||||||
..
|
..
|
||||||
} => (url, has_helper),
|
} => (url, has_helper, (enabled_tools, disabled_tools)),
|
||||||
};
|
};
|
||||||
column.route = Some(url.clone());
|
column.route = Some(url.clone());
|
||||||
|
// 项目表在、Codex 生效的却是别处的条目(多半是用户级 ~/.codex 的全局条目):项目配置未加载,
|
||||||
|
// 首要原因是目录未受信任——「重跑 inject_mcp」修不好(审查 ⑥b)
|
||||||
|
if let Some(project_url) = table
|
||||||
|
.url
|
||||||
|
.filter(|project_url| project_entry && *project_url != url)
|
||||||
|
{
|
||||||
|
set_connection_cells(
|
||||||
|
column,
|
||||||
|
Cell::fail(
|
||||||
|
Cause::CodexProjectUntrusted,
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
format!(
|
||||||
|
"项目 .codex/config.toml 的 lian-jing 指向 {project_url},Codex 生效的却是 {url}(多半来自用户级 ~/.codex/config.toml)\
|
||||||
|
——项目配置未加载,目录多半未受 Codex 信任(只加载受信任目录的项目配置,H-P8-1);在 Codex 中信任该项目后重试"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(cell) = route_cell(&url, &ctx.project.expected_url, "Codex ") {
|
if let Some(cell) = route_cell(&url, &ctx.project.expected_url, "Codex ") {
|
||||||
set_connection_cells(column, cell);
|
set_connection_cells(column, cell);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let helper = match (has_helper, project_helper) {
|
let helper = match (has_helper, table.helper) {
|
||||||
(true, Some(helper)) => Some(HelperCommand::Shell(helper.to_string())),
|
(true, Some(helper)) => Some(HelperCommand::Shell(helper.to_string())),
|
||||||
(true, None) => {
|
(true, None) => {
|
||||||
set_connection_cells(
|
set_connection_cells(
|
||||||
@ -924,13 +1096,50 @@ fn probe_codex(
|
|||||||
}
|
}
|
||||||
(false, _) => None,
|
(false, _) => None,
|
||||||
};
|
};
|
||||||
let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort);
|
if let Some(cell) = unmanaged_helper_cell(ctx, helper.as_ref(), "Codex ") {
|
||||||
if replayed.c.state == CellState::Ready {
|
set_connection_cells(column, cell);
|
||||||
replayed
|
return;
|
||||||
.c
|
|
||||||
.detail
|
|
||||||
.push_str(";codex mcp get 确认项目配置已加载");
|
|
||||||
}
|
}
|
||||||
|
let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort);
|
||||||
|
// 重放的 tools/list 与期望出自同一服务端,只证明服务端视角;Codex 会话还要过它自己的工具过滤器(审查 ⑤)
|
||||||
|
let (enabled_tools, disabled_tools) = filter;
|
||||||
|
let visible = |tool: &str| {
|
||||||
|
enabled_tools
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|allowed| allowed.iter().any(|name| name == tool))
|
||||||
|
&& !disabled_tools
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|denied| denied.iter().any(|name| name == tool))
|
||||||
|
};
|
||||||
|
let hidden: Vec<&str> = ctx
|
||||||
|
.project
|
||||||
|
.expected_tools
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|tool| !visible(tool))
|
||||||
|
.collect();
|
||||||
|
if replayed.c.state == CellState::Ready {
|
||||||
|
if hidden.is_empty() {
|
||||||
|
replayed.c.detail.push_str(
|
||||||
|
";codex mcp get 确认项目配置已加载,enabled_tools/disabled_tools 未隐藏期望工具(tools/list 为服务端视角)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
replayed.c = Cell::fail(
|
||||||
|
Cause::ToolsMismatch,
|
||||||
|
EvidenceLevel::ClientIntrospection,
|
||||||
|
format!(
|
||||||
|
"Codex 的 enabled_tools/disabled_tools 隐藏了 {} 个期望工具({});重放的 tools/list 只证明服务端视角",
|
||||||
|
hidden.len(),
|
||||||
|
hidden.iter().take(3).copied().collect::<Vec<_>>().join("、")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hide_invisible_tools(
|
||||||
|
&mut replayed,
|
||||||
|
&visible,
|
||||||
|
"Codex(enabled_tools/disabled_tools 过滤后)",
|
||||||
|
);
|
||||||
replayed.apply(column);
|
replayed.apply(column);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1205,6 +1414,17 @@ fn replay(
|
|||||||
Some(helper) => {
|
Some(helper) => {
|
||||||
let failure = match run_helper(ctx, helper) {
|
let failure = match run_helper(ctx, helper) {
|
||||||
HelperRun::Headers(found) => {
|
HelperRun::Headers(found) => {
|
||||||
|
// helper 成功但没给 Authorization(如 {}):连接照常,敏感读取必然被拒——归因认证头缺失,
|
||||||
|
// 不能记成「token 不符」(审查 ⑥c)
|
||||||
|
if !found
|
||||||
|
.iter()
|
||||||
|
.any(|(name, _)| name.eq_ignore_ascii_case("authorization"))
|
||||||
|
{
|
||||||
|
auth_issue = Some((
|
||||||
|
Cause::AuthHeaderMissing,
|
||||||
|
"helper 输出的请求头里没有 Authorization(内容不展示)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
headers = found;
|
headers = found;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -1293,8 +1513,8 @@ fn replay_calls(
|
|||||||
json!({ "name": tool, "arguments": arguments }),
|
json!({ "name": tool, "arguments": arguments }),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let d = match call(3, "get_diagnostics", json!({})) {
|
let d = match call(3, D_TOOL, json!({})) {
|
||||||
Err(error) => rpc_failure_cell(error, "get_diagnostics"),
|
Err(error) => rpc_failure_cell(error, D_TOOL),
|
||||||
Ok(result) if is_tool_error(&result) => Cell::fail(
|
Ok(result) if is_tool_error(&result) => Cell::fail(
|
||||||
Cause::ToolError,
|
Cause::ToolError,
|
||||||
EvidenceLevel::Protocol,
|
EvidenceLevel::Protocol,
|
||||||
@ -1305,8 +1525,8 @@ fn replay_calls(
|
|||||||
),
|
),
|
||||||
Ok(_) => Cell::ready(EvidenceLevel::Protocol, "get_diagnostics 正常返回"),
|
Ok(_) => Cell::ready(EvidenceLevel::Protocol, "get_diagnostics 正常返回"),
|
||||||
};
|
};
|
||||||
let e = match call(4, "list_project_dev_variables", json!({})) {
|
let e = match call(4, E_TOOL, json!({})) {
|
||||||
Err(error) => rpc_failure_cell(error, "list_project_dev_variables"),
|
Err(error) => rpc_failure_cell(error, E_TOOL),
|
||||||
Ok(result) if is_tool_error(&result) => Cell::fail(
|
Ok(result) if is_tool_error(&result) => Cell::fail(
|
||||||
Cause::ToolError,
|
Cause::ToolError,
|
||||||
EvidenceLevel::Protocol,
|
EvidenceLevel::Protocol,
|
||||||
@ -1321,7 +1541,7 @@ fn replay_calls(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
// F 的结果文本可能是真实测试账户:只判断固定回答,任何情况下都不把内容放进报告
|
// F 的结果文本可能是真实测试账户:只判断固定回答,任何情况下都不把内容放进报告
|
||||||
let f = match call(5, "read_project_test_account", json!({ "name": ctx.nonce })) {
|
let f = match call(5, F_TOOL, json!({ "name": ctx.nonce })) {
|
||||||
Err(RpcError::Status(403)) => match auth_issue {
|
Err(RpcError::Status(403)) => match auth_issue {
|
||||||
Some((cause, detail)) => Cell::fail(
|
Some((cause, detail)) => Cell::fail(
|
||||||
cause,
|
cause,
|
||||||
@ -1334,7 +1554,7 @@ fn replay_calls(
|
|||||||
"read_project_test_account 被拒(403):helper 给出的认证与本项目 token 不符",
|
"read_project_test_account 被拒(403):helper 给出的认证与本项目 token 不符",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Err(error) => rpc_failure_cell(error, "read_project_test_account"),
|
Err(error) => rpc_failure_cell(error, F_TOOL),
|
||||||
Ok(result) if is_tool_error(&result) && tool_text(&result).contains(UNREGISTERED_NEED) => {
|
Ok(result) if is_tool_error(&result) && tool_text(&result).contains(UNREGISTERED_NEED) => {
|
||||||
Cell::ready(
|
Cell::ready(
|
||||||
EvidenceLevel::Protocol,
|
EvidenceLevel::Protocol,
|
||||||
@ -1406,11 +1626,24 @@ pub enum ClaudeScope {
|
|||||||
Absent,
|
Absent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ClaudeScope {
|
||||||
|
/// 对应的配置作用域(回查 headersHelper 时优先采用)。Scope 行原文:Local config / Project config
|
||||||
|
/// (shared via .mcp.json) / User config;认不出为 None(退回按 URL 取第一条)。
|
||||||
|
pub fn entry_source(&self) -> Option<ClaudeEntrySource> {
|
||||||
|
match self {
|
||||||
|
Self::Local => Some(ClaudeEntrySource::Local),
|
||||||
|
Self::Other(text) if text.starts_with("Project") => Some(ClaudeEntrySource::Project),
|
||||||
|
Self::Other(text) if text.starts_with("User") => Some(ClaudeEntrySource::User),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClaudeMcpGet {
|
pub struct ClaudeMcpGet {
|
||||||
pub scope: ClaudeScope,
|
pub scope: ClaudeScope,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
/// Status 行:Connected 为 true;其他状态为 false;没有该行为 None
|
/// Status 行:Connected 为 true;其他状态为 false;没有该行为 None。取不到 init 时 L1 采信其中的「未连接」。
|
||||||
pub connected: Option<bool>,
|
pub connected: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1509,6 +1742,10 @@ pub enum CodexMcpGet {
|
|||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
/// transport.http_headers_helper 非空(Codex 输出时脱敏为 "<redacted>")
|
/// transport.http_headers_helper 非空(Codex 输出时脱敏为 "<redacted>")
|
||||||
has_helper: bool,
|
has_helper: bool,
|
||||||
|
/// 工具白名单(null 为不限)
|
||||||
|
enabled_tools: Option<Vec<String>>,
|
||||||
|
/// 工具黑名单(null 为无)
|
||||||
|
disabled_tools: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
NotFound,
|
NotFound,
|
||||||
Unrecognized,
|
Unrecognized,
|
||||||
@ -1535,6 +1772,8 @@ pub fn parse_codex_mcp_get(stdout: &str, stderr: &str) -> CodexMcpGet {
|
|||||||
has_helper: transport
|
has_helper: transport
|
||||||
.and_then(|transport| transport.get("http_headers_helper"))
|
.and_then(|transport| transport.get("http_headers_helper"))
|
||||||
.is_some_and(|helper| !helper.is_null()),
|
.is_some_and(|helper| !helper.is_null()),
|
||||||
|
enabled_tools: string_list(value.get("enabled_tools")),
|
||||||
|
disabled_tools: string_list(value.get("disabled_tools")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1545,6 +1784,17 @@ pub fn parse_codex_mcp_get(stdout: &str, stderr: &str) -> CodexMcpGet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON 字符串数组;null 或缺失为 None。
|
||||||
|
fn string_list(value: Option<&Value>) -> Option<Vec<String>> {
|
||||||
|
value.and_then(Value::as_array).map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `codex --version` → 版本号(如 codex-cli 0.155.0-alpha.9.2 → 0.155.0-alpha.9.2)。
|
/// `codex --version` → 版本号(如 codex-cli 0.155.0-alpha.9.2 → 0.155.0-alpha.9.2)。
|
||||||
pub fn parse_codex_version(stdout: &str) -> Option<String> {
|
pub fn parse_codex_version(stdout: &str) -> Option<String> {
|
||||||
stdout
|
stdout
|
||||||
@ -1610,6 +1860,16 @@ pub fn codex_has_table(config: &str) -> bool {
|
|||||||
|
|
||||||
/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的 http_headers_helper(基本字符串或字面量字符串)。
|
/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的 http_headers_helper(基本字符串或字面量字符串)。
|
||||||
pub fn codex_table_helper(config: &str) -> Option<String> {
|
pub fn codex_table_helper(config: &str) -> Option<String> {
|
||||||
|
codex_table_string(config, "http_headers_helper")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的 url。
|
||||||
|
pub fn codex_table_url(config: &str) -> Option<String> {
|
||||||
|
codex_table_string(config, "url")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [mcp_servers.lian-jing] 表里某个字符串键的值(单行基本字符串或字面量字符串)。
|
||||||
|
fn codex_table_string(config: &str, key: &str) -> Option<String> {
|
||||||
let header = format!("[mcp_servers.{SERVER_NAME}]");
|
let header = format!("[mcp_servers.{SERVER_NAME}]");
|
||||||
let mut in_table = false;
|
let mut in_table = false;
|
||||||
for line in config.lines() {
|
for line in config.lines() {
|
||||||
@ -1622,7 +1882,7 @@ pub fn codex_table_helper(config: &str) -> Option<String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(value) = line
|
let Some(value) = line
|
||||||
.strip_prefix("http_headers_helper")
|
.strip_prefix(key)
|
||||||
.map(str::trim_start)
|
.map(str::trim_start)
|
||||||
.and_then(|rest| rest.strip_prefix('='))
|
.and_then(|rest| rest.strip_prefix('='))
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@ -1776,7 +2036,11 @@ impl ProcessRunner for SystemRunner {
|
|||||||
let mut buffer = Vec::new();
|
let mut buffer = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
match reader.read_until(b'\n', &mut buffer) {
|
// 单行也有上限:不换行的输出不会在读线程里无限累积
|
||||||
|
match (&mut reader)
|
||||||
|
.take(MAX_CAPTURE as u64)
|
||||||
|
.read_until(b'\n', &mut buffer)
|
||||||
|
{
|
||||||
Ok(0) | Err(_) => break,
|
Ok(0) | Err(_) => break,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if line_tx
|
if line_tx
|
||||||
@ -1791,17 +2055,31 @@ impl ProcessRunner for SystemRunner {
|
|||||||
});
|
});
|
||||||
let (err_tx, err_rx) = channel::<String>();
|
let (err_tx, err_rx) = channel::<String>();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut bytes = Vec::new();
|
// 只保留前 MAX_CAPTURE 字节,其余读掉丢弃:持续刷 stderr 的进程不会撑爆内存
|
||||||
let _ = stderr.read_to_end(&mut bytes);
|
let mut kept = Vec::new();
|
||||||
let _ = err_tx.send(String::from_utf8_lossy(&bytes).into_owned());
|
let mut chunk = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match stderr.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(count) => {
|
||||||
|
let room = MAX_CAPTURE.saturating_sub(kept.len());
|
||||||
|
kept.extend_from_slice(&chunk[..count.min(room)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = err_tx.send(String::from_utf8_lossy(&kept).into_owned());
|
||||||
});
|
});
|
||||||
|
|
||||||
let deadline = Instant::now() + spec.timeout;
|
let deadline = Instant::now() + spec.timeout;
|
||||||
let mut output = ProcessOutput::default();
|
let mut output = ProcessOutput::default();
|
||||||
let mut exit = None;
|
let mut exit = None;
|
||||||
let mut closed = false;
|
let mut closed = false;
|
||||||
|
// 每轮都查退出与超时,收到行也不跳过:否则每 50 毫秒内至少输出一行的进程永不超时(审查 ①)
|
||||||
loop {
|
loop {
|
||||||
match line_rx.recv_timeout(Duration::from_millis(50)) {
|
let wait = deadline
|
||||||
|
.saturating_duration_since(Instant::now())
|
||||||
|
.min(Duration::from_millis(50));
|
||||||
|
match line_rx.recv_timeout(wait) {
|
||||||
Ok(line) => {
|
Ok(line) => {
|
||||||
let hit = spec
|
let hit = spec
|
||||||
.stop_at_stdout_line
|
.stop_at_stdout_line
|
||||||
@ -1814,7 +2092,6 @@ impl ProcessRunner for SystemRunner {
|
|||||||
output.stopped_early = true;
|
output.stopped_early = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Err(RecvTimeoutError::Timeout) => {}
|
Err(RecvTimeoutError::Timeout) => {}
|
||||||
Err(RecvTimeoutError::Disconnected) => {
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
@ -1859,9 +2136,41 @@ impl ProcessRunner for SystemRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 超时按 io::ErrorKind 判定(沿 source 链),不依赖错误文本:Windows 上 socket 读超时是 WSAETIMEDOUT
|
||||||
|
/// (os error 10060,ErrorKind::TimedOut),文本是本地化的系统消息、不含 "timed out"(审查 ②)。
|
||||||
|
fn is_timeout_error(error: &(dyn std::error::Error + 'static)) -> bool {
|
||||||
|
let mut current = Some(error);
|
||||||
|
while let Some(error) = current {
|
||||||
|
if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||||
|
if matches!(
|
||||||
|
io.kind(),
|
||||||
|
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = error.source();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// 真实 HTTP:ureq 单次请求,不复用连接、不记录请求头。
|
/// 真实 HTTP:ureq 单次请求,不复用连接、不记录请求头。
|
||||||
pub struct UreqHttp;
|
pub struct UreqHttp;
|
||||||
|
|
||||||
|
impl UreqHttp {
|
||||||
|
/// 读响应正文:头已到、正文读超时同样是环境类超时,不能记成「响应不是 JSON」。
|
||||||
|
fn reply(status: u16, response: ureq::Response) -> Result<HttpReply, HttpFailure> {
|
||||||
|
match response.into_json::<Value>() {
|
||||||
|
Ok(body) => Ok(HttpReply {
|
||||||
|
status,
|
||||||
|
body: Some(body),
|
||||||
|
}),
|
||||||
|
Err(error) if is_timeout_error(&error) => Err(HttpFailure::Timeout),
|
||||||
|
Err(_) => Ok(HttpReply { status, body: None }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl McpHttp for UreqHttp {
|
impl McpHttp for UreqHttp {
|
||||||
fn post(
|
fn post(
|
||||||
&self,
|
&self,
|
||||||
@ -1875,21 +2184,13 @@ impl McpHttp for UreqHttp {
|
|||||||
request = request.set(name, value);
|
request = request.set(name, value);
|
||||||
}
|
}
|
||||||
match request.send_json(body.clone()) {
|
match request.send_json(body.clone()) {
|
||||||
Ok(response) => Ok(HttpReply {
|
Ok(response) => Self::reply(response.status(), response),
|
||||||
status: response.status(),
|
Err(ureq::Error::Status(status, response)) => Self::reply(status, response),
|
||||||
body: response.into_json().ok(),
|
|
||||||
}),
|
|
||||||
Err(ureq::Error::Status(status, response)) => Ok(HttpReply {
|
|
||||||
status,
|
|
||||||
body: response.into_json().ok(),
|
|
||||||
}),
|
|
||||||
Err(ureq::Error::Transport(transport)) => Err(match transport.kind() {
|
Err(ureq::Error::Transport(transport)) => Err(match transport.kind() {
|
||||||
ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::Dns => {
|
ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::Dns => {
|
||||||
HttpFailure::Unreachable(format!("{:?}", transport.kind()))
|
HttpFailure::Unreachable(format!("{:?}", transport.kind()))
|
||||||
}
|
}
|
||||||
ureq::ErrorKind::Io if transport.to_string().contains("timed out") => {
|
_ if is_timeout_error(&transport) => HttpFailure::Timeout,
|
||||||
HttpFailure::Timeout
|
|
||||||
}
|
|
||||||
kind => HttpFailure::Other(format!("{kind:?}")),
|
kind => HttpFailure::Other(format!("{kind:?}")),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -377,7 +377,7 @@ impl Fixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn facts(&self, url: &str, shim: &Path) -> Vec<L1ClientFacts> {
|
fn facts(&self, url: &str, shim: &Path) -> Vec<L1ClientFacts> {
|
||||||
let line = format!("\"{}\" {DIGEST}", shim.display());
|
let line = helper_line(shim);
|
||||||
let agents_skill = || skill_finding(&self.root, ".agents/skills/lian-jing/SKILL.md");
|
let agents_skill = || skill_finding(&self.root, ".agents/skills/lian-jing/SKILL.md");
|
||||||
vec![
|
vec![
|
||||||
L1ClientFacts {
|
L1ClientFacts {
|
||||||
@ -386,7 +386,14 @@ impl Fixture {
|
|||||||
rules: rules_finding(&self.root, "CLAUDE.md", true),
|
rules: rules_finding(&self.root, "CLAUDE.md", true),
|
||||||
skill: skill_finding(&self.root, ".claude/skills/lian-jing/SKILL.md"),
|
skill: skill_finding(&self.root, ".claude/skills/lian-jing/SKILL.md"),
|
||||||
source: L1Source::Claude {
|
source: L1Source::Claude {
|
||||||
entries: vec![json!({ "type": "http", "url": url, "headersHelper": line })],
|
// 手写条目可能带静态认证头:零模型 init 只取 type、url、headersHelper,不得把它放上命令行
|
||||||
|
entries: vec![ClaudeEntry {
|
||||||
|
source: ClaudeEntrySource::Local,
|
||||||
|
entry: json!({
|
||||||
|
"type": "http", "url": url, "headersHelper": line,
|
||||||
|
"headers": { "Authorization": format!("Bearer {}", self.token) }
|
||||||
|
}),
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
L1ClientFacts {
|
L1ClientFacts {
|
||||||
@ -396,6 +403,7 @@ impl Fixture {
|
|||||||
skill: agents_skill(),
|
skill: agents_skill(),
|
||||||
source: L1Source::Codex {
|
source: L1Source::Codex {
|
||||||
project_entry: true,
|
project_entry: true,
|
||||||
|
project_url: Some(url.to_string()),
|
||||||
project_helper: Some(line.clone()),
|
project_helper: Some(line.clone()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -426,10 +434,19 @@ impl Fixture {
|
|||||||
control_plane: false,
|
control_plane: false,
|
||||||
expected_url: url.to_string(),
|
expected_url: url.to_string(),
|
||||||
expected_tools: catalog_names(),
|
expected_tools: catalog_names(),
|
||||||
|
// 夹具的哑 shim 视同托管写法(生产里是 %USERPROFILE% 稳定入口 + 本项目 digest)
|
||||||
|
managed_helpers: [&self.good_shim, &self.wrong_shim, &self.missing_shim]
|
||||||
|
.into_iter()
|
||||||
|
.map(|shim| helper_line(shim))
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn helper_line(shim: &Path) -> String {
|
||||||
|
format!("\"{}\" {DIGEST}", shim.display())
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for Fixture {
|
impl Drop for Fixture {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_dir_all(&self.dir);
|
let _ = std::fs::remove_dir_all(&self.dir);
|
||||||
@ -571,6 +588,29 @@ fn all_cells_ready_with_evidence_levels_and_no_token_in_output() {
|
|||||||
"缺少参数 {expected}"
|
"缺少参数 {expected}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 关掉全部 hooks(-p 不弹信任框,会执行项目与用户 hooks)
|
||||||
|
let settings = init
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.position(|arg| arg == "--settings")
|
||||||
|
.expect("应带 --settings");
|
||||||
|
let settings: Value = serde_json::from_str(&init.args[settings + 1]).unwrap();
|
||||||
|
assert_eq!(settings, json!({ "disableAllHooks": true }));
|
||||||
|
// --mcp-config 只带 type、url、headersHelper:手写条目的静态 headers(含认证)不上命令行
|
||||||
|
let config = init
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.position(|arg| arg == "--mcp-config")
|
||||||
|
.expect("应带 --mcp-config");
|
||||||
|
let config: Value = serde_json::from_str(&init.args[config + 1]).unwrap();
|
||||||
|
let mut keys: Vec<&str> = config["mcpServers"]["lian-jing"]
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.keys()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect();
|
||||||
|
keys.sort();
|
||||||
|
assert_eq!(keys, ["headersHelper", "type", "url"]);
|
||||||
assert!(init
|
assert!(init
|
||||||
.env_set
|
.env_set
|
||||||
.contains(&("MCP_CONNECTION_NONBLOCKING".into(), "false".into())));
|
.contains(&("MCP_CONNECTION_NONBLOCKING".into(), "false".into())));
|
||||||
@ -789,6 +829,7 @@ fn codex_without_project_config_is_untrusted_or_not_configured() {
|
|||||||
let mut no_entry = codex_only.clone();
|
let mut no_entry = codex_only.clone();
|
||||||
no_entry[0].source = L1Source::Codex {
|
no_entry[0].source = L1Source::Codex {
|
||||||
project_entry: false,
|
project_entry: false,
|
||||||
|
project_url: None,
|
||||||
project_helper: None,
|
project_helper: None,
|
||||||
};
|
};
|
||||||
let report = run(
|
let report = run(
|
||||||
@ -932,8 +973,10 @@ fn failing_or_garbled_helper_is_helper_failed_and_output_never_echoed() {
|
|||||||
let garbled = write_shim("bin-garbled", &format!("echo Bearer {}", fx.token));
|
let garbled = write_shim("bin-garbled", &format!("echo Bearer {}", fx.token));
|
||||||
let failing = write_shim("bin-failing", "exit /b 7");
|
let failing = write_shim("bin-failing", "exit /b 7");
|
||||||
for shim in [garbled, failing] {
|
for shim in [garbled, failing] {
|
||||||
|
let mut project = fx.project(&url);
|
||||||
|
project.managed_helpers.push(helper_line(&shim));
|
||||||
let report = run(
|
let report = run(
|
||||||
&fx.project(&url),
|
&project,
|
||||||
&fx.facts(&url, &shim),
|
&fx.facts(&url, &shim),
|
||||||
&runner,
|
&runner,
|
||||||
true,
|
true,
|
||||||
@ -971,7 +1014,8 @@ fn claude_init_view_overrides_c_when_replay_passes() {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|fact| fact.client == ClientId::Claude)
|
.filter(|fact| fact.client == ClientId::Claude)
|
||||||
.collect();
|
.collect();
|
||||||
// Claude 自己没连上:重放虽通过,C 仍按 Claude 的视角判失败;D–F 是协议重放结论
|
// Claude 自己没连上:重放虽通过,C 仍按 Claude 的视角判失败;D–F 服务端可用但会话里调不到 → blocked
|
||||||
|
// (审查 ⑩ 改口径:原断言 F 为 ready,读起来像「没连上却能调用」)
|
||||||
runner.claude_init = runner.claude_init.replace("\"connected\"", "\"failed\"");
|
runner.claude_init = runner.claude_init.replace("\"connected\"", "\"failed\"");
|
||||||
let report = run(
|
let report = run(
|
||||||
&fx.project(&url),
|
&fx.project(&url),
|
||||||
@ -983,9 +1027,57 @@ fn claude_init_view_overrides_c_when_replay_passes() {
|
|||||||
let c = cell(&report, ClientId::Claude, Check::C);
|
let c = cell(&report, ClientId::Claude, Check::C);
|
||||||
assert_eq!(c.cause, Some(Cause::NotConnected), "{c:?}");
|
assert_eq!(c.cause, Some(Cause::NotConnected), "{c:?}");
|
||||||
assert_eq!(c.evidence, EvidenceLevel::ClientIntrospection);
|
assert_eq!(c.evidence, EvidenceLevel::ClientIntrospection);
|
||||||
|
for check in [Check::D, Check::E, Check::F] {
|
||||||
|
let got = cell(&report, ClientId::Claude, check);
|
||||||
|
assert_eq!(got.state, CellState::Blocked, "{check:?}: {got:?}");
|
||||||
|
assert_eq!(got.cause, Some(Cause::NotConnected));
|
||||||
|
assert!(got.detail.contains("服务端视角"), "{got:?}");
|
||||||
|
}
|
||||||
|
assert!(!report.all_applicable_ready);
|
||||||
|
|
||||||
|
// Claude 连上了但看不到 read_project_test_account:C 与 F 按 Claude 视角记 tools_mismatch,D、E 照常
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
runner.claude_init = runner
|
||||||
|
.claude_init
|
||||||
|
.replace("\"mcp__lian-jing__read_project_test_account\",", "");
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&facts,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
for check in [Check::C, Check::F] {
|
||||||
|
let got = cell(&report, ClientId::Claude, check);
|
||||||
|
assert_eq!(got.cause, Some(Cause::ToolsMismatch), "{check:?}: {got:?}");
|
||||||
|
assert_eq!(got.evidence, EvidenceLevel::ClientIntrospection);
|
||||||
|
}
|
||||||
|
for check in [Check::D, Check::E] {
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Claude, check).state,
|
||||||
|
CellState::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取不到 init 时退用 claude mcp get 的 Status 行:明确未连接同样判 C 失败、D–F blocked
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
runner.claude_init = "{\"type\":\"result\"}\n".into();
|
||||||
|
runner.claude_get = runner
|
||||||
|
.claude_get
|
||||||
|
.replace("✔ Connected", "✘ Failed to connect");
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&facts,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
let c = cell(&report, ClientId::Claude, Check::C);
|
||||||
|
assert_eq!(c.cause, Some(Cause::NotConnected), "{c:?}");
|
||||||
|
assert!(c.detail.contains("claude mcp get"), "{c:?}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cell(&report, ClientId::Claude, Check::F).state,
|
cell(&report, ClientId::Claude, Check::F).state,
|
||||||
CellState::Ready
|
CellState::Blocked
|
||||||
);
|
);
|
||||||
|
|
||||||
// init 里 skills 缺 lian-jing:B 由静态升级为客户端自省并判失败
|
// init 里 skills 缺 lian-jing:B 由静态升级为客户端自省并判失败
|
||||||
@ -1084,6 +1176,378 @@ fn dsh_session_plugin_mismatch_fails_c_but_keeps_headless_replay() {
|
|||||||
.is_some_and(|route| route.ends_with("/mcp")));
|
.is_some_and(|route| route.ends_with("/mcp")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmanaged_helper_is_manual_config_and_never_executed() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let stub = StubServer::start(&fx.token, catalog_names());
|
||||||
|
let url = stub.url();
|
||||||
|
let runner = FakeRunner::ready(&url);
|
||||||
|
// 配置里的 helper 不是炼境托管写法(如仓库里被改成任意命令):Claude、Codex 都不执行
|
||||||
|
let mut project = fx.project(&url);
|
||||||
|
project.managed_helpers.clear();
|
||||||
|
let report = run(
|
||||||
|
&project,
|
||||||
|
&fx.facts(&url, &fx.good_shim),
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
for client in [ClientId::Claude, ClientId::Codex] {
|
||||||
|
for check in [Check::C, Check::D, Check::E, Check::F] {
|
||||||
|
let got = cell(&report, client, check);
|
||||||
|
assert_eq!(got.state, CellState::Unknown, "{client:?} {check:?}");
|
||||||
|
assert_eq!(got.cause, Some(Cause::ManualConfig), "{client:?} {check:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Claude 也不起零模型 init(init 会让 Claude 自己执行该 helper)
|
||||||
|
assert!(!runner.specs().iter().any(|spec| {
|
||||||
|
spec.program.ends_with("fake-claude") && spec.args.first().map(String::as_str) == Some("-p")
|
||||||
|
}));
|
||||||
|
// DSH 的 helper 由炼境按托管块写法构造,照常执行:全程只起这一个 cmd
|
||||||
|
assert_eq!(runner.count("cmd.exe"), 1);
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Dsh, Check::F).state,
|
||||||
|
CellState::Ready
|
||||||
|
);
|
||||||
|
let reads = stub
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|call| call.tool.as_deref() == Some("read_project_test_account"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(reads, 1, "只有 DSH 重放");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helper_without_authorization_header_is_auth_header_missing() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let stub = StubServer::start(&fx.token, catalog_names());
|
||||||
|
let url = stub.url();
|
||||||
|
let runner = FakeRunner::ready(&url);
|
||||||
|
let bin = fx.dir.join("bin-empty");
|
||||||
|
std::fs::create_dir_all(&bin).unwrap();
|
||||||
|
let shim = bin.join("lianjing-mcp-header.cmd");
|
||||||
|
std::fs::write(&shim, "@echo off\r\necho {}\r\n").unwrap();
|
||||||
|
let mut project = fx.project(&url);
|
||||||
|
project.managed_helpers.push(helper_line(&shim));
|
||||||
|
let report = run(
|
||||||
|
&project,
|
||||||
|
&fx.facts(&url, &shim),
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
for client in ALL {
|
||||||
|
for check in [Check::C, Check::D, Check::E] {
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, client, check).state,
|
||||||
|
CellState::Ready,
|
||||||
|
"{client:?} {check:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let f = cell(&report, client, Check::F);
|
||||||
|
assert_eq!(f.cause, Some(Cause::AuthHeaderMissing), "{client:?}: {f:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_helper_is_looked_up_in_the_scope_claude_reports() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let stub = StubServer::start(&fx.token, catalog_names());
|
||||||
|
let url = stub.url();
|
||||||
|
// local 条目(错误 token)与 user 条目(正确 token)都指向本项目地址:采用 Claude 报告的作用域那条
|
||||||
|
let entries = vec![
|
||||||
|
ClaudeEntry {
|
||||||
|
source: ClaudeEntrySource::Local,
|
||||||
|
entry: json!({ "type": "http", "url": url, "headersHelper": helper_line(&fx.wrong_shim) }),
|
||||||
|
},
|
||||||
|
ClaudeEntry {
|
||||||
|
source: ClaudeEntrySource::User,
|
||||||
|
entry: json!({ "type": "http", "url": url, "headersHelper": helper_line(&fx.good_shim) }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let facts: Vec<L1ClientFacts> = fx
|
||||||
|
.facts(&url, &fx.good_shim)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|fact| fact.client == ClientId::Claude)
|
||||||
|
.map(|mut fact| {
|
||||||
|
fact.source = L1Source::Claude {
|
||||||
|
entries: entries.clone(),
|
||||||
|
};
|
||||||
|
fact
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (scope, expected) in [
|
||||||
|
(
|
||||||
|
"User config (available in all your projects)",
|
||||||
|
CellState::Ready,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Local config (private to you in this project)",
|
||||||
|
CellState::Fail,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
runner.claude_get = claude_get_output(scope, &url);
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&facts,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
let f = cell(&report, ClientId::Claude, Check::F);
|
||||||
|
assert_eq!(f.state, expected, "{scope}: {f:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_effective_entry_elsewhere_with_project_table_is_untrusted() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let url = format!("http://127.0.0.1:9/mcp/projects/{PID}");
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
// Codex 生效的是用户级全局条目,项目表却指向本项目地址:项目配置没加载
|
||||||
|
runner.codex_get.0 = runner.codex_get.0.replace(&url, "http://127.0.0.1:9/mcp");
|
||||||
|
let codex_only: Vec<L1ClientFacts> = fx
|
||||||
|
.facts(&url, &fx.good_shim)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|fact| fact.client == ClientId::Codex)
|
||||||
|
.collect();
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&codex_only,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
let c = cell(&report, ClientId::Codex, Check::C);
|
||||||
|
assert_eq!(c.cause, Some(Cause::CodexProjectUntrusted), "{c:?}");
|
||||||
|
assert!(c.detail.contains("信任"), "{c:?}");
|
||||||
|
|
||||||
|
// 项目里没有该表:仍是 route_global(重跑 inject_mcp 写项目地址)
|
||||||
|
let mut no_table = codex_only.clone();
|
||||||
|
no_table[0].source = L1Source::Codex {
|
||||||
|
project_entry: false,
|
||||||
|
project_url: None,
|
||||||
|
project_helper: None,
|
||||||
|
};
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&no_table,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Codex, Check::C).cause,
|
||||||
|
Some(Cause::RouteGlobal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_tool_filters_hide_tools_from_the_session_view() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let stub = StubServer::start(&fx.token, catalog_names());
|
||||||
|
let url = stub.url();
|
||||||
|
let codex_only: Vec<L1ClientFacts> = fx
|
||||||
|
.facts(&url, &fx.good_shim)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|fact| fact.client == ClientId::Codex)
|
||||||
|
.collect();
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
runner.codex_get.0 = runner.codex_get.0.replace(
|
||||||
|
"\"disabled_tools\":null",
|
||||||
|
"\"disabled_tools\":[\"read_project_test_account\"]",
|
||||||
|
);
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&codex_only,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
for check in [Check::C, Check::F] {
|
||||||
|
let got = cell(&report, ClientId::Codex, check);
|
||||||
|
assert_eq!(got.cause, Some(Cause::ToolsMismatch), "{check:?}: {got:?}");
|
||||||
|
assert_eq!(got.evidence, EvidenceLevel::ClientIntrospection);
|
||||||
|
}
|
||||||
|
assert!(cell(&report, ClientId::Codex, Check::C)
|
||||||
|
.detail
|
||||||
|
.contains("read_project_test_account"));
|
||||||
|
for check in [Check::D, Check::E] {
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Codex, check).state,
|
||||||
|
CellState::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单只放 get_diagnostics:E、F 在 Codex 会话里不可见
|
||||||
|
let mut runner = FakeRunner::ready(&url);
|
||||||
|
runner.codex_get.0 = runner.codex_get.0.replace(
|
||||||
|
"\"enabled_tools\":null",
|
||||||
|
"\"enabled_tools\":[\"get_diagnostics\"]",
|
||||||
|
);
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&codex_only,
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Codex, Check::D).state,
|
||||||
|
CellState::Ready
|
||||||
|
);
|
||||||
|
for check in [Check::C, Check::E, Check::F] {
|
||||||
|
assert_eq!(
|
||||||
|
cell(&report, ClientId::Codex, check).cause,
|
||||||
|
Some(Cause::ToolsMismatch),
|
||||||
|
"{check:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silent_server_read_timeout_is_timeout_not_tool_error() {
|
||||||
|
// 只进 backlog、从不响应的端口:Windows 上 socket 读超时是 WSAETIMEDOUT,文本为本地化系统消息
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let url = format!("http://127.0.0.1:{port}/mcp/projects/{PID}");
|
||||||
|
let started = Instant::now();
|
||||||
|
let failure = UreqHttp
|
||||||
|
.post(&url, &[], &json!({}), Duration::from_millis(800))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(failure, HttpFailure::Timeout);
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(3));
|
||||||
|
|
||||||
|
// 端到端:三端 C–F 记 timeout(环境类),不是 tool_error(投影类)
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let runner = FakeRunner::ready(&url);
|
||||||
|
let report = run(
|
||||||
|
&fx.project(&url),
|
||||||
|
&fx.facts(&url, &fx.good_shim),
|
||||||
|
&runner,
|
||||||
|
true,
|
||||||
|
Duration::from_millis(2500),
|
||||||
|
);
|
||||||
|
for client in ALL {
|
||||||
|
for check in [Check::C, Check::D, Check::E, Check::F] {
|
||||||
|
let got = cell(&report, client, check);
|
||||||
|
assert_eq!(
|
||||||
|
got.state,
|
||||||
|
CellState::Timeout,
|
||||||
|
"{client:?} {check:?}: {got:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(got.cause, Some(Cause::Timeout));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_need_text_is_bound_to_the_real_read_test_account() {
|
||||||
|
// F 的「认证通过」判据是 read_project_test_account 对未登记需求名的报错文案:在内存库上调用真实本体绑定,
|
||||||
|
// 文案一改这里就红(否则生产上三端 F 全变 unknown(tool_error) 而单测照过)
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id,profile_id) VALUES (?1,'profile-1')",
|
||||||
|
[PID],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = crate::commands::dev_variables::read_test_account(
|
||||||
|
&conn,
|
||||||
|
&crate::credential_store::MemoryCredentialStore::new(),
|
||||||
|
PID,
|
||||||
|
&new_nonce(),
|
||||||
|
)
|
||||||
|
.err()
|
||||||
|
.expect("未登记的需求名应报错");
|
||||||
|
assert!(error.contains(UNREGISTERED_NEED), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 生产执行器(真实 cmd.exe,不起任何客户端)────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_spec(command_line: &str, timeout: Duration) -> ProcessSpec {
|
||||||
|
ProcessSpec {
|
||||||
|
program: system_cmd_exe(),
|
||||||
|
args: vec!["/d".into(), "/s".into(), "/c".into()],
|
||||||
|
raw_tail: Some(format!("\"{command_line}\"")),
|
||||||
|
cwd: None,
|
||||||
|
env_remove: Vec::new(),
|
||||||
|
env_set: Vec::new(),
|
||||||
|
timeout,
|
||||||
|
stop_at_stdout_line: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_runner_times_out_on_flooding_child_and_stops_at_marker() {
|
||||||
|
// 持续刷行的子进程(如被篡改的 helper):收到行也必须检查超时,否则 run_l1 永不返回
|
||||||
|
let started = Instant::now();
|
||||||
|
let flood = SystemRunner
|
||||||
|
.run(&cmd_spec(
|
||||||
|
"for /l %i in (1,1,400000) do @echo x",
|
||||||
|
Duration::from_secs(1),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
assert!(flood.timed_out, "{flood:?}");
|
||||||
|
assert!(flood.stdout.starts_with('x'));
|
||||||
|
assert!(elapsed < Duration::from_secs(2), "耗时 {elapsed:?}");
|
||||||
|
|
||||||
|
// 命中停止标记即结束(后面是不输出的死循环)
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut spec = cmd_spec(
|
||||||
|
"echo before& echo MARKER-LINE& for /l %i in (1,1,500000) do @type nul",
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
spec.stop_at_stdout_line = Some("MARKER".into());
|
||||||
|
let stopped = SystemRunner.run(&spec).unwrap();
|
||||||
|
assert!(stopped.stopped_early && !stopped.timed_out, "{stopped:?}");
|
||||||
|
assert!(stopped.stdout.contains("before") && stopped.stdout.contains("MARKER-LINE"));
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_runner_removes_inherited_env_by_name_and_prefix() {
|
||||||
|
std::env::set_var("LJ_P4A_RM_PREFIXED", "1");
|
||||||
|
std::env::set_var("LJ_P4A_EXACT", "1");
|
||||||
|
std::env::set_var("LJ_P4A_EXACTLY", "1");
|
||||||
|
let mut spec = cmd_spec("set LJ_P4A", Duration::from_secs(10));
|
||||||
|
// 与 client_spec 同一写法:以 _ 结尾的项按前缀去掉(CLAUDE_CODE_*),其余按全名
|
||||||
|
spec.env_remove = vec!["LJ_P4A_RM_".into(), "LJ_P4A_EXACT".into()];
|
||||||
|
spec.env_set = vec![("LJ_P4A_SET".into(), "yes".into())];
|
||||||
|
let output = SystemRunner.run(&spec).unwrap();
|
||||||
|
for name in ["LJ_P4A_RM_PREFIXED", "LJ_P4A_EXACTLY", "LJ_P4A_EXACT"] {
|
||||||
|
std::env::remove_var(name);
|
||||||
|
}
|
||||||
|
assert_eq!(output.exit_code, Some(0), "{output:?}");
|
||||||
|
assert!(
|
||||||
|
!output.stdout.contains("LJ_P4A_RM_PREFIXED"),
|
||||||
|
"{}",
|
||||||
|
output.stdout
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.stdout.contains("LJ_P4A_EXACT="),
|
||||||
|
"{}",
|
||||||
|
output.stdout
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.stdout.contains("LJ_P4A_EXACTLY=1"),
|
||||||
|
"{}",
|
||||||
|
output.stdout
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.stdout.contains("LJ_P4A_SET=yes"),
|
||||||
|
"{}",
|
||||||
|
output.stdout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 客户端输出解析(P4-0 真实夹具)──────────────────────────────────────────
|
// ── 客户端输出解析(P4-0 真实夹具)──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user