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>
This commit is contained in:
lanrtop 2026-10-03 13:40:00 +09:00
parent 5b804bb967
commit f0237da3c3
8 changed files with 1080 additions and 127 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/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 上下文文档(速查卡、契约映射)

View File

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

View File

@ -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"));
} }
/// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端): /// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端):

View File

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

View File

@ -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": {

View File

@ -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:?}")),
}), }),
} }

View File

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