diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 6f19be9..3fa5183 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -139,9 +139,15 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ### 🔵 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 缺失与预算耗尽两条失败;把认证头写进 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-code 身份连项目地址,服务端会把该项目记为已有真实客户端初始化(影响 readiness 的 reload_required 提示)——这确是一次真实 Claude 进程的连接,未排除。⑧ A 只判规则文件存在(Claude 另要求 CLAUDE.md 引用 @AGENTS.md),版本归 verify_onboarding;B 对 Codex/DSH 只有静态证据。⑨ [MCP 全局] 两项 acceptance 须在合并、运行中的炼境重编译后实测,本卡保持 🔵。" +- 有意偏差:① 卡片与设计写 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)。 +- 待办:独立审查与开 PR(统筹编排);合并后统筹会话在重编译的炼境上实测 [MCP 全局] 两项,通过后标 ✅。 ### 📋 P4b — MCP 服务端调用轨迹环 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index aa4b317..10b2f7d 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -17,7 +17,8 @@ contracts: - "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.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)。" + - "src-tauri/src/onboard/probe_l1.rs" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_);返回 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。" - label: 被管理项目身份上下文 paths: @@ -32,7 +33,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 13b8c69..e5536fb 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; @@ -637,22 +640,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), } } @@ -1437,8 +1431,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 信任不在此判定 @@ -2062,6 +2055,56 @@ 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 => read_claude_config() + .map(|config| claude_entries_covering(&config, project.root)) + .unwrap_or_default(), + }; + 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 + } +} + +/// ~/.claude.json 中覆盖本根目录的项目键下的 lian-jing 条目(含手写),最具体(最长)的键在前。 +fn claude_entries_covering(config: &Value, root: &std::path::Path) -> Vec { + let root_text = root.display().to_string(); + let mut entries: Vec<(usize, Value)> = config + .get("projects") + .and_then(Value::as_object) + .map(|projects| { + projects + .iter() + .filter(|(key, _)| key_covers_root(key, &root_text)) + .filter_map(|(key, project)| { + let entry = project.get("mcpServers")?.get(MCP_KEY)?; + Some((normalize_root_key(key).len(), entry.clone())) + }) + .collect() + }) + .unwrap_or_default(); + entries.sort_by_key(|(length, _)| std::cmp::Reverse(*length)); + entries.into_iter().map(|(_, entry)| entry).collect() } /// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理), @@ -2135,6 +2178,25 @@ 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_helper: probe_l1::codex_table_helper(&config), + }, + } + } } /// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。 @@ -2184,6 +2246,232 @@ 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, + }; + 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, + }; + 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)。 @@ -3756,6 +4044,105 @@ 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_helper, + } => { + assert!(project_entry); + assert_eq!( + project_helper.as_deref(), + Some(desired.header_helper.as_str()) + ); + } + other => panic!("Codex 应给出 Codex 来源:{other:?}"), + } + } + + #[test] + fn claude_entries_covering_root_prefer_most_specific_key() { + let managed = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p", "h"); + let manual = json!({ "type": "http", "url": "http://127.0.0.1:9/mcp" }); + let config = json!({ "projects": { + "E:/code": { "mcpServers": { "lian-jing": manual } }, + "E:/code/proj": { "mcpServers": { "lian-jing": managed } }, + "E:/other": { "mcpServers": { "lian-jing": { "url": "x" } } }, + "E:/code/proj/sub": { "mcpServers": { "lian-jing": { "url": "y" } } } + } }); + let entries = claude_entries_covering(&config, std::path::Path::new("E:\\code\\proj")); + assert_eq!(entries, vec![managed, manual]); + } + + /// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端): + /// 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..a41ff60 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -50,7 +50,10 @@ 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 + { return; } if let Ok(mut clients) = initialized_project_clients().write() { @@ -1524,6 +1527,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" } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index c9df25d..8dc89aa 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -400,11 +400,33 @@ 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()); } apply_tool_annotations(&mut result); 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/timeout)、证据等级(static < client_introspection < protocol)与归因(如 shim_missing、auth_failed、unreachable、route_global、codex_project_untrusted)。认证头只在炼境进程内使用,不进入结果。控制面项目上 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"] + } + }) +} + /// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限) fn envoy_tool_schemas() -> Value { json!([ @@ -447,7 +469,10 @@ fn is_project_scoped_tool(tool: &Value) -> bool { if name == "get_diagnostics" { return true; } - if matches!(name, "unregister_project" | "setup_gitea_repo") { + if matches!( + name, + "unregister_project" | "setup_gitea_repo" | "probe_client_parity" + ) { return false; } tool.get("inputSchema") @@ -580,6 +605,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, @@ -738,16 +764,72 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .as_str() .unwrap_or("接入验收结构化结果已返回") .to_string(), + "probe_client_parity" => parity_probe_summary(value), _ => "结构化结果已返回".to_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", ]; if STRUCTURED_TOOLS.contains(&tool_name) { if let Ok(structured) = serde_json::from_str::(&text) { @@ -1404,6 +1486,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)?; @@ -2491,6 +2579,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..1bf4937 --- /dev/null +++ b/src-tauri/src/onboard/probe_l1.rs @@ -0,0 +1,1900 @@ +//! 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 托管块白名单与会话级插件哈希。 +//! +//! 进程执行、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\""; +/// read_project_test_account 对未登记需求名的固定回答:出现即说明认证已通过(零凭据验证法)。 +const UNREGISTERED_NEED: &str = "开发需求未登记"; +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, +} + +/// 只读判定的一条静态事实。 +#[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 }, +} + +/// 各端取配置的来源(客户端适配器只读填写)。 +#[derive(Debug, Clone)] +pub enum L1Source { + /// Claude:~/.claude.json 中覆盖本根目录的 lian-jing 条目(最具体的键在前)。生效哪一条以 + /// `claude mcp get` 的回答为准(不复刻 Claude 的项目键推导);该命令不输出 headersHelper,按 URL 回查。 + Claude { entries: Vec }, + /// Codex:生效配置以 `codex mcp get --json` 为准;它会把 helper 脱敏,helper 取项目 .codex/config.toml 同名表。 + Codex { + project_entry: bool, + 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 = format!( + "LJ_PROBE_{}", + uuid::Uuid::new_v4().simple().to_string()[..8].to_ascii_uppercase() + ); + 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(), + } +} + +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_helper, + } => probe_codex( + ctx, + &located, + *project_entry, + 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}"), + ) + }) +} + +fn probe_claude(ctx: &Ctx, located: &Located, entries: &[Value], 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; + } + let entry = entries + .iter() + .find(|entry| entry.get("url").and_then(Value::as_str) == Some(url.as_str())); + let helper = entry + .and_then(|entry| entry.get("headersHelper")) + .and_then(Value::as_str) + .map(|helper| HelperCommand::Shell(helper.to_string())); + + // ② 零模型 init:只挂这一条生效条目(--strict-mcp-config),避免等待无关 MCP 服务器; + // 读到 init 即结束——此时只有 HTTP 连接,无 stdio 子进程会因提前结束而成为孤儿。 + let introspected = match ctx.cap(CLIENT_CLI_TIMEOUT) { + None => Err("预算耗尽,未取 init".to_string()), + Some(timeout) => { + let config = json!({ "mcpServers": { SERVER_NAME: entry.cloned().unwrap_or_else(|| json!({ "type": "http", "url": url })) } }); + let mut spec = client_spec( + ctx, + program, + vec![ + "-p".into(), + "hi".into(), + "--model".into(), + ZERO_MODEL.into(), + "--no-session-persistence".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 introspection_c = 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); + introspection_c = Some(match init.server_status.as_deref() { + Some("connected") => match tools_diff(&init.tools, &ctx.project.expected_tools) { + None => Cell::ready( + EvidenceLevel::ClientIntrospection, + format!( + "Claude init:lian-jing connected,{} 个工具与期望一致", + init.tools.len() + ), + ), + Some(diff) => Cell::fail( + Cause::ToolsMismatch, + EvidenceLevel::ClientIntrospection, + format!("Claude init 看到的 lian-jing 工具与期望不一致:{diff}"), + ), + }, + Some(status) => Cell::fail( + Cause::NotConnected, + EvidenceLevel::ClientIntrospection, + format!("Claude init:lian-jing 状态 {status}"), + ), + None => Cell::fail( + Cause::NotConnected, + EvidenceLevel::ClientIntrospection, + "Claude init 的 mcp_servers 中没有 lian-jing", + ), + }); + } + Err(reason) => introspection_note = format!("(未取得 Claude init 自省:{reason})"), + } + + // ③ 认证链重放:Claude 的 helper 失败时连接不建立 + let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort); + replayed.c = match (replayed.c.state, introspection_c) { + // 重放失败的归因更具体(shim_missing / auth_failed / unreachable),优先采用 + (CellState::Ready, Some(introspected)) if introspected.state != CellState::Ready => { + introspected + } + (CellState::Ready, Some(_)) => Cell::ready( + EvidenceLevel::Protocol, + format!("{};Claude init 自省一致", replayed.c.detail), + ), + (_, _) => { + let mut cell = replayed.c; + cell.detail.push_str(&introspection_note); + cell + } + }; + replayed.apply(column); +} + +fn probe_codex( + ctx: &Ctx, + located: &Located, + project_entry: bool, + project_helper: Option<&str>, + column: &mut ClientColumn, +) { + 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) = 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, + .. + } => (url, has_helper), + }; + column.route = Some(url.clone()); + if let Some(cell) = route_cell(&url, &ctx.project.expected_url, "Codex ") { + set_connection_cells(column, cell); + return; + } + let helper = match (has_helper, project_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, + }; + let mut replayed = replay(ctx, &url, helper.as_ref(), HelperFailure::Abort); + if replayed.c.state == CellState::Ready { + replayed + .c + .detail + .push_str(";codex mcp get 确认项目配置已加载"); + } + 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) => { + 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, "get_diagnostics", json!({})) { + Err(error) => rpc_failure_cell(error, "get_diagnostics"), + 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, "list_project_dev_variables", json!({})) { + Err(error) => rpc_failure_cell(error, "list_project_dev_variables"), + 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, "read_project_test_account", 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, "read_project_test_account"), + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeMcpGet { + pub scope: ClaudeScope, + pub url: Option, + /// Status 行:Connected 为 true;其他状态为 false;没有该行为 None + 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, + }, + 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()), + }; + } + } + if stdout.contains("No MCP server named") || stderr.contains("No MCP server named") { + CodexMcpGet::NotFound + } else { + CodexMcpGet::Unrecognized + } +} + +/// `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 { + 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("http_headers_helper") + .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 reader.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 || { + let mut bytes = Vec::new(); + let _ = stderr.read_to_end(&mut bytes); + let _ = err_tx.send(String::from_utf8_lossy(&bytes).into_owned()); + }); + + let deadline = Instant::now() + spec.timeout; + let mut output = ProcessOutput::default(); + let mut exit = None; + let mut closed = false; + loop { + match line_rx.recv_timeout(Duration::from_millis(50)) { + 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; + } + continue; + } + 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) + } +} + +/// 真实 HTTP:ureq 单次请求,不复用连接、不记录请求头。 +pub struct UreqHttp; + +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) => Ok(HttpReply { + status: response.status(), + body: response.into_json().ok(), + }), + Err(ureq::Error::Status(status, response)) => Ok(HttpReply { + status, + body: response.into_json().ok(), + }), + Err(ureq::Error::Transport(transport)) => Err(match transport.kind() { + ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::Dns => { + HttpFailure::Unreachable(format!("{:?}", transport.kind())) + } + ureq::ErrorKind::Io if transport.to_string().contains("timed out") => { + 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..14ddd90 --- /dev/null +++ b/src-tauri/src/onboard/probe_l1/tests.rs @@ -0,0 +1,1245 @@ +//! 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 = format!("\"{}\" {DIGEST}", shim.display()); + 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 { + entries: vec![json!({ "type": "http", "url": url, "headersHelper": line })], + }, + }, + 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_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(), + } + } +} + +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}" + ); + } + 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_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 report = run( + &fx.project(&url), + &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 是协议重放结论 + 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); + assert_eq!( + cell(&report, ClientId::Claude, Check::F).state, + CellState::Ready + ); + + // 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"))); +} + +// ── 客户端输出解析(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 {