diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 42a5360..3060d2c 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -136,12 +136,19 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - verification: "2026-10-03 主会话后台实测,零凭据(全部连本地桩服务,桩只记方法、工具名、clientInfo、UA):Claude Code 2.1.173、codex-cli 0.155.0-alpha.9.2、DeepSeek Harness 桌面版内置运行时。每臂独立桩端口与日志,调用归属不靠时间窗。脱敏夹具入库 src-tauri/tests/fixtures/client_probe/(10 个文件:claude-init-badmodel、claude-narrow、claude-bypass-allowedtools、codex-ro-read/ro-write/ro-slow、codex-mcp-get-trusted/untrusted、dsh-read)——用户路径、会话 ID、UUID 换占位符,长文本截断到 200 字符,grep 无用户名、无 Bearer、无 64 位十六进制串。原始日志在本机 %TEMP%\\lj-p4-0(未入库)。" - known_limits: "只验了无头 CLI;Codex 首轮四臂因脚本误加 -c mcp_servers.cua_repl.enabled=false(cua_repl 不在用户配置里,生成了缺 transport 的条目,配置加载失败)全部未运行,第二轮去掉后重跑。Claude 收窄组合只用桩工具验证,接 lian-jing 时只读工具白名单须取自 tools_list_result_for_project 中 readOnlyHint=true 的工具。实验附带的流程问题:前两轮脚本在脚本文件里用 kill 结束自己启动的桩进程,等于绕过了用户 deny 规则 Bash(kill *);之后桩服务改为到时自动退出,agent 会话里的实验脚本不再用 kill。" -### 📋 P4a — L1 零模型三端检查 -- status: todo +### 🔵 P4a — L1 零模型三端检查 +- status: in_progress - complexity: M -- files: src-tauri/src/onboard/probe_l1.rs, src-tauri/src/onboard/clients.rs, src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/onboarding.rs, src/lib/commands.ts +- files: src-tauri/src/onboard/probe_l1.rs, src-tauri/src/onboard/clients.rs, src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/onboarding.rs, src/lib/commands.ts(实际另含 src-tauri/src/onboard/probe_l1/tests.rs、src-tauri/src/onboard/mod.rs、src-tauri/src/mcp_inject.rs(三端 l1_facts、本体入口、本机定位)、src-tauri/src/lib.rs、docs/ai-context/project-context.yaml) - depends: P4-0, P8b - acceptance: "agent_verify: [cargo test onboard::probe_l1] 进程内桩 MCP 服务 + 哑 shim:适配器取配置、按客户端方式执行 helper,再依次 initialize、tools/list、tools/call,C/D/E/F 均 ready 且每格带证据等级;shim 输出错误 token 记 auth_failed,shim 缺失记 shim_missing,端口不可达记 unreachable,客户端未安装记 not_installed(不计失败);期望工具集取自 tools_list_result_for_project,不写死数字;结果 JSON 与日志不含桩 token。mcp_server 识别 lian-jing-l1-probe 并排除出 has_initialized_project_client。[MCP 全局] probe_client_parity(enterprise, tier=l1) 10 秒内返回,三端适用格 ready 并附版本号;对 dev-manager-tauri 调用时 Claude 与 DSH 记 not_applicable(control_plane)。" +- 实现(2026-10-03,分支 feat/agent-client-parity-p4a):新增 src-tauri/src/onboard/probe_l1.rs(本体,受 onboard 适应度测试约束)与 probe_l1/tests.rs。三端并行、整次预算 9 秒,每步取「步骤上限与剩余预算」的较小值,耗尽记 timeout。进程、HTTP、客户端定位经 ProcessRunner / McpHttp / ClientLocator 注入(另注入 %VAR% 展开),生产实现 SystemRunner(不弹窗、stdin 置空、去掉 CLAUDECODE 与 CLAUDE_CODE_*、超时或命中停止标记即结束直接子进程)与 UreqHttp。各端证据:① Claude:项目目录执行 claude mcp get lian-jing 取实际生效条目(Claude 自己的回答为准,不复刻项目键推导);再以该条目作唯一 --mcp-config(--strict-mcp-config)运行 claude -p --model lj-no-such-model --no-session-persistence --output-format stream-json --verbose,读到 system/init 即结束,取版本、lian-jing 连接状态、工具名与 skills(B 升为 client_introspection)。headersHelper 不在 mcp get 输出里,按 URL 回查 ~/.claude.json 覆盖本根目录的条目。② Codex:codex --version 与 codex mcp get lian-jing --json;看不到条目而项目配置有该表 → codex_project_untrusted(H-P8-1)。Codex 输出会把 http_headers_helper 脱敏成 \"\",重放用的 helper 从项目 .codex/config.toml 同名表回查(与 P3 托管块写法往返测试)。③ DSH:白名单复用 P8b dsh_static_outcome;会话级插件核对托管块引用当前版本条目且文件 sha256 与内置源一致(忽略行尾);版本读安装包 app.asar 的 package.json,不起进程。认证链重放:Claude/Codex 把配置里的 helper 交给 cmd.exe /d /s /c(原样拼接,%VAR% 由 cmd 展开),DSH 按托管块 !!js 的写法执行 \"\"\" \";helper 入口先展开 %VAR% 判存在,不存在记 shim_missing、不起 cmd;Claude/Codex 取不到认证头时 C–F 同记失败(连接不建立),DSH 按其 !!js 的 catch 降级为不带认证连接,只有 F 失败。随后在该端配置的地址上 initialize(clientInfo=lian-jing-l1-probe)→ tools/list(与 tools_list_result_for_project 比对)→ get_diagnostics(D)→ list_project_dev_variables(E)→ read_project_test_account(name=LJ_PROBE_<8 位随机>)(F:返回「开发需求未登记」即认证通过;F 的结果文本一律不进报告)。认证头只存在于重放函数栈上,报告文本另经 scrub 兜底。适配器注册表 ClientAdapter 新增 l1_facts(三端实现在 mcp_inject;不适用时不读客户端配置);本体入口 mcp_inject::probe_client_parity(project_id, tier)(tier 校验在最前,不查登记、不起进程)。claude mcp get 的解析合并为一份(mcp_inject 的同步逻辑改调 probe_l1::parse_claude_mcp_get),has_manual_codex_entry 改调 probe_l1::codex_has_table。投影:MCP probe_client_parity(只在全局路由,readOnly/idempotent,structuredContent + 一行摘要,schema 单独成函数);Tauri async command probe_client_parity(spawn_blocking)并在 lib.rs 注册;commands.ts 补 ClientParityL1Report 等类型与 probeClientParity。mcp_server:clientInfo.name 为 lian-jing-l1-probe 的 initialize 与 lian-jing-readiness 一样不计入 has_initialized_project_client。T1:docs/ai-context/project-context.yaml 的 MCP 工具接口与 Tauri command 契约补述。 +- 证据矩阵结构(L1Report):project_id、tier=l1、probe_spec_version=1、profile(project / control_plane)、expected_route、expected_tools(自省值)、nonce、clients[{client, version, route(该端实际会连的地址), cells{A..F: {state, cause?, evidence, detail}}, duration_ms}]、all_applicable_ready、duration_ms、note。state ∈ ready / fail / unknown / not_installed / not_applicable / blocked / timeout;evidence ∈ static < client_introspection < protocol(< session_event / server_trace,留给 L3 与服务端轨迹);cause 按环境(timeout、unreachable、client_error)→ 投影(not_configured、route_global、route_mismatch、not_connected、codex_project_untrusted、server_disabled、manual_config、rules_not_loaded、skill_not_visible、session_plugin_mismatch、shim_missing、helper_failed、auth_header_missing、auth_failed、tools_mismatch、tool_error)→ 适配器(adapter_drift)归因。C 的证据:Claude 在 init 自省与协议重放都通过时记 protocol;重放失败用重放的(更具体)归因,重放通过而 Claude 自己没连上或工具不符时记 client_introspection 的失败。 +- verification: "2026-10-03:全量 cargo test 349 passed / 0 failed / 6 ignored(新增 24 条 + 1 条 ignored 实机冒烟),编译 0 warning;pnpm typecheck 通过。onboard::probe_l1 20 条:进程内桩 MCP 服务(std TcpListener,只记方法/工具/clientInfo/认证是否匹配,60 秒自动退出)+ 真实 cmd.exe 执行的哑 shim + 假 claude/codex:三端 C–F ready 且 C–F 为 protocol、Claude 的 B 为 client_introspection、三端附版本、桩只收到 lian-jing-l1-probe 的 initialize 且 F 均带对认证、报告 JSON 与进程参数不含桩 token、Claude init 参数与子进程卫生(坏模型名、strict、no-session-persistence、MCP_CONNECTION_NONBLOCKING=false、git-bash、去 CLAUDECODE/CLAUDE_CODE_*、cwd、停止标记);错误 token → 三端 F auth_failed、C/D/E ready;shim 缺失 → Claude/Codex C–F shim_missing、DSH 只有 F shim_missing、不起 cmd;端口不可达 → C–F unreachable;未安装 → 全部 not_installed、零进程、不计失败;控制面 → Claude/DSH 整列 not_applicable(control_plane)、零 claude 进程、Codex 照常;Codex 看不到条目 → codex_project_untrusted / not_configured;Claude 生效全局条目 → route_global 且不起 init;工具集漂移 → tools_mismatch(期望取 tools_list_result_for_project);Codex 无 helper → F auth_header_missing;预算耗尽 → timeout、零进程;helper 非零退出或输出非 JSON(输出里带 token)→ helper_failed 且 token 不进报告;Claude init 视角覆盖 C、skills 缺失判 B、init 缺失时注明;DSH 会话插件不符判 C、白名单缺失判 route_global;P4-0 夹具解析(claude-init-badmodel 含 CRLF、codex-mcp-get-trusted/untrusted)、claude mcp get 文本、%VAR% 展开、TOML helper 回查、静态 A/B、合成 asar 版本。mcp_inject 2 条(l1_facts 控制面/WSL 不适用且不读客户端配置、Codex helper 与托管块写法逐字往返;Claude 候选条目按键的具体程度排序);mcp_tools 2 条(probe_client_parity 只在全局目录、readOnly、required 只有 project_id、tier 只有 l1、描述不含敏感读取工具名、项目路由调用得 scope_violation、tier=l3 在本体最前被拒;摘要列出版本与归因);mcp_server 既有 readiness 测试补 l1-probe 断言。变异(先 git add 暂存,改后跑测、git checkout 还原):去掉 mcp_server 的探针排除 → readiness 测试失败;403 归因改成 tool_error → 错误 token 测试失败;去掉 shim 存在预判 → shim 缺失测试失败;tools_diff 恒为一致 → 工具集漂移测试失败;DSH 改为 helper 失败即中止 → shim 缺失测试失败(审查 ⑨ 更正:原记「与预算耗尽两条失败」有误——预算为 0 时 run_helper 在起 cmd 前返回 TimedOut,中止与降级两条路径都得到全 timeout,预算耗尽测试不受该变异影响;按代码推演,另一条失败的应是 helper 失败用例(断言 DSH 的 D 为 ready),本次未重跑);把认证头写进 D 的 detail 时 scrub 能兜住(测试仍过),再去掉 scrub → 「结果 JSON 含桩 token」失败;l1_facts 不再判控制面 → mcp_inject l1_facts 测试失败;Claude C 不再采用 init 视角 → init 视角测试失败。实机冒烟(#[ignore] 的 l1_live_probe_against_running_lianjing,后台 bash、运行中的炼境,未经 MCP 投影):enterprise 三端 18 格全部 ready(Claude B 为 client_introspection、C–F 为 protocol),附版本 claude 2.1.90 / codex 0.155.0-alpha.9.2 / dsh 0.2.0-rc.2,1.35–1.64 秒;dev-manager-tauri(控制面)Claude、DSH 整列 not_applicable(control_plane)、Codex 6 格 ready,2.8 秒;未接入的 LiteLLM 归因为 Claude route_global(生效全局条目)、Codex not_configured、DSH route_global、A/B rules_not_loaded/skill_not_visible,0.5 秒;三份输出均无 Bearer 与 64 位十六进制串。" +- known_limits: "① Claude 定位沿用 P1 起的 find_claude_bin:优先 %USERPROFILE%\\.local\\bin\\claude.exe(本机 2.1.90),而用户 PATH 上 pnpm 安装的 claude 是 2.1.173——L1 报告的是炼境实际调用的那一个(注入同用它),与用户终端里的 claude 可能不同;定位顺序影响注入,本卡未改。② Claude init 只挂 claude mcp get 选出的生效条目(--strict-mcp-config):自省的是「Claude 用这条条目能否连上、看到哪些工具与 skills」,不是一次完整的项目配置加载;全量配置下 init 要等 5–6 秒无关 MCP 服务器、自然结束要 13–14 秒(要等一次必然失败的模型请求),超出 10 秒预算。③ 读到 init 即结束只终止直接子进程:只挂 HTTP 条目所以没有 stdio 子进程,但超时时孙进程可能残留(Job Object 归 P4d)。④ Codex 的 helper 被 Codex 脱敏,重放用项目 .codex/config.toml 的同名表;生效配置若来自用户级 ~/.codex/config.toml 记 unknown(not_configured)。Codex 执行 helper 时的环境白名单(H4 env_clear)未复刻,重放继承炼境进程环境;Claude 的 helper 失败按「连接不建立」处理是保守假设(H4 只实测了 Codex)。⑤ codex 只认 CODEX_BIN、Codex 桌面版自带的 %LOCALAPPDATA%\\OpenAI\\Codex\\bin\\<哈希>\\codex.exe(取最新)与 PATH 中的 codex.exe,npm 全局安装的 codex.cmd 记 not_installed。⑥ DSH 没有零模型自省:C 的路由是静态判定(白名单),D–F 为协议重放;桌面 GUI 会话只核对插件哈希,未实测会话。⑦ 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连项目地址,服务端无法与真实会话区分:检查期间(≤9 秒)服务端对该项目路由上的 initialize 一律不计入 has_initialized_project_client(审查 ③ 后的时间窗静默),恰好在此期间新开的真实会话也不计入,reload_required 可能多提示一次重启(宁可误报,不漏报);静默只在炼境进程内有效,实机冒烟测试进程里调用不影响运行中的炼境。⑧ A 只判规则文件存在(Claude 另要求 CLAUDE.md 引用 @AGENTS.md),版本归 verify_onboarding;B 对 Codex/DSH 只有静态证据。⑨ [MCP 全局] 两项 acceptance 须在合并、运行中的炼境重编译后实测,本卡保持 🔵。⑩ claude -p 在项目目录启动仍会更新 ~/.claude.json(启动计数;从未打开过的目录新增 projects 键),参数关不掉;hooks 已用 --settings {\"disableAllHooks\":true} 关闭(实测 2.1.90/2.1.173 的项目 hooks),托管策略(managed settings)里的 hooks 是否同样被关闭未验证。⑪ 只执行炼境托管写法的 helper(当前 %USERPROFILE% 或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest):手写条目与更早的本机 exe 路径写法记 unknown(manual_config),L1 不替它们重放认证链。⑫ 静默守卫在 mcp_inject::probe_client_parity_at 的接线(一行)没有单测覆盖,守卫本身由 mcp_server 测试覆盖。" +- 有意偏差:① 卡片与设计写 Claude 自省用 claude mcp get,派单写以坏模型名 init 为主——两者都做:mcp get 定生效地址,init 取 skills、工具与连接状态;init 加 --strict-mcp-config 只挂生效条目、读到 init 即止(理由见 known_limits ②)。② 格子状态用卡片口径 ready,而非设计 §6 的 pass;auth_failed、shim_missing、unreachable 等是 cause(state 为 fail),not_installed、not_applicable 是 state。③ probe_client_parity 只开放全局路由(与 P4e 的 start_parity_probe 同口径;项目路由的只读 get_client_parity 归 P4e)。④ 卡片未列、新增两个归因:helper_failed(helper 非零退出或输出不是 JSON 请求头)与 auth_header_missing(配置里没有 helper)。⑤ 文件比卡片多 src-tauri/src/lib.rs(注册 Tauri command)与 docs/ai-context/project-context.yaml(T1)。 +- review: "2026-10-03 独立只读审查 5b804bb,结论 fix_then_merge,11 条发现处置:① [中] SystemRunner 收到 stdout 行即 continue,跳过 try_wait 与超时检查,持续刷行的子进程永不超时 → 已修:每轮按剩余时间计算接收等待(≤50 毫秒),收到行后同样检查退出与超时;单行读取与 stderr 留存都限 MAX_CAPTURE。补 system_runner_times_out_on_flooding_child_and_stops_at_marker(真实 cmd 持续刷行、1 秒超时 → timed_out 且耗时 < 2 秒;刷行循环有界(40 万行,约 10 秒),回归时测试失败而不是挂死;另验停止标记)与 system_runner_removes_inherited_env_by_name_and_prefix(env_remove 的前缀与全名两种写法、env_set)。② [中] 读超时靠英文子串判定,Windows 上 WSAETIMEDOUT 是本地化消息,记成 tool_error → 已修:is_timeout_error 沿 source 链找 io::Error,kind 为 TimedOut/WouldBlock 即 Timeout;正文读超时(into_json)同样记 Timeout;ConnectionFailed/Dns 仍记 unreachable。补 silent_server_read_timeout_is_timeout_not_tool_error(只进 backlog、不响应的端口:UreqHttp 直接得 Timeout;三端 C–F 记 timeout(timeout))。③ [中] 探针的 Claude 自省把项目记成已有真实客户端,抹掉 reload_required → 已修,口径为时间窗静默:mcp_server 新增 quiet_project_initializes 守卫(按项目计数,并发检查先结束的不提前解除,只作用于被检查项目),probe_client_parity_at 在 run_l1 期间持有,期间该项目路由上的 initialize 一律不计入 has_initialized_project_client。未采用审查建议的标记头:实测 Claude 会把静态 headers 与 headersHelper 合并(标记头对 init 可行),但 claude mcp get 的健康检查无法打标,单靠标记头修不全,时间窗一并覆盖两者;代价写入 known_limits ⑦。补 l1_quiet_window_hides_claude_self_introspection_from_reload_evidence。④ [低] 测试假阳性 → 第一处同 ①;第二处已修:unregistered_need_text_is_bound_to_the_real_read_test_account 在内存库上调用真实 commands::dev_variables::read_test_account(new_nonce()),断言报错含 UNREGISTERED_NEED(nonce 生成抽成 new_nonce,顺带绑定「nonce 能通过开发变量名校验」);没有改成从 dev_variables 导出常量,是为了不让 onboard 本体再新增对 commands 层的依赖(P8c known_limits ①)。⑤ [低] Codex 未采用 enabled_tools/disabled_tools → 已修:解析两个字段,期望工具被过滤时 C 记 tools_mismatch(client_introspection),D/E/F 对应工具被过滤时同记;C 为 ready 时 detail 注明 tools/list 只是服务端视角。补 codex_tool_filters_hide_tools_from_the_session_view(黑名单 read_project_test_account → C、F 失败;白名单只放 get_diagnostics → C、E、F 失败)。⑥ [低] 归因边角 → (a) 已修:claude_l1_entries 按 URL 在全部项目键(覆盖本根目录者在前、越具体越前,其余键随后,覆盖 worktree 与 NFC 键)、项目 .mcp.json(project)、顶层 mcpServers(user)中回查,条目带作用域标签,probe_claude 优先取与 claude mcp get 报告的作用域一致的那条;补 mcp_inject claude_l1_entries_match_project_url_in_every_scope(替换原 claude_entries_covering_root_prefer_most_specific_key)与 probe_l1 claude_helper_is_looked_up_in_the_scope_claude_reports。(b) 已修:L1Source::Codex 新增 project_url,项目表存在且与 Codex 生效地址不同 → codex_project_untrusted(detail 写明多半来自用户级 ~/.codex),不再建议重跑 inject_mcp;补 codex_effective_entry_elsewhere_with_project_table_is_untrusted。(c) 已修:helper 输出中没有 Authorization(如 {})→ F 记 auth_header_missing;补 helper_without_authorization_header_is_auth_header_missing(三端)。⑦ [低] claude -p 的 hooks 与 ~/.claude.json 副作用 → hooks 已修:init 加 --settings {\"disableAllHooks\":true},测试断言该参数。实测(2026-10-03,%TEMP%\\lj-p4a-fix,零凭据桩服务、坏模型名,claude 自然退出,不结束任何进程):2.1.90 与 2.1.173 都接受该参数,init 照常发出(桩 connected,0.7–1.7 秒),total_cost_usd=0;项目 .claude/settings.json 的 SessionStart、UserPromptSubmit hook 不加参数时执行,加上后不执行;Claude 的 clientInfo.name 为 claude-code。~/.claude.json 写入关不掉,记入 known_limits ⑩。⑧ [低] 安全加固 → 第一项已修:init 的 --mcp-config 只带 type、url、headersHelper(测试夹具给 Claude 条目加了带桩 token 的静态认证头,断言进程参数不含 token、条目键只有这三个)。第二项已修:L1Project 新增 managed_helpers(当前 %USERPROFILE% 稳定入口与旧 %LOCALAPPDATA% 入口,均带本项目 digest),Claude/Codex 配置里的 helper 逐字等于其一才执行,否则 C–F 记 unknown(manual_config),Claude 也不起零模型 init(init 会让 Claude 自己执行该 helper);更早的本机 exe 路径写法同样记 manual_config。补 unmanaged_helper_is_manual_config_and_never_executed(只起 DSH 的一个 cmd、不起 init)与 mcp_inject managed_helpers_cover_current_and_legacy_shim_forms。⑨ [信息] 变异记录不准 → 复核成立,verification 已更正(见该字段 DSH 一句)。⑩ [信息] Claude 未连上时 D–F 仍为 ready → 已修:Claude 明确未连上(init 状态不是 connected 或 mcp_servers 里没有 lian-jing;取不到 init 时退用 claude mcp get 的 Status 行,只采信「未连接」)时,D–F 中协议重放为 ready 的格改记 blocked(not_connected, client_introspection),detail 注明服务端视角通过;Claude 已连上但 init 的工具名里没有 D/E/F 对应工具时,该格记 tools_mismatch。ClaudeMcpGet.connected 用作取不到 init 时的退路。改断言报备:claude_init_view_overrides_c_when_replay_passes 中「Claude 未连上时 F 为 ready」改为「D–F 为 blocked(not_connected)」——这是审查要求的判定口径变化,不是为了让实现通过测试;该用例另补两段(工具缺失、mcp get 退路)。⑪ [信息] 定位器加在存量 mcp_inject、AGENTS.md 未提 L1 → 定位器本卡不迁移(已经 trait 注入,P4d 移到 onboard);AGENTS.md 架构速查 onboard 行补上 plan 与 probe_l1。project_root 在函数内取连接池,与 client_static_status 等本体入口写法一致(入口层),未改。T1:probe_client_parity 工具描述(状态补 blocked、只执行托管 helper、init 关 hooks)与 project-context.yaml 同步。修复后全量 cargo test 360 passed / 0 failed / 6 ignored(比 349 多 11 条),编译 0 warning。之后把两条 SystemRunner 测试里的 cmd 死循环改成有界循环(防回归或变异时测试挂死、留下孤儿 cmd),这一改动之后没能重跑。变异验证本轮未做:按 §2 先 git add 暂存、再改源码注入变异时,Claude Code auto mode 安全分类器以 Security Weaken 拦截(命令未执行,git diff 确认工作区没有变异残留);随后 git fetch、cargo test、查看 node_modules 也被拦截,按授权规则停下,未绕过。" +- 待办:本轮被安全分类器拦截后停下,改动已 git add 暂存、未提交、未推送、未开 PR。接手者:重跑全量 cargo test 与 pnpm typecheck(commands.ts 本轮未改)→ fetch 并合并 origin/master 后重测 → 提交推送 feat/agent-client-parity-p4a → 开 PR;合并后统筹会话在重编译的炼境上实测 [MCP 全局] 两项,通过后标 ✅。 ### 📋 P4b — MCP 服务端调用轨迹环 - status: todo diff --git a/AGENTS.md b/AGENTS.md index dc5e608..16c3da7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具 src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口) -src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) +src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 710d1c7..07cbce0 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -17,8 +17,9 @@ contracts: - "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" + - "src-tauri/src/onboard/probe_l1.rs" - "src-tauri/src/commands/projects.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)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。" + 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)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。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_);返回 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: 被管理项目身份上下文 paths: @@ -33,7 +34,7 @@ contracts: paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report)。" architecture_docs: - "AGENTS.md" diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 8f322f8..9207dec 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -515,6 +515,20 @@ pub fn apply_onboarding_pack( }) } +/// 三端对等 L1 零模型检查(agent-client-parity P4a)的 UI 投影,与 MCP probe_client_parity 同一本体。 +/// 会起零模型的客户端自省进程并回调本机 MCP 服务(约 5–10 秒),放到阻塞线程池执行,不占界面线程。 +#[tauri::command] +pub async fn probe_client_parity( + project_id: String, + tier: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::mcp_inject::probe_client_parity(&project_id, tier.as_deref()) + }) + .await + .map_err(|error| format!("L1 检查线程异常: {error}"))? +} + // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── /// 单项验收结果。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db4e3bb..f158ccf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -442,6 +442,7 @@ pub fn run() { list_buffed_projects, // onboarding pack commands::onboarding::apply_onboarding_pack, + commands::onboarding::probe_client_parity, // agent infrastructure commands::agent_infra::scan_agent_infra_stack, commands::agent_infra::generate_agent_infra_files, diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index bc6220d..1e0b7e5 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -2,6 +2,9 @@ use crate::onboard::clients::{ ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason, ProjectFacts, }; +use crate::onboard::probe_l1::{ + self, HelperCommand, L1ClientFacts, L1Source, Located, StaticFinding, +}; use crate::{db, db::log_event}; use serde_json::{json, Value}; use std::path::PathBuf; @@ -102,6 +105,11 @@ fn shim_helper_command(digest: &str) -> String { format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") } +/// 迁移期旧写法(%LOCALAPPDATA% 入口,ensure_header_shim 仍同步写入):L1 视同托管写法、照常执行。 +fn legacy_shim_helper_command(digest: &str) -> String { + format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") +} + // ── 路径解析 ────────────────────────────────────────────────────────────────── /// 校验并解析登记路径:空串/空白/相对路径一律拒绝。 @@ -637,22 +645,13 @@ enum ClaudeEffective { } /// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。 +/// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。 fn parse_claude_mcp_get(output: &str) -> Option { - if output.contains("No MCP server found") { - return Some(ClaudeEffective::NotLocal); - } - let field = |name: &str| { - output.lines().find_map(|line| { - line.trim() - .strip_prefix(name) - .map(|value| value.trim().to_string()) - }) - }; - let scope = field("Scope:")?; - if scope.starts_with("Local config") { - Some(ClaudeEffective::Local(field("URL:")?)) - } else { - Some(ClaudeEffective::NotLocal) + use crate::onboard::probe_l1::ClaudeScope; + let parsed = crate::onboard::probe_l1::parse_claude_mcp_get(output)?; + match parsed.scope { + ClaudeScope::Local => Some(ClaudeEffective::Local(parsed.url?)), + ClaudeScope::Other(_) | ClaudeScope::Absent => Some(ClaudeEffective::NotLocal), } } @@ -1438,8 +1437,7 @@ fn render_codex_block(server_url: &str, header_helper: &str) -> Result bool { - text.lines() - .any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) + probe_l1::codex_has_table(text) } /// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定 @@ -2063,6 +2061,104 @@ impl ClientAdapter for ClaudeAdapter { sync_claude_local(project.project_id, project.root, wanted), ) } + + /// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。 + fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts { + let not_applicable = l1_not_applicable(project); + let entries = match not_applicable { + Some(_) => Vec::new(), + None => { + let mcp_json = read_optional(&project.root.join(".mcp.json")) + .ok() + .flatten() + .and_then(|text| { + serde_json::from_str::(text.trim_start_matches('\u{feff}')).ok() + }); + claude_l1_entries( + &read_claude_config().unwrap_or(Value::Null), + mcp_json.as_ref(), + project.root, + &desired.server_url, + ) + } + }; + L1ClientFacts { + client: ClientId::Claude, + not_applicable, + rules: probe_l1::rules_finding(project.root, "CLAUDE.md", true), + skill: probe_l1::skill_finding(project.root, ".claude/skills/lian-jing/SKILL.md"), + source: L1Source::Claude { entries }, + } + } +} + +/// Claude 与 DSH 的 L1 不适用情形(与各自 static_status 同口径):WSL 路径项目不支持;控制面项目走全局地址。 +fn l1_not_applicable(project: &ProjectFacts) -> Option { + if project.wsl { + Some(NotApplicableReason::Wsl) + } else if project.control_plane { + Some(NotApplicableReason::ControlPlane) + } else { + None + } +} + +/// L1 回查 headersHelper 的候选条目:URL 等于本项目地址的 lian-jing 条目(含手写),按 URL 匹配、不复刻 +/// Claude 的项目键推导(与 managed_claude_entries 同一原则:worktree 映射到主仓库根、NFC 归一化的键也能找到)。 +/// 顺序:覆盖本根目录的项目键(最具体在前)→ 其余项目键 → 项目 .mcp.json(project 作用域)→ 顶层 mcpServers +/// (user 作用域)。生效哪一条由 claude mcp get 报告的作用域决定,顺序只是兜底。 +fn claude_l1_entries( + config: &Value, + mcp_json: Option<&Value>, + root: &std::path::Path, + url: &str, +) -> Vec { + use probe_l1::{ClaudeEntry, ClaudeEntrySource}; + let root_text = root.display().to_string(); + let lian_jing = |servers: Option<&Value>| { + servers + .and_then(|servers| servers.get(MCP_KEY)) + .filter(|entry| entry.get("url").and_then(Value::as_str) == Some(url)) + .cloned() + }; + // (覆盖本根目录, 键长度) 降序:覆盖者在前、越具体越前 + let mut local: Vec<((bool, usize), Value)> = config + .get("projects") + .and_then(Value::as_object) + .map(|projects| { + projects + .iter() + .filter_map(|(key, project)| { + let entry = lian_jing(project.get("mcpServers"))?; + let rank = ( + key_covers_root(key, &root_text), + normalize_root_key(key).len(), + ); + Some((rank, entry)) + }) + .collect() + }) + .unwrap_or_default(); + local.sort_by(|(left, _), (right, _)| right.cmp(left)); + local + .into_iter() + .map(|(_, entry)| ClaudeEntry { + source: ClaudeEntrySource::Local, + entry, + }) + .chain( + lian_jing(mcp_json.and_then(|json| json.get("mcpServers"))).map(|entry| ClaudeEntry { + source: ClaudeEntrySource::Project, + entry, + }), + ) + .chain( + lian_jing(config.get("mcpServers")).map(|entry| ClaudeEntry { + source: ClaudeEntrySource::User, + entry, + }), + ) + .collect() } /// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理), @@ -2136,6 +2232,26 @@ impl ClientAdapter for CodexAdapter { } outcome } + + /// 生效配置以 codex mcp get --json 为准(L1 执行时取);它把 helper 脱敏,helper 只能回查项目配置。 + fn l1_facts(&self, project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts { + let config = read_optional(&project.root.join(".codex").join("config.toml")) + .ok() + .flatten() + .unwrap_or_default(); + L1ClientFacts { + client: ClientId::Codex, + // 控制面与 WSL 项目的 Codex 同样写项目地址(P8b),照常检查 + not_applicable: None, + rules: probe_l1::rules_finding(project.root, "AGENTS.md", false), + skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"), + source: L1Source::Codex { + project_entry: has_manual_codex_entry(&config), + project_url: probe_l1::codex_table_url(&config), + project_helper: probe_l1::codex_table_helper(&config), + }, + } + } } /// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。 @@ -2185,6 +2301,239 @@ impl ClientAdapter for DshAdapter { } finalize_for_project(project, refresh_dsh_patch(None)) } + + /// DSH 无零模型自省入口:白名单复用 static_status,另核对会话级插件;helper 按托管块 !!js 的方式执行。 + fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts { + let not_applicable = l1_not_applicable(project); + let (whitelist, session_plugin) = match not_applicable { + Some(reason) => ( + ClientOutcome::NotApplicable { reason }, + StaticFinding { + ok: true, + detail: "不适用".into(), + }, + ), + None => ( + self.static_status(project, desired), + dsh_session_plugin_finding(), + ), + }; + L1ClientFacts { + client: ClientId::Dsh, + not_applicable, + rules: probe_l1::rules_finding(project.root, "AGENTS.md", false), + skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"), + source: L1Source::Dsh { + whitelist, + session_plugin, + // 与托管块 !!js 同源:%USERPROFILE%\.lian-jing\bin 下的稳定入口 + sha256(project_id) + helper: HelperCommand::DshShim { + shim: helper_bin_dir() + .map(|dir| dir.join(MCP_HEADER_SHIM_NAME)) + .unwrap_or_else(|_| PathBuf::from(MCP_HEADER_SHIM_NAME)), + digest: crate::credential_store::mcp_auth_digest(project.project_id) + .unwrap_or_default(), + }, + }, + } + } +} + +/// DSH 会话级插件(P9,桌面 GUI 按工作区连项目地址)的完整性:托管块引用了当前版本的插件条目, +/// 且插件文件与炼境内置版本的 sha256 一致(忽略行尾)。只读。 +fn dsh_session_plugin_finding() -> StaticFinding { + use sha2::Digest; + let hash = |text: &str| -> String { + sha2::Sha256::digest(text.replace("\r\n", "\n").as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + }; + let patch = dsh_home() + .ok() + .and_then(|home| read_optional(&home.join("cordis.patch.yml")).ok().flatten()) + .unwrap_or_default(); + if !patch.contains(&format!("- id: {DSH_SESSION_PLUGIN_ID}")) { + return StaticFinding { + ok: false, + detail: format!("DSH 托管块未引用会话级插件 {DSH_SESSION_PLUGIN_ID};重跑 inject_mcp"), + }; + } + let expected = hash(DSH_SESSION_PLUGIN_SOURCE); + let path = helper_bin_dir().ok().and_then(|bin| { + bin.parent() + .map(|dir| dir.join("dsh").join(DSH_SESSION_PLUGIN_FILE)) + }); + match path + .as_ref() + .map(|path| (path, std::fs::read_to_string(path))) + { + Some((_, Ok(content))) if hash(&content) == expected => StaticFinding { + ok: true, + detail: format!( + "会话级插件 {DSH_SESSION_PLUGIN_FILE} 与炼境内置版本一致(sha256 {}…)", + &expected[..12] + ), + }, + Some((path, Ok(_))) => StaticFinding { + ok: false, + detail: format!( + "会话级插件 {} 与炼境内置版本不一致;重启炼境或重跑 inject_mcp", + path.display() + ), + }, + _ => StaticFinding { + ok: false, + detail: "会话级插件文件缺失;重启炼境或重跑 inject_mcp".into(), + }, + } +} + +// ── L1 零模型三端检查(P4a):本体入口与本机客户端定位 ───────────────────────── + +/// 三端对等 L1 零模型检查的本体入口(MCP probe_client_parity 与 Tauri command 共用,边界只在这里)。 +/// 只读三端配置,起零模型的客户端自省进程,经稳定 shim 取认证头后在项目地址上重放(认证头不出本进程)。 +pub fn probe_client_parity( + project_id: &str, + tier: Option<&str>, +) -> Result { + match tier.unwrap_or("l1") { + "l1" => {} + other => { + return Err(format!( + "tier={other} 不支持:目前只有 l1(零模型检查);L3 无头会话由后续 start_parity_probe 提供" + )) + } + } + let root = project_root(project_id)?; + probe_client_parity_at(project_id, &root, crate::mcp_server::read_port()) +} + +/// probe_client_parity 去掉登记查询的部分(根目录与端口由调用方给出;实机冒烟测试直接调用)。 +fn probe_client_parity_at( + project_id: &str, + root: &std::path::Path, + port: u16, +) -> Result { + let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); + let digest = crate::credential_store::mcp_auth_digest(project_id)?; + let facts = project_facts(project_id, root); + let desired = DesiredMcp { + port, + server_url: server_url.clone(), + header_helper: shim_helper_command(&digest), + refresh_dsh: false, + }; + let clients: Vec = crate::onboard::clients::registry() + .iter() + .map(|adapter| adapter.l1_facts(&facts, &desired)) + .collect(); + let expected_tools = crate::mcp_tools::tools_list_result_for_project(project_id)["tools"] + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let project = probe_l1::L1Project { + project_id, + root, + control_plane: facts.control_plane, + expected_url: server_url, + expected_tools, + managed_helpers: vec![ + shim_helper_command(&digest), + legacy_shim_helper_command(&digest), + ], + }; + let env = |name: &str| std::env::var(name).ok(); + let deps = probe_l1::L1Deps { + runner: &probe_l1::SystemRunner, + http: &probe_l1::UreqHttp, + locator: &SystemLocator, + env: &env, + budget: probe_l1::L1_BUDGET, + }; + // 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连项目地址,服务端无法与 + // 真实会话区分:检查期间本项目的 initialize 不计入「真实客户端已连接」,免得抹掉 reload_required 证据(审查 ③) + let _quiet = crate::mcp_server::quiet_project_initializes(project_id); + Ok(probe_l1::run_l1(&project, &clients, &deps)) +} + +/// 本机客户端定位(R15:客户端私有安装位置集中在这里,格式变化归因 adapter_drift)。 +struct SystemLocator; + +impl probe_l1::ClientLocator for SystemLocator { + fn locate(&self, client: ClientId) -> Option { + match client { + ClientId::Claude => find_claude_bin().map(|bin| Located { + program: Some(PathBuf::from(bin)), + version: None, + }), + ClientId::Codex => find_codex_bin().map(|bin| Located { + program: Some(bin), + version: None, + }), + ClientId::Dsh => DSH_ADAPTER.detect().then(|| Located { + program: None, + version: dsh_install_dir().and_then(|dir| { + probe_l1::read_asar_package_version(&dir.join("resources").join("app.asar")) + }), + }), + } + } + + fn git_bash(&self) -> Option { + find_git_bash().map(PathBuf::from) + } +} + +/// codex 可执行文件:CODEX_BIN → Codex 桌面版自带的 %LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe +/// (本机可能有多个哈希目录,取最新)→ PATH 中的 codex.exe。npm 全局安装的 codex.cmd 不在此列。 +fn find_codex_bin() -> Option { + if let Some(path) = std::env::var_os("CODEX_BIN") + .map(PathBuf::from) + .filter(|path| path.is_file()) + { + return Some(path); + } + let bundled = std::env::var_os("LOCALAPPDATA") + .map(|base| PathBuf::from(base).join("OpenAI").join("Codex").join("bin")) + .and_then(|bin| std::fs::read_dir(bin).ok()) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path().join("codex.exe")) + .filter(|exe| exe.is_file()) + .max_by_key(|exe| exe.metadata().and_then(|meta| meta.modified()).ok()); + if bundled.is_some() { + return bundled; + } + let output = crate::silent_cmd("where").arg("codex.exe").output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|line| PathBuf::from(line.trim())) + .filter(|path| path.is_file()) +} + +/// DSH 桌面版安装目录(读版本用):DSH_INSTALL_DIR → %LOCALAPPDATA%\Programs\DeepSeek Harness。 +fn dsh_install_dir() -> Option { + std::env::var_os("DSH_INSTALL_DIR") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("LOCALAPPDATA").map(|base| { + PathBuf::from(base) + .join("Programs") + .join("DeepSeek Harness") + }) + }) + .filter(|dir| dir.is_dir()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 @@ -3757,6 +4106,155 @@ trust_level = "trusted" assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n"); } + #[test] + fn l1_facts_skip_control_plane_and_wsl_and_codex_helper_round_trips() { + let desired = DesiredMcp { + port: 27190, + server_url: "http://127.0.0.1:27190/mcp/projects/l1".into(), + header_helper: shim_helper_command("abc"), + refresh_dsh: false, + }; + let control = ProjectFacts { + project_id: "l1", + root: std::path::Path::new("E:\\nowhere\\l1"), + control_plane: true, + wsl: false, + }; + // 不适用时不读客户端配置(Claude、DSH 的判定都不碰 ~/.claude.json 与 ~/.dsh) + for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] { + assert_eq!( + adapter.l1_facts(&control, &desired).not_applicable, + Some(NotApplicableReason::ControlPlane) + ); + } + let wsl = ProjectFacts { + project_id: "w", + root: std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"), + control_plane: false, + wsl: true, + }; + for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] { + assert_eq!( + adapter.l1_facts(&wsl, &desired).not_applicable, + Some(NotApplicableReason::Wsl) + ); + } + assert_eq!( + CODEX_ADAPTER.l1_facts(&control, &desired).not_applicable, + None + ); + + // Codex 的 helper 被 codex mcp get 脱敏:L1 从项目托管块回查,须与写入时逐字一致 + let root = std::env::temp_dir().join(format!("lj-l1-codex-{}", uuid::Uuid::new_v4())); + let config = root.join(".codex").join("config.toml"); + upsert_codex_mcp_config_with( + &config, + &desired.server_url, + &desired.header_helper, + &crate::DiskSink, + ) + .unwrap(); + let normal = ProjectFacts { + project_id: "l1", + root: &root, + control_plane: false, + wsl: false, + }; + let facts = CODEX_ADAPTER.l1_facts(&normal, &desired); + let _ = std::fs::remove_dir_all(&root); + match facts.source { + L1Source::Codex { + project_entry, + project_url, + project_helper, + } => { + assert!(project_entry); + assert_eq!(project_url.as_deref(), Some(desired.server_url.as_str())); + assert_eq!( + project_helper.as_deref(), + Some(desired.header_helper.as_str()) + ); + } + other => panic!("Codex 应给出 Codex 来源:{other:?}"), + } + } + + #[test] + fn claude_l1_entries_match_project_url_in_every_scope() { + use probe_l1::ClaudeEntrySource::{Local, Project, User}; + let url = "http://127.0.0.1:27190/mcp/projects/p"; + let entry = |helper: &str| claude_local_server_entry(url, helper); + let config = json!({ + "mcpServers": { "lian-jing": entry("user") }, + "projects": { + "E:/code": { "mcpServers": { "lian-jing": entry("ancestor") } }, + "E:/code/proj": { "mcpServers": { "lian-jing": entry("exact") } }, + // worktree 被 Claude 映射到主仓库根:键不覆盖本根目录,但 URL 是本项目地址 + "E:/code/proj-main": { "mcpServers": { "lian-jing": entry("main-repo") } }, + // 覆盖本根目录、却指向别处:不是本项目地址,不作候选 + "E:/": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:9/mcp" } } } + } + }); + let mcp_json = json!({ "mcpServers": { "lian-jing": entry("project") } }); + let entries = claude_l1_entries( + &config, + Some(&mcp_json), + std::path::Path::new("E:\\code\\proj"), + url, + ); + let got: Vec<(probe_l1::ClaudeEntrySource, &str)> = entries + .iter() + .map(|candidate| { + ( + candidate.source, + candidate.entry["headersHelper"].as_str().unwrap(), + ) + }) + .collect(); + assert_eq!( + got, + vec![ + (Local, "exact"), + (Local, "ancestor"), + (Local, "main-repo"), + (Project, "project"), + (User, "user"), + ] + ); + // ~/.claude.json 读不到时仍回查 .mcp.json + assert_eq!( + claude_l1_entries( + &Value::Null, + Some(&mcp_json), + std::path::Path::new("E:\\x"), + url + ) + .len(), + 1 + ); + } + + #[test] + fn managed_helpers_cover_current_and_legacy_shim_forms() { + assert!(legacy_shim_helper_command("d1").starts_with("\"%LOCALAPPDATA%\\lian-jing\\bin\\")); + assert!(legacy_shim_helper_command("d1").ends_with(&format!("{MCP_HEADER_SHIM_NAME}\" d1"))); + assert_ne!(legacy_shim_helper_command("d1"), shim_helper_command("d1")); + } + + /// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端): + /// LJ_L1_LIVE="|<项目根目录>" cargo test l1_live -- --ignored --nocapture + /// 只打印矩阵(不含认证头),由人或统筹会话判读。 + #[test] + #[ignore = "实机冒烟:起真实客户端自省进程并连运行中的炼境"] + fn l1_live_probe_against_running_lianjing() { + let spec = std::env::var("LJ_L1_LIVE").expect("设置 LJ_L1_LIVE=|"); + let (project_id, root) = spec.split_once('|').expect("格式 |"); + let started = std::time::Instant::now(); + let report = probe_client_parity_at(project_id, std::path::Path::new(root), 27190).unwrap(); + println!("{}", serde_json::to_string_pretty(&report).unwrap()); + println!("elapsed_ms={}", started.elapsed().as_millis()); + } + #[test] fn registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index cb62762..9638ac2 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -50,7 +50,11 @@ fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str .and_then(|info| info.get("name")) .and_then(Value::as_str) .unwrap_or_default(); - if client_name == "lian-jing-readiness" { + // 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据 + if client_name == "lian-jing-readiness" + || client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME + || project_initializes_quiet(project_id) + { return; } if let Ok(mut clients) = initialized_project_clients().write() { @@ -58,6 +62,50 @@ fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str } } +/// 正在跑 L1 检查的项目 → 并发检查数。 +static QUIET_PROJECTS: OnceLock>> = OnceLock::new(); + +fn quiet_projects() -> &'static RwLock> { + QUIET_PROJECTS.get_or_init(|| RwLock::new(HashMap::new())) +} + +fn project_initializes_quiet(project_id: &str) -> bool { + quiet_projects() + .read() + .map(|projects| projects.contains_key(project_id)) + .unwrap_or(false) +} + +/// L1 检查期间(守卫存活时)本项目路由上的 initialize 一律不计入「真实客户端已连接」。 +/// 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连接,服务端无法与真实会话 +/// 区分;检查期间(≤10 秒)恰好新开的真实会话也因此不计入——reload_required 宁可多提示一次重启, +/// 也不在客户端仍用旧配置时报「已重载」(agent-client-parity P4a 审查 ③)。 +pub(crate) struct QuietInitializeGuard { + project_id: String, +} + +pub(crate) fn quiet_project_initializes(project_id: &str) -> QuietInitializeGuard { + if let Ok(mut projects) = quiet_projects().write() { + *projects.entry(project_id.to_string()).or_insert(0) += 1; + } + QuietInitializeGuard { + project_id: project_id.to_string(), + } +} + +impl Drop for QuietInitializeGuard { + fn drop(&mut self) { + if let Ok(mut projects) = quiet_projects().write() { + if let Some(count) = projects.get_mut(&self.project_id) { + *count -= 1; + if *count == 0 { + projects.remove(&self.project_id); + } + } + } + } +} + pub(crate) fn has_initialized_project_client(project_id: &str) -> bool { initialized_project_clients() .read() @@ -1524,6 +1572,14 @@ mod tests { process_rpc(&readiness, Some(project_id)).await.unwrap(); assert!(!has_initialized_project_client(project_id)); + // L1 零模型探针(P4a)的认证链重放同样不算真实客户端 + let mut l1_probe = rpc("initialize"); + l1_probe.params = Some(json!({ + "clientInfo": { "name": crate::onboard::probe_l1::PROBE_CLIENT_NAME, "version": "1" } + })); + process_rpc(&l1_probe, Some(project_id)).await.unwrap(); + assert!(!has_initialized_project_client(project_id)); + let mut codex = rpc("initialize"); codex.params = Some(json!({ "clientInfo": { "name": "codex", "version": "test" } @@ -1532,6 +1588,37 @@ mod tests { assert!(has_initialized_project_client(project_id)); } + #[tokio::test] + async fn l1_quiet_window_hides_claude_self_introspection_from_reload_evidence() { + let project_id = "reload-evidence-quiet-fixture"; + let other = "reload-evidence-quiet-other"; + let claude = |project: &'static str| async move { + let mut init = rpc("initialize"); + init.params = Some(json!({ + "clientInfo": { "name": "claude-code", "version": "2.1.173" } + })); + process_rpc(&init, Some(project)).await.unwrap(); + }; + { + // 两次并发检查:先结束的那次不能提前解除静默 + let first = quiet_project_initializes(project_id); + let second = quiet_project_initializes(project_id); + claude(project_id).await; + drop(first); + claude(project_id).await; + assert!( + !has_initialized_project_client(project_id), + "L1 检查期间 claude mcp get / init 的连接不得算作真实客户端" + ); + // 静默只针对正在检查的项目 + claude(other).await; + assert!(has_initialized_project_client(other)); + drop(second); + } + claude(project_id).await; + assert!(has_initialized_project_client(project_id)); + } + #[tokio::test] async fn scoped_route_rejects_unregistered_project() { db::init_test_pool(); diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 229f1cd..b6180d5 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -400,6 +400,11 @@ pub fn tools_list_result() -> Value { if let Value::Array(envoy_tools) = envoy_tool_schemas() { tools.splice(after_list..after_list, envoy_tools); } + let after_verify = tools + .iter() + .position(|tool| tool["name"] == "verify_onboarding") + .map_or(tools.len(), |index| index + 1); + tools.insert(after_verify, parity_probe_tool_schema()); let after_scan = tools .iter() .position(|tool| tool["name"] == "scan_unregistered_projects") @@ -410,6 +415,23 @@ pub fn tools_list_result() -> Value { result } +/// 三端对等 L1 零模型检查(agent-client-parity P4a)。单独成函数:并入主清单会超出 json! 宏递归上限。 +/// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。 +fn parity_probe_tool_schema() -> Value { + json!({ + "name": "probe_client_parity", + "description": "三端对等 L1 零模型检查(只读、不调用模型、零额度,10 秒内返回):在项目目录对 Claude Code 执行 claude mcp get 与坏模型名的 init 自省、对 Codex 执行 codex mcp get --json、读 DSH 托管块白名单与会话插件哈希;再按各客户端方式执行稳定 helper,在其配置的项目地址上依次 initialize、tools/list、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(需求名 LJ_PROBE_,认证通过即返回「开发需求未登记」)重放认证链。返回 A–F × 三端矩阵,每格带状态(ready/fail/unknown/not_installed/not_applicable/blocked/timeout;客户端自己没连上或看不到某工具时,协议重放通过的格子记 blocked 或 tools_mismatch)、证据等级(static < client_introspection < protocol)与归因(如 shim_missing、auth_failed、unreachable、route_global、codex_project_untrusted)。认证头只在炼境进程内使用,不进入结果;只执行炼境托管写法的 helper,其余记 unknown(manual_config);零模型 init 关闭全部 hooks。控制面项目上 Claude 与 DSH 记 not_applicable;未安装的客户端记 not_installed、不计失败。L1 是配置与协议重放,绿灯不等于真实会话可用。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects)" }, + "tier": { "type": "string", "enum": ["l1"], "description": "检查层级,目前只有 l1(零模型),省略即 l1" } + }, + "required": ["project_id"] + } + }) +} + /// 登记项目(P8f,单独成函数:并入主清单会超出 json! 宏递归上限)。只开放在全局地址。 fn register_project_schema() -> Value { json!({ @@ -477,10 +499,10 @@ fn is_project_scoped_tool(tool: &Value) -> bool { if name == "get_diagnostics" { return true; } - // 登记与删除登记属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id) + // 登记与删除登记、三端检查属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id) if matches!( name, - "register_project" | "unregister_project" | "setup_gitea_repo" + "register_project" | "unregister_project" | "setup_gitea_repo" | "probe_client_parity" ) { return false; } @@ -614,6 +636,7 @@ fn apply_tool_annotations(result: &mut Value) { || name.starts_with("plan_") || name.starts_with("predict_") || name == "verify_onboarding" + || name == "probe_client_parity" || name == "generate_blueprint_prompt"; let destructive = matches!( name, @@ -772,6 +795,7 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .as_str() .unwrap_or("接入验收结构化结果已返回") .to_string(), + "probe_client_parity" => parity_probe_summary(value), "register_project" => { let summary = format!( "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", @@ -788,12 +812,67 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { } } +/// L1 矩阵的一行摘要:每端「ready 格数/适用格数」与非 ready 格的归因(结构化结果在 structuredContent)。 +fn parity_probe_summary(value: &Value) -> String { + let columns: Vec = value["clients"] + .as_array() + .map(|clients| { + clients + .iter() + .map(|column| { + let client = column["client"].as_str().unwrap_or("?"); + let cells = column["cells"].as_object(); + let states = |state: &str| { + cells.map_or(0, |cells| { + cells.values().filter(|cell| cell["state"] == state).count() + }) + }; + if states("not_applicable") == 6 { + return format!("{client} 不适用"); + } + if states("not_installed") == 6 { + return format!("{client} 未安装"); + } + let issues: Vec = cells + .map(|cells| { + cells + .iter() + .filter(|(_, cell)| cell["state"] != "ready") + .map(|(check, cell)| { + format!("{check}={}", cell["cause"].as_str().unwrap_or("?")) + }) + .collect() + }) + .unwrap_or_default(); + let version = column["version"].as_str().unwrap_or("版本未知"); + if issues.is_empty() { + format!("{client} {version} 6/6 ready") + } else { + format!("{client} {version} {}", issues.join(" ")) + } + }) + .collect() + }) + .unwrap_or_default(); + format!( + "L1 三端检查:project_id={},{};{}", + value["project_id"].as_str().unwrap_or("?"), + if value["all_applicable_ready"] == true { + "适用格全部 ready" + } else { + "有未就绪格" + }, + columns.join(";") + ) +} + fn tool_success_response(tool_name: &str, text: String) -> Value { const STRUCTURED_TOOLS: &[&str] = &[ "get_delivery_status", "get_toolchain_status", "get_project_resource_status", "verify_onboarding", + "probe_client_parity", "register_project", ]; if STRUCTURED_TOOLS.contains(&tool_name) { @@ -1455,6 +1534,12 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { + let pid = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let tier = args.get("tier").and_then(Value::as_str); + crate::mcp_inject::probe_client_parity(pid, tier) + .and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string())) + } "verify_onboarding" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let root = resolve_project_root(pid)?; @@ -2560,6 +2645,82 @@ mod tests { } } + #[tokio::test] + async fn parity_probe_is_global_read_only_and_rejects_unknown_tier_before_io() { + let global = tools_list_result(); + let probe = global["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "probe_client_parity") + .expect("全局路由应有 probe_client_parity"); + assert_eq!(probe["annotations"]["readOnlyHint"], true); + assert_eq!(probe["annotations"]["destructiveHint"], false); + assert_eq!(probe["inputSchema"]["required"], json!(["project_id"])); + assert_eq!( + probe["inputSchema"]["properties"]["tier"]["enum"], + json!(["l1"]) + ); + // 全局目录不得出现敏感读取工具名(scoped_dev_variable_http_pilot 有同一断言) + for sensitive in ["read_project_test_account", "read_project_dev_variable"] { + assert!(!probe.to_string().contains(sensitive), "{sensitive}"); + } + let scoped = tools_list_result_for_project("p1"); + assert!(!scoped["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "probe_client_parity")); + let from_project = tools_call_for_project( + Some(&json!({"name": "probe_client_parity", "arguments": {}})), + "p1", + ) + .await; + assert_eq!(from_project["structuredContent"]["code"], "scope_violation"); + // tier 校验在本体入口最前面:不查登记、不起任何进程 + let l3 = tools_call(Some(&json!({ + "name": "probe_client_parity", + "arguments": { "project_id": "p1", "tier": "l3" } + }))) + .await; + assert_eq!(l3["isError"], true); + assert!(l3["content"][0]["text"] + .as_str() + .unwrap() + .contains("tier=l3")); + } + + #[test] + fn parity_probe_summary_lists_versions_and_causes() { + let cell = |state: &str, cause: Option<&str>| match cause { + Some(cause) => { + json!({ "state": state, "cause": cause, "evidence": "protocol", "detail": "" }) + } + None => json!({ "state": state, "evidence": "protocol", "detail": "" }), + }; + let ready = json!({ + "A": cell("ready", None), "B": cell("ready", None), "C": cell("ready", None), + "D": cell("ready", None), "E": cell("ready", None), "F": cell("ready", None) + }); + let mut auth = ready.clone(); + auth["F"] = cell("fail", Some("auth_failed")); + let na = cell("not_applicable", Some("control_plane")); + let summary = parity_probe_summary(&json!({ + "project_id": "p1", + "all_applicable_ready": false, + "clients": [ + { "client": "claude", "version": null, "cells": { + "A": na, "B": na, "C": na, "D": na, "E": na, "F": na } }, + { "client": "codex", "version": "0.155.0", "cells": auth }, + { "client": "dsh", "version": "0.2.0", "cells": ready } + ] + })); + assert!(summary.contains("有未就绪格"), "{summary}"); + assert!(summary.contains("claude 不适用"), "{summary}"); + assert!(summary.contains("codex 0.155.0 F=auth_failed"), "{summary}"); + assert!(summary.contains("dsh 0.2.0 6/6 ready"), "{summary}"); + } + #[tokio::test] async fn scoped_calls_reject_cross_project_and_global_write_before_io() { let readiness_mismatch = tools_call_for_project( diff --git a/src-tauri/src/onboard/clients.rs b/src-tauri/src/onboard/clients.rs index 2c10770..b9c7425 100644 --- a/src-tauri/src/onboard/clients.rs +++ b/src-tauri/src/onboard/clients.rs @@ -129,6 +129,13 @@ pub trait ClientAdapter: Sync { fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome; /// 收敛到期望状态;调用方须持有接入锁。失败返回 failed / unavailable,不 panic、不中断其他端。 fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome; + /// L1 零模型检查(P4a)要的本端配置事实:只读文件、不起子进程、不读凭据。客户端自省与认证链重放 + /// 由 onboard::probe_l1 按这里声明的来源与 helper 执行方式进行;不适用(控制面 / WSL)时不读任何配置。 + fn l1_facts( + &self, + project: &ProjectFacts, + desired: &DesiredMcp, + ) -> crate::onboard::probe_l1::L1ClientFacts; } /// 三端适配器注册表;顺序即报告顺序。 diff --git a/src-tauri/src/onboard/mod.rs b/src-tauri/src/onboard/mod.rs index ce4eb9a..1fc1b8c 100644 --- a/src-tauri/src/onboard/mod.rs +++ b/src-tauri/src/onboard/mod.rs @@ -7,6 +7,7 @@ pub mod clients; pub mod plan; +pub mod probe_l1; #[cfg(test)] mod fitness_tests { diff --git a/src-tauri/src/onboard/probe_l1.rs b/src-tauri/src/onboard/probe_l1.rs new file mode 100644 index 0000000..a2a47bb --- /dev/null +++ b/src-tauri/src/onboard/probe_l1.rs @@ -0,0 +1,2201 @@ +//! L1 零模型三端检查(agent-client-parity P4a)。 +//! +//! 不调用任何模型、不消耗额度,按 Claude Code、Codex、DSH 各自对本项目实际生效的配置给出 A–F × 三端矩阵, +//! 每格标注证据等级(static < client_introspection < protocol < session_event/server_trace): +//! - 客户端自省(client_introspection):Claude 在项目目录执行 `claude mcp get lian-jing` 取实际生效条目, +//! 再以不存在的模型名运行 `claude -p` 读 system/init(P4-0 H-P8-4:init 先于模型请求、零额度); +//! Codex 执行 `codex mcp get lian-jing --json`(H-P8-1:未受信任目录看不到项目配置)。 +//! - 认证链重放(protocol):按客户端方式执行稳定 shim 取认证头(只在本进程内存里使用,不落盘、不进结果), +//! 在客户端配置的地址上依次 initialize(clientInfo = lian-jing-l1-probe)、tools/list(与 +//! tools_list_result_for_project 自省值比对)、get_diagnostics(D)、list_project_dev_variables(E)、 +//! read_project_test_account(LJ_PROBE_)(F:返回「开发需求未登记」即认证通过)。 +//! - 静态(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),测试用假实现; +//! 各端配置事实由客户端适配器只读取得(ClientAdapter::l1_facts)。本模块不读凭据存储、不取连接池。 +//! L1 是配置与协议重放,绿灯不等于真实会话可用:报告里每格的证据等级就是为此标注的(设计 §6、风险 R6)。 + +use crate::onboard::clients::{ClientId, ClientOutcome, NotApplicableReason}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// 探针在 initialize 中自报的客户端名:服务端据此把它排除出「真实客户端已连接」证据(mcp_server)。 +pub const PROBE_CLIENT_NAME: &str = "lian-jing-l1-probe"; +/// 判定口径版本:口径变化时递增,供 P4c/P4d 比较「是否可比」与缓存键使用。 +pub const PROBE_SPEC_VERSION: u32 = 1; +/// 整次检查的时间预算:投影要求 10 秒内返回,留出序列化与传输余量。三端并行执行。 +pub const L1_BUDGET: Duration = Duration::from_secs(9); +const SERVER_NAME: &str = "lian-jing"; +/// 不存在的模型名:claude -p 先发 system/init(含 MCP 连接状态、工具、skills)再报模型不可用(H-P8-4)。 +const ZERO_MODEL: &str = "lj-no-such-model"; +/// 读到这一行即结束 claude 进程:init 之后只剩一次必然失败的模型请求(实测 5–8 秒),不必等。 +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 对未登记需求名的固定回答:出现即说明认证已通过(零凭据验证法)。 +/// 与 commands::dev_variables 的报错文案由 probe_l1 测试在内存库上调用真实本体绑定(文案一改测试即红)。 +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 HELPER_TIMEOUT: Duration = Duration::from_secs(5); +const HTTP_TIMEOUT: Duration = Duration::from_secs(4); +/// 剩余时间不足此值就不再起新步骤,直接记 timeout。 +const MIN_STEP: Duration = Duration::from_millis(200); +const MAX_CAPTURE: usize = 4 * 1024 * 1024; + +// ── 矩阵结构 ───────────────────────────────────────────────────────────────── + +/// 证据等级,从弱到强。L1 最高只到 protocol;session_event / server_trace 属 L3 与服务端轨迹(P4b–P4d)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceLevel { + Static, + ClientIntrospection, + Protocol, + SessionEvent, + ServerTrace, +} + +/// 检查项(与基线证据表一致):A 项目规则、B lian-jing skill、C MCP 工具集、D get_diagnostics、 +/// E 列出开发变量、F 敏感读取认证链。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Check { + A, + B, + C, + D, + E, + F, +} + +const ALL_CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F]; +const CONNECTION_CHECKS: [Check; 4] = [Check::C, Check::D, Check::E, Check::F]; + +/// 格子状态。not_installed 与 not_applicable 不计失败;blocked 留给编排层(P8d:依赖的步骤失败)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CellState { + Ready, + Fail, + Unknown, + NotInstalled, + NotApplicable, + Blocked, + Timeout, +} + +/// 归因(设计 §6 三分定位:环境 → 投影 → 适配器)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Cause { + // 不适用 + ControlPlane, + Wsl, + // 环境 + Timeout, + Unreachable, + ClientError, + // 投影:配置与接入 + NotConfigured, + RouteGlobal, + RouteMismatch, + NotConnected, + CodexProjectUntrusted, + ServerDisabled, + ManualConfig, + RulesNotLoaded, + SkillNotVisible, + SessionPluginMismatch, + ShimMissing, + HelperFailed, + AuthHeaderMissing, + AuthFailed, + ToolsMismatch, + ToolError, + // 适配器:客户端输出格式变了,需要升级炼境适配器 + AdapterDrift, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cell { + pub state: CellState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option, + pub evidence: EvidenceLevel, + pub detail: String, +} + +impl Cell { + fn ready(evidence: EvidenceLevel, detail: impl Into) -> Self { + Self::with(CellState::Ready, None, evidence, detail) + } + + fn fail(cause: Cause, evidence: EvidenceLevel, detail: impl Into) -> Self { + Self::with(CellState::Fail, Some(cause), evidence, detail) + } + + fn unknown(cause: Cause, evidence: EvidenceLevel, detail: impl Into) -> Self { + Self::with(CellState::Unknown, Some(cause), evidence, detail) + } + + fn timeout(evidence: EvidenceLevel, detail: impl Into) -> Self { + Self::with(CellState::Timeout, Some(Cause::Timeout), evidence, detail) + } + + fn with( + state: CellState, + cause: Option, + evidence: EvidenceLevel, + detail: impl Into, + ) -> Self { + Self { + state, + cause, + evidence, + detail: detail.into(), + } + } + + /// 已处于期望状态或按边界不计失败(未安装、不适用)。 + pub fn is_acceptable(&self) -> bool { + matches!( + self.state, + CellState::Ready | CellState::NotInstalled | CellState::NotApplicable + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientColumn { + pub client: ClientId, + /// 客户端版本(Claude 取自 init、Codex 取自 --version、DSH 取自安装包);未知为 null + pub version: Option, + /// 该端对本项目实际会连的地址(非秘密);未得出为 null + pub route: Option, + pub cells: BTreeMap, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProbeProfile { + Project, + /// 控制面项目(mcp_scope=global):Claude 与 DSH 走全局地址,整列 not_applicable + ControlPlane, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct L1Report { + pub project_id: String, + pub tier: String, + pub probe_spec_version: u32, + pub profile: ProbeProfile, + /// 本项目专用地址(期望各端连到这里) + pub expected_route: String, + /// 期望工具数:取自 tools_list_result_for_project 自省值,不写死 + pub expected_tools: usize, + /// 本次 F 项使用的随机需求名(非秘密,便于在服务端轨迹中归属) + pub nonce: String, + pub clients: Vec, + /// 全部格子 ready / not_installed / not_applicable + pub all_applicable_ready: bool, + pub duration_ms: u64, + pub note: String, +} + +// ── 输入:项目与各端配置事实 ───────────────────────────────────────────────── + +pub struct L1Project<'a> { + pub project_id: &'a str, + pub root: &'a Path, + pub control_plane: bool, + pub expected_url: String, + pub expected_tools: Vec, + /// 炼境托管写法的 helper 命令行(当前稳定入口与旧 %LOCALAPPDATA% 入口,均带本项目 digest)。 + /// 配置里的 helper 只有逐字等于其中之一才执行:配置可能来自仓库受跟踪文件(.codex/config.toml、.mcp.json), + /// 探针又可免审批调用,不能替客户端执行它自己未必会执行的任意命令。 + pub managed_helpers: Vec, +} + +/// 只读判定的一条静态事实。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticFinding { + pub ok: bool, + pub detail: String, +} + +/// helper 的执行方式(按客户端区分,P1/P3 实测)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HelperCommand { + /// Claude、Codex:配置里的 helper 命令行经 cmd.exe 执行,%VAR% 由 cmd 展开(H2、H4)。 + Shell(String), + /// DSH 托管块 !!js:`execFileSync(cmd, ['/d','/s','/c', '""" "'])`;失败时返回 {}, + /// 连接照常建立、只是不带认证头。 + 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)] +pub enum L1Source { + /// Claude:各作用域里 URL 为本项目地址的 lian-jing 条目(按 URL 匹配,不复刻 Claude 的项目键推导)。 + /// 生效哪一条以 `claude mcp get` 的回答为准;该命令不输出 headersHelper,按 URL 与作用域回查。 + Claude { entries: Vec }, + /// Codex:生效配置以 `codex mcp get --json` 为准;它会把 helper 脱敏,helper 取项目 .codex/config.toml 同名表。 + Codex { + project_entry: bool, + /// 项目 .codex/config.toml 同名表的 url:与 Codex 生效地址不同说明项目配置未加载(多半未受信任) + project_url: Option, + project_helper: Option, + }, + /// DSH:没有零模型自省入口,只有静态判定——托管块白名单(P8b dsh_static_outcome)与会话级插件哈希。 + Dsh { + whitelist: ClientOutcome, + session_plugin: StaticFinding, + helper: HelperCommand, + }, +} + +#[derive(Debug, Clone)] +pub struct L1ClientFacts { + pub client: ClientId, + /// 控制面 / WSL:整列 not_applicable,零进程 + pub not_applicable: Option, + pub rules: StaticFinding, + pub skill: StaticFinding, + pub source: L1Source, +} + +// ── 注入点 ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessSpec { + pub program: PathBuf, + pub args: Vec, + /// 原样拼到命令行末尾(cmd /s /c 的引号规则,不经标准参数转义;仅 Windows 有意义) + pub raw_tail: Option, + pub cwd: Option, + /// 从继承的环境中去掉:名字等于该项,或该项以 `_` 结尾且名字以它开头(CLAUDE_CODE_*) + pub env_remove: Vec, + pub env_set: Vec<(String, String)>, + pub timeout: Duration, + /// stdout 某行包含此标记即结束进程(Claude 读到 system/init 即止) + pub stop_at_stdout_line: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcessOutput { + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub timed_out: bool, + pub stopped_early: bool, +} + +/// 子进程执行器:stdin 一律置空。Err 只表示无法启动。 +pub trait ProcessRunner: Sync { + fn run(&self, spec: &ProcessSpec) -> Result; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HttpReply { + pub status: u16, + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HttpFailure { + Unreachable(String), + Timeout, + Other(String), +} + +/// MCP Streamable HTTP 的单次 POST。实现不得记录请求头(含认证)。 +pub trait McpHttp: Sync { + fn post( + &self, + url: &str, + headers: &[(String, String)], + body: &Value, + timeout: Duration, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Located { + /// 可执行文件(DSH 的 L1 不起进程,为 None) + pub program: Option, + /// 不起进程即可得到的版本(DSH 读安装包);Claude、Codex 由检查过程取得 + pub version: Option, +} + +/// 客户端定位:None 表示本机未安装(该列记 not_installed,不起任何进程)。 +pub trait ClientLocator: Sync { + fn locate(&self, client: ClientId) -> Option; + /// claude CLI 在 Windows 上依赖 git-bash + fn git_bash(&self) -> Option; + fn cmd_exe(&self) -> PathBuf { + system_cmd_exe() + } +} + +pub struct L1Deps<'a> { + pub runner: &'a dyn ProcessRunner, + pub http: &'a dyn McpHttp, + pub locator: &'a dyn ClientLocator, + /// 展开 helper 命令行里的 %VAR%(只用于判断 shim 是否存在) + pub env: &'a (dyn Fn(&str) -> Option + Sync), + pub budget: Duration, +} + +// ── 执行 ───────────────────────────────────────────────────────────────────── + +struct Ctx<'a> { + project: &'a L1Project<'a>, + deps: &'a L1Deps<'a>, + deadline: Instant, + nonce: &'a str, +} + +impl Ctx<'_> { + /// 本步可用的时长:不超过 cap 与剩余预算;剩余不足 MIN_STEP 返回 None(记 timeout)。 + fn cap(&self, cap: Duration) -> Option { + let remaining = self.deadline.saturating_duration_since(Instant::now()); + (remaining >= MIN_STEP).then(|| remaining.min(cap)) + } +} + +/// 执行 L1 检查:三端并行,每端独立判定,一端异常不影响其他端。 +pub fn run_l1(project: &L1Project, clients: &[L1ClientFacts], deps: &L1Deps) -> L1Report { + let started = Instant::now(); + let deadline = started + deps.budget; + let nonce = new_nonce(); + let ctx = Ctx { + project, + deps, + deadline, + nonce: &nonce, + }; + let columns: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = clients + .iter() + .map(|facts| (facts.client, scope.spawn(|| probe_client(&ctx, facts)))) + .collect(); + handles + .into_iter() + .map(|(client, handle)| { + handle.join().unwrap_or_else(|_| { + filled_column( + client, + Cell::unknown( + Cause::AdapterDrift, + EvidenceLevel::Static, + "该端检查线程异常中止(适配器缺陷),其他端不受影响", + ), + ) + }) + }) + .collect() + }); + let all_applicable_ready = columns + .iter() + .all(|column| column.cells.values().all(Cell::is_acceptable)); + L1Report { + project_id: project.project_id.to_string(), + tier: "l1".into(), + probe_spec_version: PROBE_SPEC_VERSION, + profile: if project.control_plane { + ProbeProfile::ControlPlane + } else { + ProbeProfile::Project + }, + expected_route: project.expected_url.clone(), + expected_tools: project.expected_tools.len(), + nonce, + clients: columns, + all_applicable_ready, + duration_ms: started.elapsed().as_millis() as u64, + note: + "L1 为零模型的客户端自省与认证链重放(证据等级至多 protocol),绿灯不等于真实会话可用;\ + 客户端版本变化时以 L3 无头会话复核,桌面 GUI 形态归人工终验。" + .into(), + } +} + +/// 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 { + ClientColumn { + client, + version: None, + route: None, + cells: ALL_CHECKS + .iter() + .map(|check| (*check, cell.clone())) + .collect(), + duration_ms: 0, + } +} + +fn set_connection_cells(column: &mut ClientColumn, cell: Cell) { + for check in CONNECTION_CHECKS { + column.cells.insert(check, cell.clone()); + } +} + +fn probe_client(ctx: &Ctx, facts: &L1ClientFacts) -> ClientColumn { + let started = Instant::now(); + if let Some(reason) = facts.not_applicable { + let (cause, detail) = match reason { + NotApplicableReason::ControlPlane => ( + Cause::ControlPlane, + "控制面项目:该端保留全局地址与跨项目工具,不按项目路由", + ), + NotApplicableReason::Wsl => (Cause::Wsl, "WSL 路径项目:该端不支持(2026-07-04 裁决)"), + }; + return filled_column( + facts.client, + Cell::with( + CellState::NotApplicable, + Some(cause), + EvidenceLevel::Static, + detail, + ), + ); + } + let Some(located) = ctx.deps.locator.locate(facts.client) else { + return filled_column( + facts.client, + Cell::with( + CellState::NotInstalled, + None, + EvidenceLevel::Static, + "本机未安装该客户端(不计失败)", + ), + ); + }; + let mut column = ClientColumn { + client: facts.client, + version: located.version.clone(), + route: None, + cells: BTreeMap::new(), + duration_ms: 0, + }; + column + .cells + .insert(Check::A, static_cell(&facts.rules, Cause::RulesNotLoaded)); + column + .cells + .insert(Check::B, static_cell(&facts.skill, Cause::SkillNotVisible)); + match &facts.source { + L1Source::Claude { entries } => probe_claude(ctx, &located, entries, &mut column), + L1Source::Codex { + project_entry, + project_url, + project_helper, + } => probe_codex( + ctx, + &located, + CodexProjectTable { + present: *project_entry, + url: project_url.as_deref(), + helper: project_helper.as_deref(), + }, + &mut column, + ), + L1Source::Dsh { + whitelist, + session_plugin, + helper, + } => probe_dsh(ctx, whitelist, session_plugin, helper, &mut column), + } + column.duration_ms = started.elapsed().as_millis() as u64; + column +} + +fn static_cell(finding: &StaticFinding, cause: Cause) -> Cell { + if finding.ok { + Cell::ready(EvidenceLevel::Static, finding.detail.clone()) + } else { + Cell::fail(cause, EvidenceLevel::Static, finding.detail.clone()) + } +} + +/// 客户端子命令的统一卫生:cwd=项目目录、stdin 置空、去掉父会话注入的 CLAUDECODE 与 CLAUDE_CODE_* +/// (P4-0 嵌套无头会话的坑),Claude 另设 git-bash 与 MCP 阻塞连接。 +fn client_spec( + ctx: &Ctx, + program: &Path, + args: Vec, + timeout: Duration, + env_set: Vec<(String, String)>, +) -> ProcessSpec { + ProcessSpec { + program: program.to_path_buf(), + args, + raw_tail: None, + cwd: Some(ctx.project.root.to_path_buf()), + env_remove: vec!["CLAUDECODE".into(), "CLAUDE_CODE_".into()], + env_set, + timeout, + stop_at_stdout_line: None, + } +} + +/// 与期望地址比对:一致返回 None,否则返回 C–F 共同的失败格。 +fn route_cell(url: &str, expected: &str, source: &str) -> Option { + if url == expected { + return None; + } + let global = url.trim_end_matches('/').ends_with("/mcp"); + Some(if global { + Cell::fail( + Cause::RouteGlobal, + EvidenceLevel::ClientIntrospection, + format!("{source}生效的是全局地址 {url}(看不到项目工具集,E、F 不可用);重跑 inject_mcp 写项目地址"), + ) + } else { + Cell::fail( + Cause::RouteMismatch, + EvidenceLevel::ClientIntrospection, + format!("{source}生效地址 {url} 不是本项目地址 {expected}"), + ) + }) +} + +/// 配置里的 helper 不是炼境托管写法时不执行(C–F 记 unknown(manual_config));DSH 的 helper 由炼境自己构造,不在此列。 +fn unmanaged_helper_cell(ctx: &Ctx, helper: Option<&HelperCommand>, source: &str) -> Option { + 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), + /// 该端明确没连上(附原因) + 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 { + set_connection_cells( + column, + Cell::unknown( + Cause::ClientError, + EvidenceLevel::Static, + "未找到 claude CLI,无法自省", + ), + ); + return; + }; + let mut env_set = vec![( + "MCP_CONNECTION_NONBLOCKING".to_string(), + "false".to_string(), + )]; + if let Some(bash) = ctx.deps.locator.git_bash() { + env_set.push(( + "CLAUDE_CODE_GIT_BASH_PATH".into(), + bash.display().to_string(), + )); + } + // ① 实际生效条目:以 Claude 自己的回答为准 + let Some(timeout) = ctx.cap(CLIENT_CLI_TIMEOUT) else { + set_connection_cells(column, Cell::timeout(EvidenceLevel::Static, "预算耗尽")); + return; + }; + let get = client_spec( + ctx, + program, + vec!["mcp".into(), "get".into(), SERVER_NAME.into()], + timeout, + env_set.clone(), + ); + let output = match ctx.deps.runner.run(&get) { + Ok(output) if output.timed_out => { + set_connection_cells( + column, + Cell::timeout( + EvidenceLevel::ClientIntrospection, + "claude mcp get 超时(已终止)", + ), + ); + return; + } + Ok(output) => output, + Err(error) => { + set_connection_cells( + column, + Cell::unknown( + Cause::ClientError, + EvidenceLevel::ClientIntrospection, + format!("claude mcp get 无法执行:{error}"), + ), + ); + return; + } + }; + let combined = format!("{}\n{}", output.stdout, output.stderr); + let Some(effective) = parse_claude_mcp_get(&combined) else { + set_connection_cells( + column, + Cell::unknown( + Cause::AdapterDrift, + EvidenceLevel::ClientIntrospection, + "claude mcp get 输出格式无法识别(客户端升级?需升级炼境适配器)", + ), + ); + return; + }; + let url = match (&effective.scope, effective.url.clone()) { + (ClaudeScope::Absent, _) => { + set_connection_cells( + column, + Cell::fail( + Cause::NotConfigured, + EvidenceLevel::ClientIntrospection, + "Claude 在项目目录看不到 lian-jing;重跑 inject_mcp", + ), + ); + return; + } + (_, Some(url)) => url, + (_, None) => { + set_connection_cells( + column, + Cell::unknown( + Cause::AdapterDrift, + EvidenceLevel::ClientIntrospection, + "claude mcp get 未给出 URL", + ), + ); + return; + } + }; + column.route = Some(url.clone()); + if let Some(cell) = route_cell(&url, &ctx.project.expected_url, "Claude ") { + set_connection_cells(column, cell); + return; + } + // headersHelper 不在 mcp get 输出里:按 URL 回查候选条目,优先与 Claude 报告的作用域一致的那条 + let wanted = effective.scope.entry_source(); + let matching: Vec<&ClaudeEntry> = entries + .iter() + .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 + .and_then(|entry| entry.get("headersHelper")) + .and_then(Value::as_str) + .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 即结束——此时只有 HTTP 连接,无 stdio 子进程会因提前结束而成为孤儿。 + // 条目只保留 type、url、headersHelper:手写条目若带静态 headers(含认证),不放上进程命令行。 + // 关掉全部 hooks(-p 不弹信任框,会执行项目与用户的 SessionStart 等 hooks)。 + let introspected = match ctx.cap(CLIENT_CLI_TIMEOUT) { + None => Err("预算耗尽,未取 init".to_string()), + Some(timeout) => { + 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( + ctx, + program, + vec![ + "-p".into(), + "hi".into(), + "--model".into(), + ZERO_MODEL.into(), + "--no-session-persistence".into(), + "--settings".into(), + CLAUDE_INIT_SETTINGS.into(), + "--strict-mcp-config".into(), + "--mcp-config".into(), + config.to_string(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + ], + timeout, + env_set, + ); + spec.stop_at_stdout_line = Some(CLAUDE_INIT_MARKER.into()); + match ctx.deps.runner.run(&spec) { + Err(error) => Err(format!("claude -p 无法执行:{error}")), + Ok(output) => parse_claude_init(&output.stdout, SERVER_NAME).ok_or_else(|| { + if output.timed_out { + "claude -p 超时仍未发出 system/init".to_string() + } else { + "claude -p 输出中没有可识别的 system/init(客户端升级?)".to_string() + } + }), + } + } + }; + let mut view = None; + let mut introspection_note = String::new(); + match &introspected { + Ok(init) => { + if init.version.is_some() { + column.version = init.version.clone(); + } + let b = if init.skills.iter().any(|skill| skill == SERVER_NAME) { + Cell::ready( + EvidenceLevel::ClientIntrospection, + "Claude init 的 skills 含 lian-jing", + ) + } else { + Cell::fail( + Cause::SkillNotVisible, + EvidenceLevel::ClientIntrospection, + "Claude init 的 skills 不含 lian-jing(.claude/skills 投影缺失?重跑接入包)", + ) + }; + column.cells.insert(Check::B, b); + view = Some(match init.server_status.as_deref() { + Some("connected") => ClientView::Connected(init.tools.clone()), + Some(status) => { + ClientView::NotConnected(format!("Claude init:lian-jing 状态 {status}")) + } + None => { + ClientView::NotConnected("Claude init 的 mcp_servers 中没有 lian-jing".into()) + } + }); + } + // 取不到 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})"), + } + + // ③ 认证链重放:Claude 的 helper 失败时连接不建立 + let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort); + let replay_c_ready = replayed.c.state == CellState::Ready; + match view { + None => replayed.c.detail.push_str(&introspection_note), + 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})"), + ); + } + Some(ClientView::Connected(tools)) => { + if replay_c_ready { + replayed.c = match tools_diff(&tools, &ctx.project.expected_tools) { + None => Cell::ready( + EvidenceLevel::Protocol, + format!("{};Claude init 自省一致", replayed.c.detail), + ), + 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); +} + +/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的事实(Codex 是否加载它以 codex mcp get 为准)。 +struct CodexProjectTable<'a> { + present: bool, + url: Option<&'a str>, + helper: Option<&'a str>, +} + +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 { + set_connection_cells( + column, + Cell::unknown( + Cause::ClientError, + EvidenceLevel::Static, + "未找到 codex 可执行文件,无法自省", + ), + ); + return; + }; + if let Some(timeout) = ctx.cap(Duration::from_secs(3)) { + let spec = client_spec(ctx, program, vec!["--version".into()], timeout, Vec::new()); + if let Ok(output) = ctx.deps.runner.run(&spec) { + column.version = parse_codex_version(&output.stdout); + } + } + let Some(timeout) = ctx.cap(CLIENT_CLI_TIMEOUT) else { + set_connection_cells(column, Cell::timeout(EvidenceLevel::Static, "预算耗尽")); + return; + }; + let spec = client_spec( + ctx, + program, + vec![ + "mcp".into(), + "get".into(), + SERVER_NAME.into(), + "--json".into(), + ], + timeout, + Vec::new(), + ); + let output = match ctx.deps.runner.run(&spec) { + Ok(output) if output.timed_out => { + set_connection_cells( + column, + Cell::timeout( + EvidenceLevel::ClientIntrospection, + "codex mcp get 超时(已终止)", + ), + ); + return; + } + Ok(output) => output, + Err(error) => { + set_connection_cells( + column, + Cell::unknown( + Cause::ClientError, + EvidenceLevel::ClientIntrospection, + format!("codex mcp get 无法执行:{error}"), + ), + ); + return; + } + }; + let (url, has_helper, filter) = match parse_codex_mcp_get(&output.stdout, &output.stderr) { + CodexMcpGet::NotFound if project_entry => { + set_connection_cells( + column, + Cell::fail( + Cause::CodexProjectUntrusted, + EvidenceLevel::ClientIntrospection, + "项目 .codex/config.toml 已有 lian-jing,但 Codex 在该目录看不到——目录未受 Codex 信任(只加载受信任目录的项目配置,H-P8-1);在 Codex 中信任该项目后重试", + ), + ); + return; + } + CodexMcpGet::NotFound => { + set_connection_cells( + column, + Cell::fail( + Cause::NotConfigured, + EvidenceLevel::ClientIntrospection, + "Codex 在项目目录看不到 lian-jing,项目 .codex/config.toml 也没有该表;重跑 inject_mcp", + ), + ); + return; + } + CodexMcpGet::Unrecognized => { + set_connection_cells( + column, + Cell::unknown( + Cause::AdapterDrift, + EvidenceLevel::ClientIntrospection, + format!( + "codex mcp get 输出格式无法识别(退出码 {:?};客户端升级?需升级炼境适配器)", + output.exit_code + ), + ), + ); + return; + } + CodexMcpGet::Found { + enabled: false, + disabled_reason, + .. + } => { + set_connection_cells( + column, + Cell::fail( + Cause::ServerDisabled, + EvidenceLevel::ClientIntrospection, + format!( + "Codex 中 lian-jing 已禁用:{}", + disabled_reason.unwrap_or_else(|| "未给出原因".into()) + ), + ), + ); + return; + } + CodexMcpGet::Found { url: None, .. } => { + set_connection_cells( + column, + Cell::unknown( + Cause::AdapterDrift, + EvidenceLevel::ClientIntrospection, + "codex mcp get 未给出 transport.url(非 HTTP 传输?)", + ), + ); + return; + } + CodexMcpGet::Found { + url: Some(url), + has_helper, + enabled_tools, + disabled_tools, + .. + } => (url, has_helper, (enabled_tools, disabled_tools)), + }; + 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 ") { + set_connection_cells(column, cell); + return; + } + let helper = match (has_helper, table.helper) { + (true, Some(helper)) => Some(HelperCommand::Shell(helper.to_string())), + (true, None) => { + set_connection_cells( + column, + Cell::unknown( + Cause::NotConfigured, + EvidenceLevel::ClientIntrospection, + "Codex 生效配置带 helper(已被 Codex 脱敏),但项目 .codex/config.toml 里没有,L1 无法重放", + ), + ); + return; + } + (false, _) => None, + }; + if let Some(cell) = unmanaged_helper_cell(ctx, helper.as_ref(), "Codex ") { + set_connection_cells(column, cell); + return; + } + 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::>().join("、") + ), + ); + } + } + hide_invisible_tools( + &mut replayed, + &visible, + "Codex(enabled_tools/disabled_tools 过滤后)", + ); + replayed.apply(column); +} + +fn probe_dsh( + ctx: &Ctx, + whitelist: &ClientOutcome, + session_plugin: &StaticFinding, + helper: &HelperCommand, + column: &mut ClientColumn, +) { + match whitelist { + ClientOutcome::Converged => {} + ClientOutcome::Missing => { + column.route = Some(global_url_of(&ctx.project.expected_url)); + set_connection_cells( + column, + Cell::fail( + Cause::RouteGlobal, + EvidenceLevel::Static, + "DSH 托管块白名单未把本项目根目录映射到本项目(或端口不符),会话回退全局地址;重跑 inject_mcp", + ), + ); + return; + } + ClientOutcome::ManualKept => { + set_connection_cells( + column, + Cell::unknown( + Cause::ManualConfig, + EvidenceLevel::Static, + "DSH 补丁文件里是手写的 lian-jing 条目,炼境不评估手写 !!js", + ), + ); + return; + } + other => { + set_connection_cells( + column, + Cell::unknown( + Cause::NotConfigured, + EvidenceLevel::Static, + format!("DSH 托管块状态:{}", other.describe()), + ), + ); + return; + } + } + let url = ctx.project.expected_url.clone(); + column.route = Some(url.clone()); + let mut replayed = replay( + ctx, + &url, + Some(helper), + HelperFailure::ContinueUnauthenticated, + ); + if replayed.c.state == CellState::Ready { + if session_plugin.ok { + replayed.c.detail = format!("{};{}", replayed.c.detail, session_plugin.detail); + } else { + replayed.c = Cell::fail( + Cause::SessionPluginMismatch, + EvidenceLevel::Static, + format!( + "headless 白名单可用,但{}(桌面 GUI 会话不能按工作区连项目地址)", + session_plugin.detail + ), + ); + } + } + replayed.apply(column); +} + +fn global_url_of(scoped: &str) -> String { + scoped + .split_once("/mcp/projects/") + .map(|(base, _)| format!("{base}/mcp")) + .unwrap_or_else(|| scoped.to_string()) +} + +// ── 认证链重放 ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +enum HelperFailure { + /// Claude、Codex:helper 失败则连接不建立(H4 实测 Codex;Claude 按同口径保守处理) + Abort, + /// DSH 托管块 catch 后返回 {}:连接照常、不带认证头,只有 F 受影响 + ContinueUnauthenticated, +} + +struct Replay { + c: Cell, + d: Cell, + e: Cell, + f: Cell, +} + +impl Replay { + fn all(cell: Cell) -> Self { + Self { + c: cell.clone(), + d: cell.clone(), + e: cell.clone(), + f: cell, + } + } + + fn apply(self, column: &mut ClientColumn) { + column.cells.insert(Check::C, self.c); + column.cells.insert(Check::D, self.d); + column.cells.insert(Check::E, self.e); + column.cells.insert(Check::F, self.f); + } +} + +enum HelperRun { + Headers(Vec<(String, String)>), + Missing(String), + Failed(String), + TimedOut, +} + +/// 按客户端方式执行 helper。stdout 含认证头:只解析、不进入任何报错文本。 +fn run_helper(ctx: &Ctx, helper: &HelperCommand) -> HelperRun { + let (entry, tail) = match helper { + HelperCommand::Shell(line) => { + (helper_entry_path(line, ctx.deps.env), format!("\"{line}\"")) + } + HelperCommand::DshShim { shim, digest } => ( + Some(shim.clone()), + format!("\"\"{}\" {digest}\"", shim.display()), + ), + }; + if let Some(path) = entry { + if !path.is_file() { + return HelperRun::Missing(format!("helper 入口不存在:{}", path.display())); + } + } + let Some(timeout) = ctx.cap(HELPER_TIMEOUT) else { + return HelperRun::TimedOut; + }; + let spec = ProcessSpec { + program: ctx.deps.locator.cmd_exe(), + args: vec!["/d".into(), "/s".into(), "/c".into()], + raw_tail: Some(tail), + cwd: Some(ctx.project.root.to_path_buf()), + env_remove: Vec::new(), + env_set: Vec::new(), + timeout, + stop_at_stdout_line: None, + }; + match ctx.deps.runner.run(&spec) { + Err(error) => HelperRun::Failed(format!("helper 无法执行:{error}")), + Ok(output) if output.timed_out => HelperRun::TimedOut, + Ok(output) if output.exit_code != Some(0) => { + HelperRun::Failed(format!("helper 退出码 {:?}", output.exit_code)) + } + Ok(output) => match parse_helper_headers(&output.stdout) { + Some(headers) => HelperRun::Headers(headers), + None => HelperRun::Failed("helper 输出不是 JSON 请求头对象(内容不展示)".into()), + }, + } +} + +enum RpcError { + Unreachable(String), + Timeout, + Status(u16), + Protocol(String), +} + +fn rpc( + ctx: &Ctx, + url: &str, + headers: &[(String, String)], + id: u64, + method: &str, + params: Value, +) -> Result { + let timeout = ctx.cap(HTTP_TIMEOUT).ok_or(RpcError::Timeout)?; + let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + let reply = + ctx.deps + .http + .post(url, headers, &body, timeout) + .map_err(|failure| match failure { + HttpFailure::Unreachable(detail) => RpcError::Unreachable(detail), + HttpFailure::Timeout => RpcError::Timeout, + HttpFailure::Other(detail) => RpcError::Protocol(detail), + })?; + if reply.status != 200 { + return Err(RpcError::Status(reply.status)); + } + let body = reply + .body + .ok_or_else(|| RpcError::Protocol(format!("{method} 响应不是 JSON")))?; + if let Some(error) = body.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("未给出"); + return Err(RpcError::Protocol(format!( + "{method} 返回 JSON-RPC error:{message}" + ))); + } + body.get("result") + .cloned() + .ok_or_else(|| RpcError::Protocol(format!("{method} 响应缺少 result"))) +} + +fn rpc_failure_cell(error: RpcError, step: &str) -> Cell { + match error { + RpcError::Unreachable(detail) => Cell::fail( + Cause::Unreachable, + EvidenceLevel::Protocol, + format!("{step}:炼境 MCP 端口不可达({detail})"), + ), + RpcError::Timeout => Cell::timeout(EvidenceLevel::Protocol, format!("{step} 超时")), + RpcError::Status(404) => Cell::fail( + Cause::RouteMismatch, + EvidenceLevel::Protocol, + format!("{step}:项目地址返回 404(项目未登记?)"), + ), + RpcError::Status(403) => Cell::fail( + Cause::AuthFailed, + EvidenceLevel::Protocol, + format!("{step}:服务端拒绝(403)"), + ), + RpcError::Status(status) => Cell::fail( + Cause::ToolError, + EvidenceLevel::Protocol, + format!("{step}:HTTP {status}"), + ), + RpcError::Protocol(detail) => Cell::fail( + Cause::ToolError, + EvidenceLevel::Protocol, + format!("{step}:{detail}"), + ), + } +} + +fn tool_text(result: &Value) -> &str { + result + .get("content") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("text")) + .and_then(Value::as_str) + .unwrap_or("") +} + +fn is_tool_error(result: &Value) -> bool { + result.get("isError").and_then(Value::as_bool) == Some(true) +} + +/// 在客户端配置的地址上重放一遍连接与 C–F 调用。认证头只存在于本函数栈上。 +fn replay( + ctx: &Ctx, + url: &str, + helper: Option<&HelperCommand>, + on_failure: HelperFailure, +) -> Replay { + let mut headers: Vec<(String, String)> = Vec::new(); + // 未取得认证头时 F 的归因(DSH 降级为不带认证继续时使用) + let mut auth_issue: Option<(Cause, String)> = None; + match helper { + None => { + auth_issue = Some(( + Cause::AuthHeaderMissing, + "客户端配置没有 helper,请求不带认证头".into(), + )) + } + Some(helper) => { + let failure = match run_helper(ctx, helper) { + 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; + None + } + HelperRun::Missing(detail) => Some((Cause::ShimMissing, detail)), + HelperRun::Failed(detail) => Some((Cause::HelperFailed, detail)), + HelperRun::TimedOut => Some((Cause::Timeout, "helper 超时(已终止)".to_string())), + }; + if let Some((cause, detail)) = failure { + if on_failure == HelperFailure::Abort { + let detail = format!("{detail};该客户端取不到认证头时不建立连接"); + return Replay::all(if cause == Cause::Timeout { + Cell::timeout(EvidenceLevel::Protocol, detail) + } else { + Cell::fail(cause, EvidenceLevel::Protocol, detail) + }); + } + // DSH 的 helper 超时同样被 catch 成 {}:对 F 而言就是 helper 失败 + let cause = if cause == Cause::Timeout { + Cause::HelperFailed + } else { + cause + }; + auth_issue = Some((cause, format!("{detail};DSH 降级为不带认证连接"))); + } + } + } + let secrets: Vec = headers.iter().map(|(_, value)| value.clone()).collect(); + let mut result = replay_calls(ctx, url, &headers, auth_issue); + for cell in [&mut result.c, &mut result.d, &mut result.e, &mut result.f] { + scrub(&mut cell.detail, &secrets); + } + result +} + +fn replay_calls( + ctx: &Ctx, + url: &str, + headers: &[(String, String)], + auth_issue: Option<(Cause, String)>, +) -> Replay { + let init_params = json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": PROBE_CLIENT_NAME, "version": PROBE_SPEC_VERSION.to_string() } + }); + if let Err(error) = rpc(ctx, url, headers, 1, "initialize", init_params) { + return Replay::all(rpc_failure_cell(error, "initialize")); + } + let c = match rpc(ctx, url, headers, 2, "tools/list", json!({})) { + Err(error) => rpc_failure_cell(error, "tools/list"), + Ok(result) => { + let names: Vec = result + .get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + match tools_diff(&names, &ctx.project.expected_tools) { + None => Cell::ready( + EvidenceLevel::Protocol, + format!( + "tools/list 返回 {} 个工具,与项目工具集自省值一致", + names.len() + ), + ), + Some(diff) => Cell::fail( + Cause::ToolsMismatch, + EvidenceLevel::Protocol, + format!("tools/list 与项目工具集不一致:{diff}"), + ), + } + } + }; + let call = |id: u64, tool: &str, arguments: Value| { + rpc( + ctx, + url, + headers, + id, + "tools/call", + json!({ "name": tool, "arguments": arguments }), + ) + }; + let d = match call(3, D_TOOL, json!({})) { + Err(error) => rpc_failure_cell(error, D_TOOL), + Ok(result) if is_tool_error(&result) => Cell::fail( + Cause::ToolError, + EvidenceLevel::Protocol, + format!( + "get_diagnostics 返回错误:{}", + truncate(tool_text(&result), 80) + ), + ), + Ok(_) => Cell::ready(EvidenceLevel::Protocol, "get_diagnostics 正常返回"), + }; + let e = match call(4, E_TOOL, json!({})) { + Err(error) => rpc_failure_cell(error, E_TOOL), + Ok(result) if is_tool_error(&result) => Cell::fail( + Cause::ToolError, + EvidenceLevel::Protocol, + format!( + "list_project_dev_variables 返回错误:{}", + truncate(tool_text(&result), 80) + ), + ), + Ok(_) => Cell::ready( + EvidenceLevel::Protocol, + "list_project_dev_variables 正常返回", + ), + }; + // F 的结果文本可能是真实测试账户:只判断固定回答,任何情况下都不把内容放进报告 + let f = match call(5, F_TOOL, json!({ "name": ctx.nonce })) { + Err(RpcError::Status(403)) => match auth_issue { + Some((cause, detail)) => Cell::fail( + cause, + EvidenceLevel::Protocol, + format!("read_project_test_account 被拒(403):{detail}"), + ), + None => Cell::fail( + Cause::AuthFailed, + EvidenceLevel::Protocol, + "read_project_test_account 被拒(403):helper 给出的认证与本项目 token 不符", + ), + }, + Err(error) => rpc_failure_cell(error, F_TOOL), + Ok(result) if is_tool_error(&result) && tool_text(&result).contains(UNREGISTERED_NEED) => { + Cell::ready( + EvidenceLevel::Protocol, + format!( + "认证通过:read_project_test_account({}) 返回「{UNREGISTERED_NEED}」", + ctx.nonce + ), + ) + } + Ok(_) => Cell::unknown( + Cause::ToolError, + EvidenceLevel::Protocol, + "read_project_test_account 返回了非预期结果(内容不展示)", + ), + }; + Replay { c, d, e, f } +} + +fn scrub(text: &mut String, secrets: &[String]) { + for secret in secrets.iter().filter(|secret| secret.len() >= 8) { + if text.contains(secret.as_str()) { + *text = text.replace(secret.as_str(), ""); + } + if let Some(bare) = secret.strip_prefix("Bearer ") { + if bare.len() >= 8 && text.contains(bare) { + *text = text.replace(bare, ""); + } + } + } +} + +fn truncate(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let head: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{head}…") + } else { + head + } +} + +/// 工具名集合差异:一致返回 None,否则给出缺少/多出的数量与前几个名字。 +fn tools_diff(actual: &[String], expected: &[String]) -> Option { + let actual: BTreeSet<&str> = actual.iter().map(String::as_str).collect(); + let expected: BTreeSet<&str> = expected.iter().map(String::as_str).collect(); + let missing: Vec<&str> = expected.difference(&actual).copied().collect(); + let extra: Vec<&str> = actual.difference(&expected).copied().collect(); + if missing.is_empty() && extra.is_empty() { + return None; + } + let sample = |names: &[&str]| names.iter().take(3).copied().collect::>().join("、"); + Some(format!( + "缺少 {} 个({}),多出 {} 个({})", + missing.len(), + sample(&missing), + extra.len(), + sample(&extra) + )) +} + +// ── 客户端输出解析(纯函数,P4-0 夹具校验)──────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaudeScope { + Local, + /// user / project 等非 local 作用域(原文) + Other(String), + /// 本目录没有任何 lian-jing 条目 + Absent, +} + +impl ClaudeScope { + /// 对应的配置作用域(回查 headersHelper 时优先采用)。Scope 行原文:Local config / Project config + /// (shared via .mcp.json) / User config;认不出为 None(退回按 URL 取第一条)。 + pub fn entry_source(&self) -> Option { + 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)] +pub struct ClaudeMcpGet { + pub scope: ClaudeScope, + pub url: Option, + /// Status 行:Connected 为 true;其他状态为 false;没有该行为 None。取不到 init 时 L1 采信其中的「未连接」。 + pub connected: Option, +} + +/// 解析 `claude mcp get ` 的人类可读输出。格式无法识别返回 None(按「未知」处理)。 +pub fn parse_claude_mcp_get(output: &str) -> Option { + if output.contains("No MCP server found") { + return Some(ClaudeMcpGet { + scope: ClaudeScope::Absent, + url: None, + connected: None, + }); + } + let field = |name: &str| { + output.lines().find_map(|line| { + line.trim() + .strip_prefix(name) + .map(|value| value.trim().to_string()) + }) + }; + let scope = field("Scope:")?; + Some(ClaudeMcpGet { + scope: if scope.starts_with("Local config") { + ClaudeScope::Local + } else { + ClaudeScope::Other(scope) + }, + url: field("URL:"), + connected: field("Status:").map(|status| { + let lower = status.to_lowercase(); + lower.contains("connected") && !lower.contains("fail") + }), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeInit { + pub version: Option, + /// mcp_servers 中该服务器的状态(connected / failed / pending …) + pub server_status: Option, + /// 该服务器的工具名(去掉 mcp____ 前缀) + pub tools: Vec, + pub skills: Vec, +} + +/// 从 `claude -p --output-format stream-json --verbose` 的输出中取 system/init 事件。逐行解析,容忍 CRLF。 +pub fn parse_claude_init(stream: &str, server: &str) -> Option { + let prefix = format!("mcp__{server}__"); + stream.lines().find_map(|line| { + let event: Value = serde_json::from_str(line.trim_end_matches('\r')).ok()?; + if event.get("type")?.as_str()? != "system" || event.get("subtype")?.as_str()? != "init" { + return None; + } + let strings = |key: &str| -> Vec { + event + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + }; + Some(ClaudeInit { + version: event + .get("claude_code_version") + .and_then(Value::as_str) + .map(str::to_string), + server_status: event + .get("mcp_servers") + .and_then(Value::as_array) + .and_then(|servers| { + servers + .iter() + .find(|entry| entry.get("name").and_then(Value::as_str) == Some(server)) + }) + .and_then(|entry| entry.get("status")) + .and_then(Value::as_str) + .map(str::to_string), + tools: strings("tools") + .into_iter() + .filter_map(|tool| tool.strip_prefix(prefix.as_str()).map(str::to_string)) + .collect(), + skills: strings("skills"), + }) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodexMcpGet { + Found { + enabled: bool, + disabled_reason: Option, + url: Option, + /// transport.http_headers_helper 非空(Codex 输出时脱敏为 "") + has_helper: bool, + /// 工具白名单(null 为不限) + enabled_tools: Option>, + /// 工具黑名单(null 为无) + disabled_tools: Option>, + }, + NotFound, + Unrecognized, +} + +/// 解析 `codex mcp get --json`。 +pub fn parse_codex_mcp_get(stdout: &str, stderr: &str) -> CodexMcpGet { + if let Ok(value) = serde_json::from_str::(stdout.trim()) { + if value.get("name").and_then(Value::as_str).is_some() { + let transport = value.get("transport"); + return CodexMcpGet::Found { + enabled: value + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(true), + disabled_reason: value + .get("disabled_reason") + .and_then(Value::as_str) + .map(str::to_string), + url: transport + .and_then(|transport| transport.get("url")) + .and_then(Value::as_str) + .map(str::to_string), + has_helper: transport + .and_then(|transport| transport.get("http_headers_helper")) + .is_some_and(|helper| !helper.is_null()), + enabled_tools: string_list(value.get("enabled_tools")), + disabled_tools: string_list(value.get("disabled_tools")), + }; + } + } + if stdout.contains("No MCP server named") || stderr.contains("No MCP server named") { + CodexMcpGet::NotFound + } else { + CodexMcpGet::Unrecognized + } +} + +/// JSON 字符串数组;null 或缺失为 None。 +fn string_list(value: Option<&Value>) -> Option> { + 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)。 +pub fn parse_codex_version(stdout: &str) -> Option { + stdout + .lines() + .next()? + .split_whitespace() + .last() + .filter(|token| token.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + .map(str::to_string) +} + +/// helper 输出的请求头对象:只取字符串值。非 JSON 对象返回 None。 +fn parse_helper_headers(stdout: &str) -> Option> { + let value: Value = serde_json::from_str(stdout.trim().trim_start_matches('\u{feff}')).ok()?; + let object = value.as_object()?; + Some( + object + .iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string()))) + .collect(), + ) +} + +/// helper 命令行的第一个词(可执行入口),%VAR% 按 env 展开。展开不完整或不是绝对路径时返回 None(不预判)。 +pub fn helper_entry_path( + line: &str, + env: &(dyn Fn(&str) -> Option + Sync), +) -> Option { + let line = line.trim(); + let first = match line.strip_prefix('"') { + Some(rest) => rest.split_once('"').map(|(first, _)| first)?, + None => line.split_whitespace().next()?, + }; + let expanded = expand_percent_vars(first, env)?; + let path = PathBuf::from(expanded); + path.is_absolute().then_some(path) +} + +/// 展开 cmd 风格的 %NAME%;有未定义的变量返回 None。 +pub fn expand_percent_vars( + text: &str, + env: &(dyn Fn(&str) -> Option + Sync), +) -> Option { + let mut out = String::new(); + let mut rest = text; + while let Some(start) = rest.find('%') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + let end = after.find('%')?; + out.push_str(&env(&after[..end])?); + rest = &after[end + 1..]; + } + out.push_str(rest); + Some(out) +} + +/// 项目 .codex/config.toml 是否定义了 [mcp_servers.lian-jing]。 +pub fn codex_has_table(config: &str) -> bool { + config + .lines() + .any(|line| line.trim() == format!("[mcp_servers.{SERVER_NAME}]")) +} + +/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的 http_headers_helper(基本字符串或字面量字符串)。 +pub fn codex_table_helper(config: &str) -> Option { + codex_table_string(config, "http_headers_helper") +} + +/// 项目 .codex/config.toml 里 [mcp_servers.lian-jing] 表的 url。 +pub fn codex_table_url(config: &str) -> Option { + codex_table_string(config, "url") +} + +/// [mcp_servers.lian-jing] 表里某个字符串键的值(单行基本字符串或字面量字符串)。 +fn codex_table_string(config: &str, key: &str) -> Option { + let header = format!("[mcp_servers.{SERVER_NAME}]"); + let mut in_table = false; + for line in config.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_table = line == header; + continue; + } + if !in_table { + continue; + } + let Some(value) = line + .strip_prefix(key) + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix('=')) + .map(str::trim) + else { + continue; + }; + if value.starts_with('"') { + // TOML 基本字符串的转义(\" \\)与 JSON 字符串一致:炼境托管块即以 JSON 序列化写入 + return serde_json::from_str::(value).ok(); + } + return value + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .map(str::to_string); + } + None +} + +/// A:规则文件存在;Claude 读 CLAUDE.md,项目有 AGENTS.md 时须经 @AGENTS.md 引入。 +pub fn rules_finding(root: &Path, file: &str, must_import_agents: bool) -> StaticFinding { + match std::fs::read_to_string(root.join(file)) { + Err(_) => StaticFinding { + ok: false, + detail: format!("缺少 {file},客户端启动时读不到项目规则;重跑接入包"), + }, + Ok(content) + if must_import_agents + && root.join("AGENTS.md").is_file() + && !content.contains("@AGENTS.md") => + { + StaticFinding { + ok: false, + detail: format!("{file} 未引用 @AGENTS.md,Claude 读不到项目规则单一来源"), + } + } + Ok(_) => StaticFinding { + ok: true, + detail: format!("{file} 存在(客户端启动即读)"), + }, + } +} + +/// B:lian-jing skill 已分发到该端读取的位置。 +pub fn skill_finding(root: &Path, relative: &str) -> StaticFinding { + match std::fs::read_to_string(root.join(relative)) { + Ok(content) if content.contains("name: lian-jing") => StaticFinding { + ok: true, + detail: format!("{relative} 存在"), + }, + _ => StaticFinding { + ok: false, + detail: format!("缺少 {relative};重跑接入包"), + }, + } +} + +/// 从 Electron 安装包 app.asar 读根 package.json 的 version(DSH 版本,不起进程)。 +/// asar 布局:[u32 4][u32 头 pickle 大小 H][u32][u32 JSON 长度 L][JSON 头],文件数据起点 8 + H。 +pub fn read_asar_package_version(asar: &Path) -> Option { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(asar).ok()?; + let mut prefix = [0u8; 16]; + file.read_exact(&mut prefix).ok()?; + let word = |at: usize| { + u32::from_le_bytes([prefix[at], prefix[at + 1], prefix[at + 2], prefix[at + 3]]) + }; + let (header_size, json_len) = (word(4) as u64, word(12) as usize); + if json_len > 64 * 1024 * 1024 { + return None; + } + let mut header = vec![0u8; json_len]; + file.read_exact(&mut header).ok()?; + let header: Value = serde_json::from_slice(&header).ok()?; + let entry = header.get("files")?.get("package.json")?; + let size = entry.get("size")?.as_u64()?; + let offset: u64 = match entry.get("offset")? { + Value::String(text) => text.parse().ok()?, + other => other.as_u64()?, + }; + if size > 1024 * 1024 { + return None; + } + file.seek(SeekFrom::Start(8 + header_size + offset)).ok()?; + let mut package = vec![0u8; size as usize]; + file.read_exact(&mut package).ok()?; + let package: Value = serde_json::from_slice(&package).ok()?; + package + .get("version") + .and_then(Value::as_str) + .map(str::to_string) +} + +pub fn system_cmd_exe() -> PathBuf { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .filter(|root| root.is_absolute()) + .map(|root| root.join("System32").join("cmd.exe")) + .unwrap_or_else(|| PathBuf::from("cmd.exe")) +} + +// ── 生产实现:子进程与 HTTP ───────────────────────────────────────────────── + +/// 真实子进程执行器:不弹窗、stdin 置空、超时或命中停止标记即结束进程(只结束直接子进程; +/// 进程树清理见 P4d 的 Job Object)。 +pub struct SystemRunner; + +impl ProcessRunner for SystemRunner { + fn run(&self, spec: &ProcessSpec) -> Result { + use std::io::{BufRead, BufReader, Read}; + use std::process::Stdio; + use std::sync::mpsc::{channel, RecvTimeoutError}; + + let mut command = crate::silent_cmd(&spec.program.to_string_lossy()); + command.args(&spec.args); + if let Some(tail) = &spec.raw_tail { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.raw_arg(tail); + } + #[cfg(not(windows))] + command.arg(tail); + } + if let Some(cwd) = &spec.cwd { + command.current_dir(cwd); + } + for (key, _) in std::env::vars_os() { + let Some(name) = key.to_str() else { continue }; + let upper = name.to_ascii_uppercase(); + let removed = spec.env_remove.iter().any(|pattern| { + upper == *pattern || (pattern.ends_with('_') && upper.starts_with(pattern.as_str())) + }); + if removed { + command.env_remove(name); + } + } + for (key, value) in &spec.env_set { + command.env(key, value); + } + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("启动 {} 失败: {error}", spec.program.display()))?; + let stdout = child.stdout.take().ok_or("无法读取子进程 stdout")?; + let mut stderr = child.stderr.take().ok_or("无法读取子进程 stderr")?; + let (line_tx, line_rx) = channel::(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + let mut buffer = Vec::new(); + loop { + buffer.clear(); + // 单行也有上限:不换行的输出不会在读线程里无限累积 + match (&mut reader) + .take(MAX_CAPTURE as u64) + .read_until(b'\n', &mut buffer) + { + Ok(0) | Err(_) => break, + Ok(_) => { + if line_tx + .send(String::from_utf8_lossy(&buffer).into_owned()) + .is_err() + { + break; + } + } + } + } + }); + let (err_tx, err_rx) = channel::(); + std::thread::spawn(move || { + // 只保留前 MAX_CAPTURE 字节,其余读掉丢弃:持续刷 stderr 的进程不会撑爆内存 + let mut kept = Vec::new(); + 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 mut output = ProcessOutput::default(); + let mut exit = None; + let mut closed = false; + // 每轮都查退出与超时,收到行也不跳过:否则每 50 毫秒内至少输出一行的进程永不超时(审查 ①) + loop { + let wait = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(50)); + match line_rx.recv_timeout(wait) { + Ok(line) => { + let hit = spec + .stop_at_stdout_line + .as_deref() + .is_some_and(|marker| line.contains(marker)); + if output.stdout.len() < MAX_CAPTURE { + output.stdout.push_str(&line); + } + if hit { + output.stopped_early = true; + break; + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + if !closed { + closed = true; + } else { + std::thread::sleep(Duration::from_millis(20)); + } + } + } + if let Ok(Some(status)) = child.try_wait() { + exit = Some(status); + break; + } + if Instant::now() >= deadline { + output.timed_out = true; + break; + } + } + match exit { + Some(status) => { + output.exit_code = status.code(); + // 进程已退出:收齐管道里剩下的行(孙进程可能仍占着管道,最多等 300 毫秒) + let drain_until = Instant::now() + Duration::from_millis(300); + while let Ok(line) = + line_rx.recv_timeout(drain_until.saturating_duration_since(Instant::now())) + { + if output.stdout.len() < MAX_CAPTURE { + output.stdout.push_str(&line); + } + } + } + None => { + let _ = child.kill(); + let _ = child.wait(); + } + } + output.stderr = err_rx + .recv_timeout(Duration::from_millis(300)) + .unwrap_or_default(); + Ok(output) + } +} + +/// 超时按 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::() { + if matches!( + io.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) { + return true; + } + } + current = error.source(); + } + false +} + +/// 真实 HTTP:ureq 单次请求,不复用连接、不记录请求头。 +pub struct UreqHttp; + +impl UreqHttp { + /// 读响应正文:头已到、正文读超时同样是环境类超时,不能记成「响应不是 JSON」。 + fn reply(status: u16, response: ureq::Response) -> Result { + match response.into_json::() { + 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 { + fn post( + &self, + url: &str, + headers: &[(String, String)], + body: &Value, + timeout: Duration, + ) -> Result { + let mut request = ureq::post(url).timeout(timeout); + for (name, value) in headers { + request = request.set(name, value); + } + match request.send_json(body.clone()) { + Ok(response) => Self::reply(response.status(), response), + Err(ureq::Error::Status(status, response)) => Self::reply(status, response), + Err(ureq::Error::Transport(transport)) => Err(match transport.kind() { + ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::Dns => { + HttpFailure::Unreachable(format!("{:?}", transport.kind())) + } + _ if is_timeout_error(&transport) => HttpFailure::Timeout, + kind => HttpFailure::Other(format!("{kind:?}")), + }), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/onboard/probe_l1/tests.rs b/src-tauri/src/onboard/probe_l1/tests.rs new file mode 100644 index 0000000..d28a6c3 --- /dev/null +++ b/src-tauri/src/onboard/probe_l1/tests.rs @@ -0,0 +1,1709 @@ +//! L1 检查的单元测试:进程内桩 MCP 服务 + 哑 shim(真实 cmd.exe 执行)+ 假客户端 CLI。 +//! 不起任何真实客户端(claude、codex、DSH),不读真实凭据存储;桩 token 只存在于测试进程内。 + +use super::*; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +const PID: &str = "qa-only-l1-project"; +const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/client_probe"); + +fn fixture(name: &str) -> String { + std::fs::read_to_string(Path::new(FIXTURES).join(name)).expect("读取 P4-0 夹具") +} + +fn catalog_names() -> Vec { + crate::mcp_tools::tools_list_result_for_project(PID)["tools"] + .as_array() + .expect("tools 数组") + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect() +} + +// ── 进程内桩 MCP 服务 ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct StubCall { + method: String, + tool: Option, + client_name: Option, + authorized: bool, +} + +/// 只记方法、工具名、clientInfo 与认证是否匹配,不记请求头原文。60 秒后自动退出。 +struct StubServer { + port: u16, + calls: Arc>>, + stop: Arc, + handle: Option>, +} + +impl StubServer { + fn start(token: &str, tools: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("绑定桩端口"); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let calls = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let expected_auth = format!("Bearer {token}"); + let tool_list: Vec = tools + .iter() + .map(|name| json!({ "name": name, "inputSchema": { "type": "object" } })) + .collect(); + let (calls_for_server, stop_for_server) = (Arc::clone(&calls), Arc::clone(&stop)); + let handle = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(60); + while !stop_for_server.load(Ordering::SeqCst) && Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) => { + serve(stream, &expected_auth, &tool_list, &calls_for_server); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + Self { + port, + calls, + stop, + handle: Some(handle), + } + } + + fn url(&self) -> String { + format!("http://127.0.0.1:{}/mcp/projects/{PID}", self.port) + } + + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl Drop for StubServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn serve( + mut stream: TcpStream, + expected_auth: &str, + tools: &[Value], + calls: &Mutex>, +) { + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(Duration::from_secs(3))); + let mut buffer = Vec::new(); + let mut chunk = [0u8; 8192]; + let header_end = loop { + let Ok(count) = stream.read(&mut chunk) else { + return; + }; + if count == 0 { + return; + } + buffer.extend_from_slice(&chunk[..count]); + if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let mut lines = head.split("\r\n"); + let path = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("") + .to_string(); + let mut content_length = 0usize; + let mut authorization = None; + for line in lines { + if let Some((name, value)) = line.split_once(':') { + match name.trim().to_ascii_lowercase().as_str() { + "content-length" => content_length = value.trim().parse().unwrap_or(0), + "authorization" => authorization = Some(value.trim().to_string()), + _ => {} + } + } + } + while buffer.len() < header_end + content_length { + let Ok(count) = stream.read(&mut chunk) else { + return; + }; + if count == 0 { + break; + } + buffer.extend_from_slice(&chunk[..count]); + } + let body: Value = serde_json::from_slice(&buffer[header_end..]).unwrap_or(Value::Null); + let method = body["method"].as_str().unwrap_or("").to_string(); + let tool = body["params"]["name"].as_str().map(str::to_string); + let authorized = authorization.as_deref() == Some(expected_auth); + calls.lock().unwrap().push(StubCall { + method: method.clone(), + tool: tool.clone(), + client_name: body["params"]["clientInfo"]["name"] + .as_str() + .map(str::to_string), + authorized, + }); + let id = body["id"].clone(); + let ok = |result: Value| { + ( + 200, + json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string(), + ) + }; + let (status, text) = if path != format!("/mcp/projects/{PID}") { + ( + 404, + json!({ "error": { "message": "项目未登记" } }).to_string(), + ) + } else { + match method.as_str() { + "initialize" => ok(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "stub" } + })), + "tools/list" => ok(json!({ "tools": tools })), + "tools/call" => match tool.as_deref() { + // 与炼境一致:敏感读取在 dispatch 前认证,失败返回 403 空体 + Some("read_project_test_account") if !authorized => (403, String::new()), + Some("read_project_test_account") => ok(json!({ + "content": [{ "type": "text", "text": "开发需求未登记" }], + "isError": true + })), + Some("get_diagnostics") | Some("list_project_dev_variables") => { + ok(json!({ "content": [{ "type": "text", "text": "ok" }] })) + } + _ => ok( + json!({ "content": [{ "type": "text", "text": "未知工具" }], "isError": true }), + ), + }, + _ => ( + 200, + json!({ "jsonrpc": "2.0", "id": id, "error": { "message": "Method not found" } }) + .to_string(), + ), + } + }; + let response = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", + text.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +// ── 假客户端 CLI 与定位 ────────────────────────────────────────────────────── + +/// 拦截 fake-claude / fake-codex,其余(cmd.exe 执行哑 shim)交给真实执行器。 +struct FakeRunner { + claude_get: String, + claude_init: String, + codex_get: (String, String), + calls: Mutex>, +} + +impl FakeRunner { + fn ready(url: &str) -> Self { + let tools: Vec = catalog_names() + .iter() + .map(|name| format!("mcp__lian-jing__{name}")) + .chain(["Read".to_string(), "Skill".to_string()]) + .collect(); + Self { + claude_get: claude_get_output("Local config (private to you in this project)", url), + claude_init: format!( + "{}\n{}\n", + json!({ + "type": "system", "subtype": "init", "tools": tools, + "mcp_servers": [{ "name": "lian-jing", "status": "connected" }], + "skills": ["architect", "lian-jing"], "claude_code_version": "2.1.173" + }), + json!({ "type": "result", "subtype": "success", "total_cost_usd": 0 }) + ), + codex_get: ( + json!({ + "name": "lian-jing", "enabled": true, "disabled_reason": null, + "transport": { "type": "streamable_http", "url": url, "http_headers_helper": "" }, + "enabled_tools": null, "disabled_tools": null + }) + .to_string(), + String::new(), + ), + calls: Mutex::new(Vec::new()), + } + } + + fn specs(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn count(&self, program: &str) -> usize { + self.specs() + .iter() + .filter(|spec| spec.program.to_string_lossy().ends_with(program)) + .count() + } +} + +fn claude_get_output(scope: &str, url: &str) -> String { + format!( + "lian-jing:\n Scope: {scope}\n Status: ✔ Connected\n Type: http\n URL: {url}\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n" + ) +} + +impl ProcessRunner for FakeRunner { + fn run(&self, spec: &ProcessSpec) -> Result { + self.calls.lock().unwrap().push(spec.clone()); + let program = spec.program.to_string_lossy().to_string(); + let output = |stdout: &str, stderr: &str, exit_code: Option, stopped_early: bool| { + Ok(ProcessOutput { + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + timed_out: false, + stopped_early, + }) + }; + if program.ends_with("fake-claude") { + if spec.args.first().map(String::as_str) == Some("mcp") { + return output(&self.claude_get, "", Some(0), false); + } + // 真实执行器读到 init 行即结束进程 + let init = self.claude_init.lines().next().unwrap_or("").to_string() + "\n"; + return output(&init, "", None, true); + } + if program.ends_with("fake-codex") { + if spec.args == ["--version"] { + return output("codex-cli 0.155.0-alpha.9.2\n", "", Some(0), false); + } + let code = if self.codex_get.0.is_empty() { 1 } else { 0 }; + return output(&self.codex_get.0, &self.codex_get.1, Some(code), false); + } + SystemRunner.run(spec) + } +} + +struct FakeLocator { + installed: bool, +} + +impl ClientLocator for FakeLocator { + fn locate(&self, client: ClientId) -> Option { + if !self.installed { + return None; + } + Some(match client { + ClientId::Claude => Located { + program: Some(PathBuf::from("C:/fake/fake-claude")), + version: None, + }, + ClientId::Codex => Located { + program: Some(PathBuf::from("C:/fake/fake-codex")), + version: None, + }, + ClientId::Dsh => Located { + program: None, + version: Some("0.2.0-rc.2".into()), + }, + }) + } + + fn git_bash(&self) -> Option { + Some(PathBuf::from("C:/fake/bash.exe")) + } +} + +// ── 夹具:项目目录与哑 shim ────────────────────────────────────────────────── + +struct Fixture { + dir: PathBuf, + root: PathBuf, + token: String, + good_shim: PathBuf, + wrong_shim: PathBuf, + missing_shim: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("lj-p4a-l1-{}", uuid::Uuid::new_v4().simple())); + let root = dir.join("project"); + for skill_dir in [".agents/skills/lian-jing", ".claude/skills/lian-jing"] { + std::fs::create_dir_all(root.join(skill_dir)).unwrap(); + std::fs::write( + root.join(skill_dir).join("SKILL.md"), + "---\nname: lian-jing\n---\n", + ) + .unwrap(); + } + std::fs::write(root.join("AGENTS.md"), "# rules\n").unwrap(); + std::fs::write(root.join("CLAUDE.md"), "@AGENTS.md\n").unwrap(); + let token = format!("qa-stub-token-{}", uuid::Uuid::new_v4().simple()); + let write_shim = |sub: &str, value: &str| { + let bin = dir.join(sub); + std::fs::create_dir_all(&bin).unwrap(); + let shim = bin.join("lianjing-mcp-header.cmd"); + std::fs::write( + &shim, + format!("@echo off\r\necho {{\"Authorization\":\"Bearer {value}\"}}\r\n"), + ) + .unwrap(); + shim + }; + let good_shim = write_shim("bin", &token); + let wrong_shim = write_shim("bin-wrong", "qa-wrong-token-0000000000000000"); + let missing_shim = dir.join("bin-missing").join("lianjing-mcp-header.cmd"); + Self { + dir, + root, + token, + good_shim, + wrong_shim, + missing_shim, + } + } + + fn facts(&self, url: &str, shim: &Path) -> Vec { + let line = helper_line(shim); + let agents_skill = || skill_finding(&self.root, ".agents/skills/lian-jing/SKILL.md"); + vec![ + L1ClientFacts { + client: ClientId::Claude, + not_applicable: None, + rules: rules_finding(&self.root, "CLAUDE.md", true), + skill: skill_finding(&self.root, ".claude/skills/lian-jing/SKILL.md"), + source: L1Source::Claude { + // 手写条目可能带静态认证头:零模型 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 { + client: ClientId::Codex, + not_applicable: None, + rules: rules_finding(&self.root, "AGENTS.md", false), + skill: agents_skill(), + source: L1Source::Codex { + project_entry: true, + project_url: Some(url.to_string()), + project_helper: Some(line.clone()), + }, + }, + L1ClientFacts { + client: ClientId::Dsh, + not_applicable: None, + rules: rules_finding(&self.root, "AGENTS.md", false), + skill: agents_skill(), + source: L1Source::Dsh { + whitelist: ClientOutcome::Converged, + session_plugin: StaticFinding { + ok: true, + detail: "会话级插件哈希一致".into(), + }, + helper: HelperCommand::DshShim { + shim: shim.to_path_buf(), + digest: DIGEST.into(), + }, + }, + }, + ] + } + + fn project(&self, url: &str) -> L1Project<'_> { + L1Project { + project_id: PID, + root: &self.root, + control_plane: false, + expected_url: url.to_string(), + 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 { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn no_env(_: &str) -> Option { + None +} + +fn run( + project: &L1Project, + facts: &[L1ClientFacts], + runner: &FakeRunner, + installed: bool, + budget: Duration, +) -> L1Report { + let locator = FakeLocator { installed }; + let deps = L1Deps { + runner, + http: &UreqHttp, + locator: &locator, + env: &no_env, + budget, + }; + run_l1(project, facts, &deps) +} + +fn column(report: &L1Report, client: ClientId) -> &ClientColumn { + report + .clients + .iter() + .find(|column| column.client == client) + .expect("该端的列") +} + +fn cell(report: &L1Report, client: ClientId, check: Check) -> &Cell { + &column(report, client).cells[&check] +} + +const ALL: [ClientId; 3] = [ClientId::Claude, ClientId::Codex, ClientId::Dsh]; + +// ── 端到端:桩服务 + 哑 shim ──────────────────────────────────────────────── + +#[test] +fn all_cells_ready_with_evidence_levels_and_no_token_in_output() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.good_shim), + &runner, + true, + Duration::from_secs(30), + ); + assert!(report.all_applicable_ready, "{report:#?}"); + assert_eq!(report.tier, "l1"); + assert_eq!(report.expected_tools, catalog_names().len()); + assert!(report.nonce.starts_with("LJ_PROBE_") && report.nonce.len() == 17); + for client in ALL { + let col = column(&report, client); + assert!(col.version.is_some(), "{client:?} 应附版本号"); + assert_eq!(col.route.as_deref(), Some(url.as_str())); + for check in [Check::C, Check::D, Check::E, Check::F] { + let got = cell(&report, client, check); + assert_eq!(got.state, CellState::Ready, "{client:?} {check:?}: {got:?}"); + assert_eq!( + got.evidence, + EvidenceLevel::Protocol, + "{client:?} {check:?}" + ); + } + assert_eq!( + cell(&report, client, Check::A).evidence, + EvidenceLevel::Static + ); + } + assert_eq!( + column(&report, ClientId::Claude).version.as_deref(), + Some("2.1.173") + ); + assert_eq!( + column(&report, ClientId::Codex).version.as_deref(), + Some("0.155.0-alpha.9.2") + ); + assert_eq!( + cell(&report, ClientId::Claude, Check::B).evidence, + EvidenceLevel::ClientIntrospection + ); + assert_eq!( + cell(&report, ClientId::Codex, Check::B).evidence, + EvidenceLevel::Static + ); + assert!(cell(&report, ClientId::Codex, Check::F) + .detail + .contains(&report.nonce)); + + // 服务端只看到探针身份,三端各一轮、F 均带对认证 + let calls = stub.calls(); + let inits: Vec<&StubCall> = calls + .iter() + .filter(|call| call.method == "initialize") + .collect(); + assert_eq!(inits.len(), 3); + assert!(inits + .iter() + .all(|call| call.client_name.as_deref() == Some(PROBE_CLIENT_NAME))); + let reads: Vec<&StubCall> = calls + .iter() + .filter(|call| call.tool.as_deref() == Some("read_project_test_account")) + .collect(); + assert_eq!(reads.len(), 3); + assert!(reads.iter().all(|call| call.authorized)); + + // 认证头不进结果、不进进程参数 + let rendered = serde_json::to_string(&report).unwrap(); + assert!(!rendered.contains(&fx.token), "结果 JSON 含桩 token"); + assert!(!format!("{:?}", runner.specs()).contains(&fx.token)); + + // Claude 零模型 init 的参数与子进程卫生 + let init = runner + .specs() + .into_iter() + .find(|spec| { + spec.program.ends_with("fake-claude") + && spec.args.first().map(String::as_str) == Some("-p") + }) + .expect("应起一次零模型 init"); + for expected in [ + ZERO_MODEL, + "--strict-mcp-config", + "--no-session-persistence", + "stream-json", + ] { + assert!( + init.args.iter().any(|arg| arg == 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 + .env_set + .contains(&("MCP_CONNECTION_NONBLOCKING".into(), "false".into()))); + assert!(init + .env_set + .iter() + .any(|(key, _)| key == "CLAUDE_CODE_GIT_BASH_PATH")); + assert!(init.env_remove.contains(&"CLAUDECODE".to_string())); + assert!(init.env_remove.contains(&"CLAUDE_CODE_".to_string())); + assert_eq!(init.cwd.as_deref(), Some(fx.root.as_path())); + assert_eq!( + init.stop_at_stdout_line.as_deref(), + Some(CLAUDE_INIT_MARKER) + ); + let codex_get = runner + .specs() + .into_iter() + .find(|spec| { + spec.program.ends_with("fake-codex") + && spec.args.first().map(String::as_str) == Some("mcp") + }) + .expect("应起一次 codex mcp get"); + assert_eq!(codex_get.args, ["mcp", "get", "lian-jing", "--json"]); + assert_eq!(codex_get.cwd.as_deref(), Some(fx.root.as_path())); +} + +#[test] +fn wrong_token_from_shim_fails_only_f_with_auth_failed() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.wrong_shim), + &runner, + true, + Duration::from_secs(30), + ); + assert!(!report.all_applicable_ready); + 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.state, CellState::Fail, "{client:?}"); + assert_eq!(f.cause, Some(Cause::AuthFailed), "{client:?}"); + assert_eq!(f.evidence, EvidenceLevel::Protocol); + } + let rendered = serde_json::to_string(&report).unwrap(); + assert!(!rendered.contains("qa-wrong-token-0000000000000000")); +} + +#[test] +fn missing_shim_aborts_claude_and_codex_but_dsh_degrades_to_f_only() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.missing_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.cause, + Some(Cause::ShimMissing), + "{client:?} {check:?}: {got:?}" + ); + } + } + for check in [Check::C, Check::D, Check::E] { + assert_eq!(cell(&report, ClientId::Dsh, check).state, CellState::Ready); + } + assert_eq!( + cell(&report, ClientId::Dsh, Check::F).cause, + Some(Cause::ShimMissing) + ); + // shim 缺失在执行前判定,不起 cmd + assert_eq!(runner.count("cmd.exe"), 0); + let reads: Vec = stub + .calls() + .into_iter() + .filter(|call| call.tool.as_deref() == Some("read_project_test_account")) + .collect(); + assert_eq!(reads.len(), 1, "只有 DSH 降级为不带认证连接"); + assert!(!reads[0].authorized); +} + +#[test] +fn unreachable_port_marks_c_to_f_unreachable() { + let fx = Fixture::new(); + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("http://127.0.0.1:{port}/mcp/projects/{PID}"); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.good_shim), + &runner, + true, + Duration::from_secs(30), + ); + for client in ALL { + for check in [Check::C, Check::D, Check::E, Check::F] { + let got = cell(&report, client, check); + assert_eq!( + got.cause, + Some(Cause::Unreachable), + "{client:?} {check:?}: {got:?}" + ); + assert_eq!(got.state, CellState::Fail); + } + } +} + +#[test] +fn not_installed_clients_spawn_nothing_and_do_not_count_as_failure() { + let fx = Fixture::new(); + let url = format!("http://127.0.0.1:9/mcp/projects/{PID}"); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.good_shim), + &runner, + false, + Duration::from_secs(30), + ); + assert!(report.all_applicable_ready); + for client in ALL { + assert!(column(&report, client) + .cells + .values() + .all(|cell| cell.state == CellState::NotInstalled)); + } + assert!(runner.specs().is_empty(), "未安装的端不得起任何进程"); +} + +#[test] +fn control_plane_marks_claude_and_dsh_not_applicable_without_processes() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let mut facts = fx.facts(&url, &fx.good_shim); + for fact in facts + .iter_mut() + .filter(|fact| fact.client != ClientId::Codex) + { + fact.not_applicable = Some(NotApplicableReason::ControlPlane); + } + let mut project = fx.project(&url); + project.control_plane = true; + let report = run(&project, &facts, &runner, true, Duration::from_secs(30)); + assert_eq!(report.profile, ProbeProfile::ControlPlane); + for client in [ClientId::Claude, ClientId::Dsh] { + for got in column(&report, client).cells.values() { + assert_eq!(got.state, CellState::NotApplicable); + assert_eq!(got.cause, Some(Cause::ControlPlane)); + } + } + assert_eq!(runner.count("fake-claude"), 0); + assert_eq!( + cell(&report, ClientId::Codex, Check::F).state, + CellState::Ready + ); + assert!(report.all_applicable_ready); + let rendered = serde_json::to_value(&report).unwrap(); + assert_eq!( + rendered["clients"][0]["cells"]["C"]["cause"], + "control_plane" + ); +} + +#[test] +fn codex_without_project_config_is_untrusted_or_not_configured() { + let fx = Fixture::new(); + let url = format!("http://127.0.0.1:9/mcp/projects/{PID}"); + let mut runner = FakeRunner::ready(&url); + runner.codex_get = ( + String::new(), + fixture("codex-mcp-get-untrusted.txt").replace("ljprobe", "lian-jing"), + ); + let facts = fx.facts(&url, &fx.good_shim); + let codex_only: Vec = facts + .iter() + .filter(|fact| fact.client == ClientId::Codex) + .cloned() + .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_eq!(c.evidence, EvidenceLevel::ClientIntrospection); + assert_eq!( + cell(&report, ClientId::Codex, Check::F).cause, + Some(Cause::CodexProjectUntrusted) + ); + + let mut no_entry = codex_only.clone(); + no_entry[0].source = L1Source::Codex { + project_entry: false, + project_url: None, + project_helper: None, + }; + let report = run( + &fx.project(&url), + &no_entry, + &runner, + true, + Duration::from_secs(30), + ); + assert_eq!( + cell(&report, ClientId::Codex, Check::C).cause, + Some(Cause::NotConfigured) + ); +} + +#[test] +fn claude_effective_global_entry_is_route_global_without_init() { + let fx = Fixture::new(); + let url = format!("http://127.0.0.1:9/mcp/projects/{PID}"); + let mut runner = FakeRunner::ready(&url); + runner.claude_get = claude_get_output( + "User config (available in all your projects)", + "http://127.0.0.1:9/mcp", + ); + let facts: Vec = fx + .facts(&url, &fx.good_shim) + .into_iter() + .filter(|fact| fact.client == ClientId::Claude) + .collect(); + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + for check in [Check::C, Check::D, Check::E, Check::F] { + assert_eq!( + cell(&report, ClientId::Claude, check).cause, + Some(Cause::RouteGlobal) + ); + } + assert_eq!( + column(&report, ClientId::Claude).route.as_deref(), + Some("http://127.0.0.1:9/mcp") + ); + assert_eq!(runner.count("fake-claude"), 1, "路由不对就不再起 init"); +} + +#[test] +fn tool_set_drift_fails_c_against_catalog_introspection() { + let fx = Fixture::new(); + let mut served = catalog_names(); + let dropped = served.pop().unwrap(); + let stub = StubServer::start(&fx.token, served); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.good_shim), + &runner, + true, + Duration::from_secs(30), + ); + for client in ALL { + let c = cell(&report, client, Check::C); + assert_eq!(c.cause, Some(Cause::ToolsMismatch), "{client:?}: {c:?}"); + assert!(c.detail.contains(&dropped)); + assert_eq!(cell(&report, client, Check::F).state, CellState::Ready); + } +} + +#[test] +fn codex_without_helper_replays_unauthenticated_and_f_reports_missing_header() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let mut runner = FakeRunner::ready(&url); + runner.codex_get.0 = runner.codex_get.0.replace("\"\"", "null"); + let facts: Vec = fx + .facts(&url, &fx.good_shim) + .into_iter() + .filter(|fact| fact.client == ClientId::Codex) + .collect(); + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + assert_eq!( + cell(&report, ClientId::Codex, Check::D).state, + CellState::Ready + ); + assert_eq!( + cell(&report, ClientId::Codex, Check::F).cause, + Some(Cause::AuthHeaderMissing) + ); +} + +#[test] +fn exhausted_budget_times_out_without_spawning() { + let fx = Fixture::new(); + let url = format!("http://127.0.0.1:9/mcp/projects/{PID}"); + let runner = FakeRunner::ready(&url); + let report = run( + &fx.project(&url), + &fx.facts(&url, &fx.good_shim), + &runner, + true, + Duration::ZERO, + ); + for client in ALL { + for check in [Check::C, Check::D, Check::E, Check::F] { + assert_eq!( + cell(&report, client, check).state, + CellState::Timeout, + "{client:?} {check:?}" + ); + } + assert_eq!(cell(&report, client, Check::A).state, CellState::Ready); + } + assert!(runner.specs().is_empty()); +} + +#[test] +fn failing_or_garbled_helper_is_helper_failed_and_output_never_echoed() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let write_shim = |sub: &str, body: &str| { + let bin = fx.dir.join(sub); + std::fs::create_dir_all(&bin).unwrap(); + let shim = bin.join("lianjing-mcp-header.cmd"); + std::fs::write(&shim, format!("@echo off\r\n{body}\r\n")).unwrap(); + shim + }; + // 非 JSON 输出里带着 token:只能判 helper_failed,内容不得进入结果 + let garbled = write_shim("bin-garbled", &format!("echo Bearer {}", fx.token)); + let failing = write_shim("bin-failing", "exit /b 7"); + for shim in [garbled, failing] { + 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 [ClientId::Claude, ClientId::Codex] { + for check in [Check::C, Check::D, Check::E, Check::F] { + assert_eq!( + cell(&report, client, check).cause, + Some(Cause::HelperFailed), + "{client:?} {check:?}" + ); + } + } + assert_eq!( + cell(&report, ClientId::Dsh, Check::D).state, + CellState::Ready + ); + assert_eq!( + cell(&report, ClientId::Dsh, Check::F).cause, + Some(Cause::HelperFailed) + ); + assert!(!serde_json::to_string(&report).unwrap().contains(&fx.token)); + } +} + +#[test] +fn claude_init_view_overrides_c_when_replay_passes() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let mut runner = FakeRunner::ready(&url); + let facts: Vec = fx + .facts(&url, &fx.good_shim) + .into_iter() + .filter(|fact| fact.client == ClientId::Claude) + .collect(); + // Claude 自己没连上:重放虽通过,C 仍按 Claude 的视角判失败;D–F 服务端可用但会话里调不到 → blocked + // (审查 ⑩ 改口径:原断言 F 为 ready,读起来像「没连上却能调用」) + runner.claude_init = runner.claude_init.replace("\"connected\"", "\"failed\""); + 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_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!( + cell(&report, ClientId::Claude, Check::F).state, + CellState::Blocked + ); + + // init 里 skills 缺 lian-jing:B 由静态升级为客户端自省并判失败 + let mut runner = FakeRunner::ready(&url); + runner.claude_init = runner.claude_init.replace("\"lian-jing\"]", "\"other\"]"); + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + let b = cell(&report, ClientId::Claude, Check::B); + assert_eq!(b.cause, Some(Cause::SkillNotVisible), "{b:?}"); + assert_eq!(b.evidence, EvidenceLevel::ClientIntrospection); + assert_eq!( + cell(&report, ClientId::Claude, Check::C).state, + CellState::Ready + ); + + // 取不到 init(格式漂移):C 仍以协议重放为准,并注明缺少 Claude 自省 + let mut runner = FakeRunner::ready(&url); + runner.claude_init = "{\"type\":\"result\"}\n".into(); + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + let c = cell(&report, ClientId::Claude, Check::C); + assert_eq!(c.state, CellState::Ready); + assert!(c.detail.contains("未取得 Claude init 自省"), "{c:?}"); + assert_eq!( + cell(&report, ClientId::Claude, Check::B).evidence, + EvidenceLevel::Static + ); +} + +#[test] +fn dsh_session_plugin_mismatch_fails_c_but_keeps_headless_replay() { + let fx = Fixture::new(); + let stub = StubServer::start(&fx.token, catalog_names()); + let url = stub.url(); + let runner = FakeRunner::ready(&url); + let mut facts: Vec = fx + .facts(&url, &fx.good_shim) + .into_iter() + .filter(|fact| fact.client == ClientId::Dsh) + .collect(); + if let L1Source::Dsh { session_plugin, .. } = &mut facts[0].source { + *session_plugin = StaticFinding { + ok: false, + detail: "会话级插件文件缺失".into(), + }; + } + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + let c = cell(&report, ClientId::Dsh, Check::C); + assert_eq!(c.cause, Some(Cause::SessionPluginMismatch), "{c:?}"); + assert_eq!(c.evidence, EvidenceLevel::Static); + for check in [Check::D, Check::E, Check::F] { + assert_eq!(cell(&report, ClientId::Dsh, check).state, CellState::Ready); + } + + facts[0].source = L1Source::Dsh { + whitelist: ClientOutcome::Missing, + session_plugin: StaticFinding { + ok: true, + detail: String::new(), + }, + helper: HelperCommand::DshShim { + shim: fx.good_shim.clone(), + digest: DIGEST.into(), + }, + }; + let report = run( + &fx.project(&url), + &facts, + &runner, + true, + Duration::from_secs(30), + ); + assert_eq!( + cell(&report, ClientId::Dsh, Check::F).cause, + Some(Cause::RouteGlobal) + ); + assert!(column(&report, ClientId::Dsh) + .route + .as_deref() + .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 = 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 = 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 = 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 真实夹具)────────────────────────────────────────── + +#[test] +fn claude_init_fixture_parses_servers_tools_skills_and_crlf() { + let stream = fixture("claude-init-badmodel.jsonl"); + for text in [ + stream.clone(), + stream.replace("\r\n", "\n").replace('\n', "\r\n"), + ] { + let init = parse_claude_init(&text, "ljprobe").expect("init 事件"); + assert_eq!(init.version.as_deref(), Some("2.1.173")); + assert_eq!(init.server_status.as_deref(), Some("connected")); + assert_eq!(init.tools, ["probe_read", "probe_slow", "probe_write"]); + assert!(init.skills.iter().any(|skill| skill == "code-review")); + } + let other = parse_claude_init(&stream, "lian-jing").unwrap(); + assert_eq!(other.server_status, None); + assert!(other.tools.is_empty()); + assert!(parse_claude_init("not json\n{\"type\":\"result\"}\n", "lian-jing").is_none()); +} + +#[test] +fn codex_mcp_get_fixtures_parse_found_and_untrusted() { + match parse_codex_mcp_get(&fixture("codex-mcp-get-trusted.json"), "") { + CodexMcpGet::Found { + enabled, + url, + has_helper, + .. + } => { + assert!(enabled); + assert_eq!(url.as_deref(), Some("http://127.0.0.1:37901/mcp")); + assert!(!has_helper, "夹具里的桩条目没有 helper"); + } + other => panic!("应解析为 Found:{other:?}"), + } + assert_eq!( + parse_codex_mcp_get("", &fixture("codex-mcp-get-untrusted.txt")), + CodexMcpGet::NotFound + ); + assert_eq!( + parse_codex_mcp_get("garbage", ""), + CodexMcpGet::Unrecognized + ); + assert_eq!( + parse_codex_version("codex-cli 0.155.0-alpha.9.2\r\n").as_deref(), + Some("0.155.0-alpha.9.2") + ); + assert_eq!(parse_codex_version("codex-cli\n"), None); +} + +#[test] +fn claude_mcp_get_output_parses_scope_url_and_status() { + let local = parse_claude_mcp_get(&claude_get_output( + "Local config (private to you in this project)", + "http://127.0.0.1:27190/mcp/projects/x", + )) + .unwrap(); + assert_eq!(local.scope, ClaudeScope::Local); + assert_eq!( + local.url.as_deref(), + Some("http://127.0.0.1:27190/mcp/projects/x") + ); + assert_eq!(local.connected, Some(true)); + let failed = parse_claude_mcp_get( + "lian-jing:\r\n Scope: User config\r\n Status: ✘ Failed to connect\r\n URL: http://127.0.0.1:27190/mcp\r\n", + ) + .unwrap(); + assert_eq!(failed.scope, ClaudeScope::Other("User config".into())); + assert_eq!(failed.connected, Some(false)); + assert_eq!( + parse_claude_mcp_get("No MCP server found with name: lian-jing") + .unwrap() + .scope, + ClaudeScope::Absent + ); + assert!(parse_claude_mcp_get("unexpected").is_none()); +} + +#[test] +fn helper_entry_expands_cmd_variables_and_codex_table_helper_reads_toml_strings() { + let env = |name: &str| (name == "USERPROFILE").then(|| r"C:\Users\qa".to_string()); + assert_eq!( + helper_entry_path( + r#""%USERPROFILE%\.lian-jing\bin\lianjing-mcp-header.cmd" abc"#, + &env + ), + Some(PathBuf::from( + r"C:\Users\qa\.lian-jing\bin\lianjing-mcp-header.cmd" + )) + ); + assert_eq!(helper_entry_path(r#""%NOPE%\x.cmd" abc"#, &env), None); + assert_eq!(helper_entry_path("relative.cmd abc", &env), None); + assert_eq!( + expand_percent_vars("a%USERPROFILE%b", &env).as_deref(), + Some(r"aC:\Users\qab") + ); + assert_eq!(expand_percent_vars("50% off", &env), None); + + let managed = "[other]\nhttp_headers_helper = \"x\"\n# LIANJING_MCP_MANAGED_START\n[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p\"\nhttp_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" abc\"\n# LIANJING_MCP_MANAGED_END\n"; + assert!(codex_has_table(managed)); + assert_eq!( + codex_table_helper(managed).as_deref(), + Some(r#""%USERPROFILE%\.lian-jing\bin\lianjing-mcp-header.cmd" abc"#) + ); + assert_eq!( + codex_table_helper("[mcp_servers.lian-jing]\nhttp_headers_helper = 'C:\\x.cmd d'\n") + .as_deref(), + Some(r"C:\x.cmd d") + ); + assert_eq!( + codex_table_helper("[mcp_servers.lian-jing]\nurl = \"u\"\n"), + None + ); + assert!(!codex_has_table("[mcp_servers.other]\n")); +} + +#[test] +fn static_findings_for_rules_and_skill() { + let fx = Fixture::new(); + assert!(rules_finding(&fx.root, "CLAUDE.md", true).ok); + std::fs::write(fx.root.join("CLAUDE.md"), "# 没有引用\n").unwrap(); + assert!(!rules_finding(&fx.root, "CLAUDE.md", true).ok); + assert!(rules_finding(&fx.root, "AGENTS.md", false).ok); + assert!(!rules_finding(&fx.root, "MISSING.md", false).ok); + assert!(skill_finding(&fx.root, ".agents/skills/lian-jing/SKILL.md").ok); + assert!(!skill_finding(&fx.root, ".agents/skills/other/SKILL.md").ok); +} + +#[test] +fn asar_package_version_is_read_without_processes() { + let fx = Fixture::new(); + let package = br#"{"name":"@deepseek-ai/dsh-desktop","version":"0.2.0-rc.2"}"#; + let header = json!({ "files": { + "a.txt": { "size": 3, "offset": "0" }, + "package.json": { "size": package.len(), "offset": "3" } + } }) + .to_string(); + let json_len = header.len() as u32; + // 头 pickle:[u32 payload][u32 json_len][json],payload 按 4 字节对齐 + let padded = (json_len as usize).div_ceil(4) * 4; + let header_pickle_size = (8 + padded) as u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&4u32.to_le_bytes()); + bytes.extend_from_slice(&header_pickle_size.to_le_bytes()); + bytes.extend_from_slice(&((4 + padded) as u32).to_le_bytes()); + bytes.extend_from_slice(&json_len.to_le_bytes()); + bytes.extend_from_slice(header.as_bytes()); + bytes.resize(8 + header_pickle_size as usize, 0); + bytes.extend_from_slice(b"abc"); + bytes.extend_from_slice(package); + let asar = fx.dir.join("app.asar"); + std::fs::write(&asar, bytes).unwrap(); + assert_eq!( + read_asar_package_version(&asar).as_deref(), + Some("0.2.0-rc.2") + ); + assert_eq!(read_asar_package_version(&fx.dir.join("none.asar")), None); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index a33e710..5cfe0ce 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1644,6 +1644,88 @@ export interface OnboardingReport { export const applyOnboardingPack = (projectPath: string, projectId: string) => invoke("apply_onboarding_pack", { projectPath, projectId }); +// ── 三端对等 L1 零模型检查(agent-client-parity P4a)──────────────────────────── +// 类型与 src-tauri/src/onboard/probe_l1.rs 的 L1Report 对应;MCP probe_client_parity 返回同一结构。 + +/** A 项目规则 / B lian-jing skill / C MCP 工具集 / D get_diagnostics / E 列出开发变量 / F 敏感读取认证链 */ +export type ParityCheck = "A" | "B" | "C" | "D" | "E" | "F"; + +/** 证据等级,从弱到强;L1 至多到 protocol */ +export type ParityEvidence = + | "static" + | "client_introspection" + | "protocol" + | "session_event" + | "server_trace"; + +/** not_installed 与 not_applicable 不计失败 */ +export type ParityCellState = + | "ready" + | "fail" + | "unknown" + | "not_installed" + | "not_applicable" + | "blocked" + | "timeout"; + +export type ParityCause = + | "control_plane" + | "wsl" + | "timeout" + | "unreachable" + | "client_error" + | "not_configured" + | "route_global" + | "route_mismatch" + | "not_connected" + | "codex_project_untrusted" + | "server_disabled" + | "manual_config" + | "rules_not_loaded" + | "skill_not_visible" + | "session_plugin_mismatch" + | "shim_missing" + | "helper_failed" + | "auth_header_missing" + | "auth_failed" + | "tools_mismatch" + | "tool_error" + | "adapter_drift"; + +export interface ParityCell { + state: ParityCellState; + cause?: ParityCause; + evidence: ParityEvidence; + detail: string; +} + +export interface ParityClientColumn { + client: "claude" | "codex" | "dsh"; + version: string | null; + /** 该端对本项目实际会连的地址;未得出为 null */ + route: string | null; + cells: Record; + duration_ms: number; +} + +export interface ClientParityL1Report { + project_id: string; + tier: "l1"; + probe_spec_version: number; + profile: "project" | "control_plane"; + expected_route: string; + expected_tools: number; + nonce: string; + clients: ParityClientColumn[]; + all_applicable_ready: boolean; + duration_ms: number; + note: string; +} + +/** 约 5–10 秒:起零模型客户端自省并重放认证链(认证头不出炼境进程) */ +export const probeClientParity = (projectId: string, tier: "l1" = "l1") => + invoke("probe_client_parity", { projectId, tier }); + // ── Agent Infrastructure ────────────────────────────────────────────────────── export interface DetectedContract {