Compare commits

..

No commits in common. "5b804bb967b38ed45b869e0a36414b9aa58cb09c" and "bd0e11ee82f28305ca7453595a0b3248d37e9524" have entirely different histories.

12 changed files with 24 additions and 3843 deletions

View File

@ -136,18 +136,12 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- verification: "2026-10-03 主会话后台实测,零凭据(全部连本地桩服务,桩只记方法、工具名、clientInfo、UA):Claude Code 2.1.173、codex-cli 0.155.0-alpha.9.2、DeepSeek Harness 桌面版内置运行时。每臂独立桩端口与日志,调用归属不靠时间窗。脱敏夹具入库 src-tauri/tests/fixtures/client_probe/(10 个文件:claude-init-badmodel、claude-narrow、claude-bypass-allowedtools、codex-ro-read/ro-write/ro-slow、codex-mcp-get-trusted/untrusted、dsh-read)——用户路径、会话 ID、UUID 换占位符,长文本截断到 200 字符,grep 无用户名、无 Bearer、无 64 位十六进制串。原始日志在本机 %TEMP%\\lj-p4-0(未入库)。"
- known_limits: "只验了无头 CLI;Codex 首轮四臂因脚本误加 -c mcp_servers.cua_repl.enabled=false(cua_repl 不在用户配置里,生成了缺 transport 的条目,配置加载失败)全部未运行,第二轮去掉后重跑。Claude 收窄组合只用桩工具验证,接 lian-jing 时只读工具白名单须取自 tools_list_result_for_project 中 readOnlyHint=true 的工具。实验附带的流程问题:前两轮脚本在脚本文件里用 kill 结束自己启动的桩进程,等于绕过了用户 deny 规则 Bash(kill *);之后桩服务改为到时自动退出,agent 会话里的实验脚本不再用 kill。"
### 🔵 P4a — L1 零模型三端检查
- status: in_progress
### 📋 P4a — L1 零模型三端检查
- status: todo
- 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(实际另含 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)
- 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
- 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 脱敏成 \"<redacted>\",重放用的 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 的写法执行 \"\"<shim>\" <digest>\";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

View File

@ -17,8 +17,7 @@ contracts:
- "src-tauri/src/commands/gitea.rs"
- "src-tauri/src/commands/dev_variables.rs"
- "src-tauri/src/envoy.rs"
- "src-tauri/src/onboard/probe_l1.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_<nonce>);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client。"
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。"
- label: 被管理项目身份上下文
paths:
@ -33,7 +32,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 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report)。"
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 构建必须拒绝启用。"
architecture_docs:
- "AGENTS.md"

View File

@ -515,20 +515,6 @@ pub fn apply_onboarding_pack(
})
}
/// 三端对等 L1 零模型检查(agent-client-parity P4a)的 UI 投影,与 MCP probe_client_parity 同一本体。
/// 会起零模型的客户端自省进程并回调本机 MCP 服务(约 5–10 秒),放到阻塞线程池执行,不占界面线程。
#[tauri::command]
pub async fn probe_client_parity(
project_id: String,
tier: Option<String>,
) -> Result<crate::onboard::probe_l1::L1Report, String> {
tokio::task::spawn_blocking(move || {
crate::mcp_inject::probe_client_parity(&project_id, tier.as_deref())
})
.await
.map_err(|error| format!("L1 检查线程异常: {error}"))?
}
// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
/// 单项验收结果。

View File

@ -442,7 +442,6 @@ pub fn run() {
list_buffed_projects,
// onboarding pack
commands::onboarding::apply_onboarding_pack,
commands::onboarding::probe_client_parity,
// agent infrastructure
commands::agent_infra::scan_agent_infra_stack,
commands::agent_infra::generate_agent_infra_files,

View File

@ -2,9 +2,6 @@ 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;
@ -640,13 +637,22 @@ enum ClaudeEffective {
}
/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。
/// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
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),
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)
}
}
@ -1431,7 +1437,8 @@ fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, S
}
fn has_manual_codex_entry(text: &str) -> bool {
probe_l1::codex_has_table(text)
text.lines()
.any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]"))
}
/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定
@ -2055,56 +2062,6 @@ 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<NotApplicableReason> {
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<Value> {
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 会清理),
@ -2178,25 +2135,6 @@ 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。
@ -2246,232 +2184,6 @@ impl ClientAdapter for DshAdapter {
}
finalize_for_project(project, refresh_dsh_patch(None))
}
/// DSH 无零模型自省入口:白名单复用 static_status,另核对会话级插件;helper 按托管块 !!js 的方式执行。
fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts {
let not_applicable = l1_not_applicable(project);
let (whitelist, session_plugin) = match not_applicable {
Some(reason) => (
ClientOutcome::NotApplicable { reason },
StaticFinding {
ok: true,
detail: "不适用".into(),
},
),
None => (
self.static_status(project, desired),
dsh_session_plugin_finding(),
),
};
L1ClientFacts {
client: ClientId::Dsh,
not_applicable,
rules: probe_l1::rules_finding(project.root, "AGENTS.md", false),
skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"),
source: L1Source::Dsh {
whitelist,
session_plugin,
// 与托管块 !!js 同源:%USERPROFILE%\.lian-jing\bin 下的稳定入口 + sha256(project_id)
helper: HelperCommand::DshShim {
shim: helper_bin_dir()
.map(|dir| dir.join(MCP_HEADER_SHIM_NAME))
.unwrap_or_else(|_| PathBuf::from(MCP_HEADER_SHIM_NAME)),
digest: crate::credential_store::mcp_auth_digest(project.project_id)
.unwrap_or_default(),
},
},
}
}
}
/// DSH 会话级插件(P9,桌面 GUI 按工作区连项目地址)的完整性:托管块引用了当前版本的插件条目,
/// 且插件文件与炼境内置版本的 sha256 一致(忽略行尾)。只读。
fn dsh_session_plugin_finding() -> StaticFinding {
use sha2::Digest;
let hash = |text: &str| -> String {
sha2::Sha256::digest(text.replace("\r\n", "\n").as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
};
let patch = dsh_home()
.ok()
.and_then(|home| read_optional(&home.join("cordis.patch.yml")).ok().flatten())
.unwrap_or_default();
if !patch.contains(&format!("- id: {DSH_SESSION_PLUGIN_ID}")) {
return StaticFinding {
ok: false,
detail: format!("DSH 托管块未引用会话级插件 {DSH_SESSION_PLUGIN_ID};重跑 inject_mcp"),
};
}
let expected = hash(DSH_SESSION_PLUGIN_SOURCE);
let path = helper_bin_dir().ok().and_then(|bin| {
bin.parent()
.map(|dir| dir.join("dsh").join(DSH_SESSION_PLUGIN_FILE))
});
match path
.as_ref()
.map(|path| (path, std::fs::read_to_string(path)))
{
Some((_, Ok(content))) if hash(&content) == expected => StaticFinding {
ok: true,
detail: format!(
"会话级插件 {DSH_SESSION_PLUGIN_FILE} 与炼境内置版本一致(sha256 {}…)",
&expected[..12]
),
},
Some((path, Ok(_))) => StaticFinding {
ok: false,
detail: format!(
"会话级插件 {} 与炼境内置版本不一致;重启炼境或重跑 inject_mcp",
path.display()
),
},
_ => StaticFinding {
ok: false,
detail: "会话级插件文件缺失;重启炼境或重跑 inject_mcp".into(),
},
}
}
// ── L1 零模型三端检查(P4a):本体入口与本机客户端定位 ─────────────────────────
/// 三端对等 L1 零模型检查的本体入口(MCP probe_client_parity 与 Tauri command 共用,边界只在这里)。
/// 只读三端配置,起零模型的客户端自省进程,经稳定 shim 取认证头后在项目地址上重放(认证头不出本进程)。
pub fn probe_client_parity(
project_id: &str,
tier: Option<&str>,
) -> Result<probe_l1::L1Report, String> {
match tier.unwrap_or("l1") {
"l1" => {}
other => {
return Err(format!(
"tier={other} 不支持:目前只有 l1(零模型检查);L3 无头会话由后续 start_parity_probe 提供"
))
}
}
let root = project_root(project_id)?;
probe_client_parity_at(project_id, &root, crate::mcp_server::read_port())
}
/// probe_client_parity 去掉登记查询的部分(根目录与端口由调用方给出;实机冒烟测试直接调用)。
fn probe_client_parity_at(
project_id: &str,
root: &std::path::Path,
port: u16,
) -> Result<probe_l1::L1Report, String> {
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
let facts = project_facts(project_id, root);
let desired = DesiredMcp {
port,
server_url: server_url.clone(),
header_helper: shim_helper_command(&digest),
refresh_dsh: false,
};
let clients: Vec<L1ClientFacts> = crate::onboard::clients::registry()
.iter()
.map(|adapter| adapter.l1_facts(&facts, &desired))
.collect();
let expected_tools = crate::mcp_tools::tools_list_result_for_project(project_id)["tools"]
.as_array()
.map(|tools| {
tools
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let project = probe_l1::L1Project {
project_id,
root,
control_plane: facts.control_plane,
expected_url: server_url,
expected_tools,
};
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<Located> {
match client {
ClientId::Claude => find_claude_bin().map(|bin| Located {
program: Some(PathBuf::from(bin)),
version: None,
}),
ClientId::Codex => find_codex_bin().map(|bin| Located {
program: Some(bin),
version: None,
}),
ClientId::Dsh => DSH_ADAPTER.detect().then(|| Located {
program: None,
version: dsh_install_dir().and_then(|dir| {
probe_l1::read_asar_package_version(&dir.join("resources").join("app.asar"))
}),
}),
}
}
fn git_bash(&self) -> Option<PathBuf> {
find_git_bash().map(PathBuf::from)
}
}
/// codex 可执行文件:CODEX_BIN → Codex 桌面版自带的 %LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe
/// (本机可能有多个哈希目录,取最新)→ PATH 中的 codex.exe。npm 全局安装的 codex.cmd 不在此列。
fn find_codex_bin() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("CODEX_BIN")
.map(PathBuf::from)
.filter(|path| path.is_file())
{
return Some(path);
}
let bundled = std::env::var_os("LOCALAPPDATA")
.map(|base| PathBuf::from(base).join("OpenAI").join("Codex").join("bin"))
.and_then(|bin| std::fs::read_dir(bin).ok())
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path().join("codex.exe"))
.filter(|exe| exe.is_file())
.max_by_key(|exe| exe.metadata().and_then(|meta| meta.modified()).ok());
if bundled.is_some() {
return bundled;
}
let output = crate::silent_cmd("where").arg("codex.exe").output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|line| PathBuf::from(line.trim()))
.filter(|path| path.is_file())
}
/// DSH 桌面版安装目录(读版本用):DSH_INSTALL_DIR → %LOCALAPPDATA%\Programs\DeepSeek Harness。
fn dsh_install_dir() -> Option<PathBuf> {
std::env::var_os("DSH_INSTALL_DIR")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("LOCALAPPDATA").map(|base| {
PathBuf::from(base)
.join("Programs")
.join("DeepSeek Harness")
})
})
.filter(|dir| dir.is_dir())
}
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
@ -4044,105 +3756,6 @@ 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="<project_id>|<项目根目录>" cargo test l1_live -- --ignored --nocapture
/// 只打印矩阵(不含认证头),由人或统筹会话判读。
#[test]
#[ignore = "实机冒烟:起真实客户端自省进程并连运行中的炼境"]
fn l1_live_probe_against_running_lianjing() {
let spec = std::env::var("LJ_L1_LIVE").expect("设置 LJ_L1_LIVE=<project_id>|<root>");
let (project_id, root) = spec.split_once('|').expect("格式 <project_id>|<root>");
let started = std::time::Instant::now();
let report = probe_client_parity_at(project_id, std::path::Path::new(root), 27190).unwrap();
println!("{}", serde_json::to_string_pretty(&report).unwrap());
println!("elapsed_ms={}", started.elapsed().as_millis());
}
#[test]
fn registered_path_accepts_absolute_and_unc() {
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());

View File

@ -50,10 +50,7 @@ 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();
// 炼境自己的就绪检查与 L1 探针(P4a)都不是真实客户端会话,不能当作「客户端已重载配置」的证据
if client_name == "lian-jing-readiness"
|| client_name == crate::onboard::probe_l1::PROBE_CLIENT_NAME
{
if client_name == "lian-jing-readiness" {
return;
}
if let Ok(mut clients) = initialized_project_clients().write() {
@ -1527,14 +1524,6 @@ 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" }

View File

@ -400,33 +400,11 @@ pub fn tools_list_result() -> Value {
if let Value::Array(envoy_tools) = envoy_tool_schemas() {
tools.splice(after_list..after_list, envoy_tools);
}
let after_verify = tools
.iter()
.position(|tool| tool["name"] == "verify_onboarding")
.map_or(tools.len(), |index| index + 1);
tools.insert(after_verify, parity_probe_tool_schema());
}
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_<nonce>,认证通过即返回「开发需求未登记」)重放认证链。返回 A–F × 三端矩阵,每格带状态(ready/fail/unknown/not_installed/not_applicable/timeout)、证据等级(static < client_introspection < protocol)与归因(如 shim_missing、auth_failed、unreachable、route_global、codex_project_untrusted)。认证头只在炼境进程内使用,不进入结果。控制面项目上 Claude 与 DSH 记 not_applicable;未安装的客户端记 not_installed、不计失败。L1 是配置与协议重放,绿灯不等于真实会话可用。",
"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!([
@ -469,10 +447,7 @@ fn is_project_scoped_tool(tool: &Value) -> bool {
if name == "get_diagnostics" {
return true;
}
if matches!(
name,
"unregister_project" | "setup_gitea_repo" | "probe_client_parity"
) {
if matches!(name, "unregister_project" | "setup_gitea_repo") {
return false;
}
tool.get("inputSchema")
@ -605,7 +580,6 @@ 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,
@ -764,72 +738,16 @@ 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<String> = value["clients"]
.as_array()
.map(|clients| {
clients
.iter()
.map(|column| {
let client = column["client"].as_str().unwrap_or("?");
let cells = column["cells"].as_object();
let states = |state: &str| {
cells.map_or(0, |cells| {
cells.values().filter(|cell| cell["state"] == state).count()
})
};
if states("not_applicable") == 6 {
return format!("{client} 不适用");
}
if states("not_installed") == 6 {
return format!("{client} 未安装");
}
let issues: Vec<String> = cells
.map(|cells| {
cells
.iter()
.filter(|(_, cell)| cell["state"] != "ready")
.map(|(check, cell)| {
format!("{check}={}", cell["cause"].as_str().unwrap_or("?"))
})
.collect()
})
.unwrap_or_default();
let version = column["version"].as_str().unwrap_or("版本未知");
if issues.is_empty() {
format!("{client} {version} 6/6 ready")
} else {
format!("{client} {version} {}", issues.join(" "))
}
})
.collect()
})
.unwrap_or_default();
format!(
"L1 三端检查:project_id={},{};{}",
value["project_id"].as_str().unwrap_or("?"),
if value["all_applicable_ready"] == true {
"适用格全部 ready"
} else {
"有未就绪格"
},
columns.join(";")
)
}
fn tool_success_response(tool_name: &str, text: String) -> Value {
const STRUCTURED_TOOLS: &[&str] = &[
"get_delivery_status",
"get_toolchain_status",
"get_project_resource_status",
"verify_onboarding",
"probe_client_parity",
];
if STRUCTURED_TOOLS.contains(&tool_name) {
if let Ok(structured) = serde_json::from_str::<Value>(&text) {
@ -1486,12 +1404,6 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
)
})
}
"probe_client_parity" => {
let pid = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let tier = args.get("tier").and_then(Value::as_str);
crate::mcp_inject::probe_client_parity(pid, tier)
.and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string()))
}
"verify_onboarding" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let root = resolve_project_root(pid)?;
@ -2579,82 +2491,6 @@ mod tests {
}
}
#[tokio::test]
async fn parity_probe_is_global_read_only_and_rejects_unknown_tier_before_io() {
let global = tools_list_result();
let probe = global["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "probe_client_parity")
.expect("全局路由应有 probe_client_parity");
assert_eq!(probe["annotations"]["readOnlyHint"], true);
assert_eq!(probe["annotations"]["destructiveHint"], false);
assert_eq!(probe["inputSchema"]["required"], json!(["project_id"]));
assert_eq!(
probe["inputSchema"]["properties"]["tier"]["enum"],
json!(["l1"])
);
// 全局目录不得出现敏感读取工具名(scoped_dev_variable_http_pilot 有同一断言)
for sensitive in ["read_project_test_account", "read_project_dev_variable"] {
assert!(!probe.to_string().contains(sensitive), "{sensitive}");
}
let scoped = tools_list_result_for_project("p1");
assert!(!scoped["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "probe_client_parity"));
let from_project = tools_call_for_project(
Some(&json!({"name": "probe_client_parity", "arguments": {}})),
"p1",
)
.await;
assert_eq!(from_project["structuredContent"]["code"], "scope_violation");
// tier 校验在本体入口最前面:不查登记、不起任何进程
let l3 = tools_call(Some(&json!({
"name": "probe_client_parity",
"arguments": { "project_id": "p1", "tier": "l3" }
})))
.await;
assert_eq!(l3["isError"], true);
assert!(l3["content"][0]["text"]
.as_str()
.unwrap()
.contains("tier=l3"));
}
#[test]
fn parity_probe_summary_lists_versions_and_causes() {
let cell = |state: &str, cause: Option<&str>| match cause {
Some(cause) => {
json!({ "state": state, "cause": cause, "evidence": "protocol", "detail": "" })
}
None => json!({ "state": state, "evidence": "protocol", "detail": "" }),
};
let ready = json!({
"A": cell("ready", None), "B": cell("ready", None), "C": cell("ready", None),
"D": cell("ready", None), "E": cell("ready", None), "F": cell("ready", None)
});
let mut auth = ready.clone();
auth["F"] = cell("fail", Some("auth_failed"));
let na = cell("not_applicable", Some("control_plane"));
let summary = parity_probe_summary(&json!({
"project_id": "p1",
"all_applicable_ready": false,
"clients": [
{ "client": "claude", "version": null, "cells": {
"A": na, "B": na, "C": na, "D": na, "E": na, "F": na } },
{ "client": "codex", "version": "0.155.0", "cells": auth },
{ "client": "dsh", "version": "0.2.0", "cells": ready }
]
}));
assert!(summary.contains("有未就绪格"), "{summary}");
assert!(summary.contains("claude 不适用"), "{summary}");
assert!(summary.contains("codex 0.155.0 F=auth_failed"), "{summary}");
assert!(summary.contains("dsh 0.2.0 6/6 ready"), "{summary}");
}
#[tokio::test]
async fn scoped_calls_reject_cross_project_and_global_write_before_io() {
let readiness_mismatch = tools_call_for_project(

View File

@ -129,13 +129,6 @@ pub trait ClientAdapter: Sync {
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome;
/// 收敛到期望状态;调用方须持有接入锁。失败返回 failed / unavailable,不 panic、不中断其他端。
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome;
/// L1 零模型检查(P4a)要的本端配置事实:只读文件、不起子进程、不读凭据。客户端自省与认证链重放
/// 由 onboard::probe_l1 按这里声明的来源与 helper 执行方式进行;不适用(控制面 / WSL)时不读任何配置。
fn l1_facts(
&self,
project: &ProjectFacts,
desired: &DesiredMcp,
) -> crate::onboard::probe_l1::L1ClientFacts;
}
/// 三端适配器注册表;顺序即报告顺序。

View File

@ -7,7 +7,6 @@
pub mod clients;
pub mod plan;
pub mod probe_l1;
#[cfg(test)]
mod fitness_tests {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1644,88 +1644,6 @@ export interface OnboardingReport {
export const applyOnboardingPack = (projectPath: string, projectId: string) =>
invoke<OnboardingReport>("apply_onboarding_pack", { projectPath, projectId });
// ── 三端对等 L1 零模型检查(agent-client-parity P4a)────────────────────────────
// 类型与 src-tauri/src/onboard/probe_l1.rs 的 L1Report 对应;MCP probe_client_parity 返回同一结构。
/** A 项目规则 / B lian-jing skill / C MCP 工具集 / D get_diagnostics / E 列出开发变量 / F 敏感读取认证链 */
export type ParityCheck = "A" | "B" | "C" | "D" | "E" | "F";
/** 证据等级,从弱到强;L1 至多到 protocol */
export type ParityEvidence =
| "static"
| "client_introspection"
| "protocol"
| "session_event"
| "server_trace";
/** not_installed 与 not_applicable 不计失败 */
export type ParityCellState =
| "ready"
| "fail"
| "unknown"
| "not_installed"
| "not_applicable"
| "blocked"
| "timeout";
export type ParityCause =
| "control_plane"
| "wsl"
| "timeout"
| "unreachable"
| "client_error"
| "not_configured"
| "route_global"
| "route_mismatch"
| "not_connected"
| "codex_project_untrusted"
| "server_disabled"
| "manual_config"
| "rules_not_loaded"
| "skill_not_visible"
| "session_plugin_mismatch"
| "shim_missing"
| "helper_failed"
| "auth_header_missing"
| "auth_failed"
| "tools_mismatch"
| "tool_error"
| "adapter_drift";
export interface ParityCell {
state: ParityCellState;
cause?: ParityCause;
evidence: ParityEvidence;
detail: string;
}
export interface ParityClientColumn {
client: "claude" | "codex" | "dsh";
version: string | null;
/** 该端对本项目实际会连的地址;未得出为 null */
route: string | null;
cells: Record<ParityCheck, ParityCell>;
duration_ms: number;
}
export interface ClientParityL1Report {
project_id: string;
tier: "l1";
probe_spec_version: number;
profile: "project" | "control_plane";
expected_route: string;
expected_tools: number;
nonce: string;
clients: ParityClientColumn[];
all_applicable_ready: boolean;
duration_ms: number;
note: string;
}
/** 约 5–10 秒:起零模型客户端自省并重放认证链(认证头不出炼境进程) */
export const probeClientParity = (projectId: string, tier: "l1" = "l1") =>
invoke<ClientParityL1Report>("probe_client_parity", { projectId, tier });
// ── Agent Infrastructure ──────────────────────────────────────────────────────
export interface DetectedContract {