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