From 557b89bd2c8805a91b4789f28455fd751615b055 Mon Sep 17 00:00:00 2001 From: Yangshifu1024 Date: Sat, 17 Oct 2026 00:01:34 +0800 Subject: [PATCH 1/9] fix(safety): enforce path, network and skill boundaries Validate relative reads against canonical roots and include scope and disabled policy in skill cache identity. Share a cancellable HTTP executor with bounded decoded bodies, pinned local DNS, explicit proxy routing and safe redirects. Cover real local proxy routing, authentication, cancellation and response limits. Remove the obsolete DNS helper and duplicate fixtures, and document remaining TLS, platform and in-flight revocation boundaries. Validation on macOS: cargo test --offline --workspace (1467 passed, 2 ignored), strengthened HTTP query roundtrip, cargo build --offline, cargo fmt check and cargo clippy --offline --all-targets (no warnings). No GUI or other-platform validation was performed in this batch. --- docs/mcp-and-skills.md | 4 +- docs/runtime-and-scheduling.md | 2 +- docs/subagents.md | 2 +- docs/technical-design.md | 2 +- docs/testing.md | 4 +- docs/tools-and-safety.md | 18 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/skills/mod.rs | 86 ++- src-tauri/src/tools/http_request.rs | 323 +++++++---- src-tauri/src/tools/net.rs | 107 +--- src-tauri/src/tools/net/request.rs | 356 ++++++++++++ src-tauri/src/tools/net/request_tests.rs | 706 +++++++++++++++++++++++ src-tauri/src/tools/pathutil.rs | 85 ++- src-tauri/src/tools/skill.rs | 6 +- src-tauri/src/tools/web_fetch.rs | 142 ++--- 16 files changed, 1494 insertions(+), 353 deletions(-) create mode 100644 src-tauri/src/tools/net/request.rs create mode 100644 src-tauri/src/tools/net/request_tests.rs diff --git a/docs/mcp-and-skills.md b/docs/mcp-and-skills.md index 40ececff..0921b1ed 100644 --- a/docs/mcp-and-skills.md +++ b/docs/mcp-and-skills.md @@ -83,9 +83,9 @@ MCP server 的 is_error 返回工具失败;文本和图片进入已有内容 设置页可删除项目/全局托管技能,不删内置与 `.agents/.claude` 兼容目录。删除路径须在 canonical 后仍位于允许的托管根内;禁用与删除是两件事,不能因为技能已禁用就丢掉安全定位所需的信息。 -### 缓存的已知限制 +### 缓存与禁用生效 -SkillIndex 的 get/list 缓存 TTL 为 10 秒,key 只有 workspace/project,未包含 disabled 列表。当前 toggle_skill 与 save_config 不主动 invalidate;因此禁用已加载技能可能在 TTL 内仍可被取得。reload/delete 会失效缓存。不能把“设置已禁用”写成即时撤销保证;[tools/skill.rs](../src-tauri/src/tools/skill.rs) 中 `disabled_after_load_should_invalidate_within_ttl` 仍是 ignored 探针,需修复缓存契约后启用。 +SkillIndex 的 get/list 缓存 TTL 为 10 秒;结构化 key 包含 workspace、全局 data_dir、project_dir 和排序去重后的 disabled 集。配置禁用/重新启用发布后,下一次 get/list 不能命中旧政策的缓存,不必等待 TTL 或依赖某个配置入口主动 invalidate;reload/delete 仍强制失效。不同全局数据目录和项目作用域不会串缓存。[tools/skill.rs](../src-tauri/src/tools/skill.rs) 的 `disabled_after_load_should_invalidate_within_ttl` 已启用,覆盖加载后禁用及重新启用。已进入运行历史/冻结系统提示的技能内容不会因此从模型记忆中撤回;这里保证后续获取拒绝,不承诺即时撤销在途步骤。 ## 5. 技能加载、询问规范与输入触发 diff --git a/docs/runtime-and-scheduling.md b/docs/runtime-and-scheduling.md index 28774ab8..eccbcca1 100644 --- a/docs/runtime-and-scheduling.md +++ b/docs/runtime-and-scheduling.md @@ -206,4 +206,4 @@ service ticker 更新活日志;service 退出当前按 Succeeded 表示进程 重点测试:后端 `core::agent::tests` 的收尾矩阵和脚本化 SSE、runtime 的 run_snapshot/activity、`core::scheduler`、`core::wait_targets`、`tools::wait`、`tools::service`;前端 `run.terminal-fallback`、`run.queue`、`run.service-update`、`toolcard.service-status`、`activity.store`、`activity.navigation`、`activity.dashboard`、`tasks.page`、`stores.tasks`、`schedulePreset`。完整运行方式见[测试指南](./testing.md)。 -仍开放:事件投递丢失的实机根因尚未最终证实,`bindEvents` 仍逐键串行 await、单次注册失败可中断后续注册;兜底不等于已定位根因。ask 的部分等待路径仍缺独立 Drop 收口。混合批次被拒调用的 user 反馈、`` 只是包含匹配导致复述误收尾、无界 web_fetch/工具执行超时、服务退出 stop-all/owner 清理、缺 service id 的停止按钮提示,均不能标成已修。本指南只记录边界,本轮不修改运行代码。 +仍开放:事件投递丢失的实机根因尚未最终证实,`bindEvents` 仍逐键串行 await、单次注册失败可中断后续注册;兜底不等于已定位根因。ask 的部分等待路径仍缺独立 Drop 收口。混合批次被拒调用的 user 反馈、`` 只是包含匹配导致复述误收尾、任意工具批次的统一总时限、服务退出 stop-all/owner 清理、缺 service id 的停止按钮提示,均不能标成已修。web_fetch/http_request 的网络 I/O 总预算见[工具与安全](./tools-and-safety.md#5-网络逐跳-ssrf-的范围),不能外推到全部工具。 diff --git a/docs/subagents.md b/docs/subagents.md index 695598c9..2f50659e 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -165,7 +165,7 @@ spawn 固化所属根 run 身份,创建独立 runtime 并注册 core.subs、 - 终态投递丢失的实机根因未最终定位。当前 `bindEvents` 串行订阅,单键 reject 会影响后续键;兜底改善可恢复性,不等于根因已消失。 - 失败/用户停止的 ToolOutcome 没有 sub_id,第二道工具结果兜底只覆盖成功路径;失败主要依靠 sub:error 和主 run 收尾/对账。 - `` 包含匹配会把复述误当汇报;混合批次无对应 tool_use 的被拒结果可能被 repair 丢弃,模型看不到那部分反馈。 -- 无工具执行期统一墙钟看门狗;当前 web_fetch 仍存在无界等待风险。LLM 流停滞检测不覆盖整个工具批次。 +- 无工具执行期统一墙钟看门狗。web_fetch/http_request 已有覆盖 DNS、节流、跳转与正文的总预算,仍不能据此推导其它工具或整个批次都有相同墙钟限制;LLM 流停滞检测也不覆盖整个工具批次。 - NudgeOnly 不豁免失败重复、MAX_TEXT_TURNS、步数预算;“只读永不终止”是错误承诺。grep 命中新文件尚不算独立进展,调用名驱动的摘要也不能可靠证明实际副作用。 - 没有运行中动态扩预算、extend 续跑或从暂停点恢复子 run 的协议。失败、预算不足和 partial 应按已完成/未完成范围决定下一步。 - 中断或监督失败时没有统一生成部分报告的额外收尾轮,已有过程历史不等于已返回结构化成果。子过程通常在结束保存,不能保证每个在途帧耐久保存。 diff --git a/docs/technical-design.md b/docs/technical-design.md index aadb588e..ef72932a 100644 --- a/docs/technical-design.md +++ b/docs/technical-design.md @@ -69,7 +69,7 @@ D3 的 Vue/Naive UI/Pinia 与 D7 的私有分发设想是历史决策,当前 - 批次统一处理交互排他、写冲突、并发、取消和 panic;`ask/wait` 与 `suggest` 的混批语义不同,不能按枚举一概拒绝或放行。 - 前端拿完整 `ToolOutcome`,模型拿 `compact_for_model` 的文本。**写入后检查**结论必须进入 `outcome.data.check/checks`;仅写 warnings 模型看不到。未执行检查必须报告跳过原因。 - 文件工具共用 pathutil,命令共用 fence;plan 下需确认的命令转拒绝,FullAccess 不绕过直接 Block。方案批准和单次操作审批是不同机制,预览不授权。 -- web_fetch/http_request 的 SSRF 层不覆盖用户配置的 Provider 端点;DNS 校验后连接仍有二次解析窗口,不能宣称完整消除 rebinding。 +- web_fetch/http_request 共用逐跳地址固定、代理路由快照、总预算与流式限额;禁止内网时明确拒绝无法固定目标地址的远端 DNS 代理域名请求。该 SSRF 层不覆盖 Provider/MCP,也不宣称约束恶意代理或底层网络路由,详见工具指南。 - WebView CSP/capabilities、隔离预览和 Rust 工具安全层各有边界,不能互相代替。 具体语义见 [tools-and-safety](./tools-and-safety.md)、[providers-and-network](./providers-and-network.md)、[mcp-and-skills](./mcp-and-skills.md)。文档读写/保真修改见 [documents](./documents.md):只替换目标包内成员,不整份解析重建。 diff --git a/docs/testing.md b/docs/testing.md index 59b8d520..9fa2e46a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -134,8 +134,8 @@ Rust ignored 的完整名称: | 事项 | 已核实边界 / 后续验证 | |---|---| -| Skills 禁用缓存 | `get/list` 命中 TTL 时不重判 disabled,保存/切换禁用未失效缓存;`disabled_after_load_should_invalidate_within_ttl` 仍 ignored。详见 [mcp-and-skills](./mcp-and-skills.md) | -| DNS rebinding | 工具先解析校验,reqwest 连接未固定已验证 IP;逐跳校验并不消除二次解析窗口。详见 [tools-and-safety](./tools-and-safety.md) | +| 技能在途撤销 | get/list 缓存已包含作用域和 disabled 集,撤销探针已启用;已加载进历史或冻结提示的正文不作回收。详见 [mcp-and-skills](./mcp-and-skills.md#缓存与禁用生效) | +| 网络代理校验 | 工具逐跳固定已校验地址;禁止内网时拒绝无法固定目标的远端 DNS 代理域名请求,不静默直连。真实代理、TLS 与三平台联调仍须另验;不覆盖 Provider/MCP。详见 [tools-and-safety](./tools-and-safety.md) | | 长期会话索引 | 索引仍有 2000 条 LRU 上限;索引元数据缺失时恢复会退回单根、无项目归属。完整历史保留不等于元数据无限保留,见 [sessions-and-projects](./sessions-and-projects.md) | | 项目提示与命令 cwd | 运行提示中的 temps 描述和工具实际默认 project_dir 存在差异;应以执行代码为准并独立修正/测试,见 [runtime-and-scheduling](./runtime-and-scheduling.md) | | 终态事件丢失 | 快照对账修复了展示与续队能力,不证明底层事件丢失原因已定位;需保留日志与 run 身份关联证据 | diff --git a/docs/tools-and-safety.md b/docs/tools-and-safety.md index 0ce16406..8b0727a7 100644 --- a/docs/tools-and-safety.md +++ b/docs/tools-and-safety.md @@ -39,7 +39,7 @@ ask/wait 与其他调用同批时整批 `E_BATCH_POLICY`。suggest 混批时只 [WriteRoots/pathutil](../src-tauri/src/tools/pathutil.rs) 是路径处理入口:工作区主目录快照 + 全局数据目录 + 用户授权 extra_roots。相对写路径基于工作区,显式绝对写路径必须规范化后落在根内;safe_join 拒绝 `..`、控制字符及 Windows 保留名。 - 相对读取先查主根,再按 extra_roots 顺序查首个存在文件;绝对读取走 canonical 边界校验。 -- **当前读侧缺口**:相对且已存在的候选分支直接返回,未经过函数尾部 canonical roots 校验;不要宣称该读取路径已完全防住 symlink 指向根外。修改边界时必须覆盖相对链接、绝对路径和额外根三种入口。 +- 相对读取选中首个存在候选后,也须与绝对读取一样通过 canonical roots 校验;主根/额外根中的 symlink 指向根外会返回 `E_PATH_OUTSIDE`,指向另一个已授权根则可读。保持主根优先,不因首候选越界而悄悄改读其它同名文件。路径校验与打开之间仍存在外部进程换链窗口,不是 OS 沙箱保证。 - 新增项目外目录是显式用户界面流程,授权读/写/列整组能力;不是任意 read/edit 遇到 E_PATH_OUTSIDE 都会自动弹窗扩根。根目录、家目录本身与应用托管数据目录有广度拒绝,详见[文档与外部引用](./documents.md)。 - FullAccess 不解除文件工具的 roots 校验。shell 的区外新建策略是另一条路径,不能套用到 create/edit。 - delete 先过 roots 与危险删除守卫,拒绝根目录、任一工作区/extra 根自身、这些根的 `.git`、家目录与系统目录。shell 删除黑名单不允许用确认绕过,应该走专用 delete 工具。 @@ -128,17 +128,19 @@ gh 不能仅按名字放行,因为远程写不一定命中文件/高危规则 ## 5. 网络:逐跳 SSRF 的范围 -[net.rs](../src-tauri/src/tools/net.rs) 只用于 web_fetch/http_request;Provider 的用户配置端点与 MCP 网络栈**不自动继承它**。默认拒私网/回环/链路本地/未指定/CGNAT 等已列地址,IPv4-mapped IPv6 先归一;域名解析后任一结果为私网则拒绝。allow_private_network 是显式设置放宽,不是用户在消息中写 localhost 即自动开放。 +[net.rs](../src-tauri/src/tools/net.rs) 及其[统一请求执行器](../src-tauri/src/tools/net/request.rs)只用于 web_fetch/http_request;Provider 的用户配置端点与 MCP 网络栈**不自动继承它**。默认拒私网/回环/链路本地/未指定/CGNAT 等已列地址,IPv4-mapped IPv6 先归一;域名解析后任一结果为私网则拒绝。allow_private_network 是显式设置放宽,不是用户在消息中写 localhost 即自动开放。 -两个工具关闭自动重定向,最多 10 跳,每跳重新 guard_host 与同 host 最少 1 秒节流。web_fetch 明确只允许 HTTP(S),用流式 read_body_limited 把响应体限制为 50 MiB;HTML 用 Readability,失败退化剥标签,默认 maxChars=60,000、范围 1,000–500,000,目前不支持 markdown 格式参数或图片内容通道。web_fetch 的客户端只有 10 秒连接超时,没有端到端 wall timeout;字节上限不能防止慢速持续响应。http_request 自身有请求超时,command/MCP 也各有超时,仍不能等同于批次层已存在统一工具执行总时限。 +两个工具仅允许 HTTP(S),拒绝 URL 内嵌凭据,关闭自动重定向,最多跟随 10 跳;要求本地解析的路由每跳重新解析并校验全部目标地址,以该地址集合构造本跳客户端,保持原域名用于 Host/TLS 校验,不能二次解析成其它连接目标。允许内网时,远端 DNS 代理保持代理解析,不先要求本地 DNS 成功。每 host 最少 1 秒节流。两工具的 `timeoutSeconds` 默认 60、范围 1–120,统一总预算覆盖代理探测、DNS、节流、全部跳转和正文读取;取消优先,取消返回 `E_CANCELLED`,超时返回 `E_NETWORK`。macOS 显式系统代理探测为可取消子进程;默认原生系统探测及其它平台探测在阻塞工作线程执行,请求取消后原生查询可能继续结束。这不等于批次层已有任意工具的统一总时限,也不包括正文 Readability 的同步 CPU 处理。 -http_request 的方法白名单为 GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS,默认超时 60 秒、范围 1–120;JSON 预览 24 Ki 字符、文本 96 Ki 字符,二进制只报体积和 MIME;响应头只对 set-cookie/www-authenticate 做脱敏。 +两工具均使用 `read_body_limited` 对**解压后的流式正文**实施 50 MiB 硬上限,无 Content-Length/分块响应同样受限;声明长度超限则提前拒绝。web_fetch 的 HTML 用 Readability,失败退化剥标签,默认 maxChars=60,000、范围 1,000–500,000,目前不支持 markdown 格式参数或图片内容通道;新增 `final_url` 标明实际取回地址,原 `url` 保留用户输入。 -须明确的网络欠账: +http_request 的方法白名单为 GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS;JSON 预览 24 Ki 字符、文本 96 Ki 字符,二进制只报体积和 MIME;响应头只对 set-cookie/www-authenticate 做脱敏。body 是 JSON 值,字符串也按 JSON 字符串发送;query 的名称和值均编码。Host、Content-Length、Transfer-Encoding 和 Proxy-Authorization 不允许由 caller 头覆盖。 -1. guard_host 的 DNS 检查后,reqwest 建连会再次解析;**地址未绑定,仍有 DNS rebinding/TOCTOU 窗口**,不能写“已彻底阻止重绑定”。 -2. http_request 只用 Content-Length 预检,随后 `resp.bytes()` 全量读取;无长度/分块响应尚无同样的流式硬上限。 -3. http_request 重定向保持原方法、headers 与 JSON body,未按 303/307/308 分派,也未剥跨源敏感头;访问不可信重定向目标可能泄露调用方凭据/数据。 +重定向与代理契约: + +1. 只跟随 301/302/303/307/308;301/302 仅 POST 转 GET,303 除 HEAD 外转 GET,转换后清除 body 和实体头;307/308 同源保留方法与 body。跨源(含端口变化)清空**全部 caller 自定义头**,避免漏掉自定义凭据;若仍需重放 body,返回 `E_REDIRECT_UNSAFE`。HTTPS 降级到 HTTP 同样拒绝,已剥头不会在后续跳回原源时恢复。 +2. 每次工具执行使用与 reqwest 同源的 hyper-util matcher 固化系统/环境/NO_PROXY 路由,显式代理保留原决议及回环直连例外。显式地址先校验协议和主机,统一 IDNA 与 SOCKS 缺省端口;不受支持的配置明确报错,不能把 matcher 丢弃配置误认作直连。客户端只使用该快照,构建失败不回退绕开代理的直连。 +3. 直连、本地 DNS SOCKS4/5 使用已校验地址。HTTP/HTTPS CONNECT、SOCKS4a/5h 会在代理端解析域名,不能由 `resolve_to_addrs` 证明实际目标;禁止内网时,对这类代理的域名目标明确返回 `E_SSRF_PROXY_UNSUPPORTED`,不发送、不静默直连。可改用本地 DNS SOCKS,或由用户明确开启 allow_private_network 放宽策略。IP 字面量没有目标 DNS 二次解析。该策略不宣称约束恶意代理或底层网络路由,真实代理联调仍需单独验收。 4. Network kind 没有统一触网审批,Plan 也未排除 http_request;所以“计划档只读”不能扩展成对任意网络写的完整技术保证。端点访问和授权是不同问题。 ## 6. 写入后检查 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5ddd00c..7adda18c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -767,6 +767,7 @@ dependencies = [ "git2", "grep-regex", "grep-searcher", + "hyper-util", "ignore", "keyring", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b6bd58fe..e4ae1039 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,9 @@ serde_yaml = "0.9" # http / llm reqwest = { version = "0.13", features = ["json", "socks", "stream", "gzip", "brotli"] } +# Reuse reqwest's proxy matcher so guarded tool requests snapshot the actual +# env/system/NO_PROXY route before pinning DNS, without silently going direct. +hyper-util = { version = "0.1", features = ["client-proxy", "client-proxy-system"] } # tools & safety git2 = { version = "0.21", default-features = false } diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index dce52f9e..9fff3934 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -393,7 +393,7 @@ PDF 里的文字是「用哪个字形画在哪里」,而不是「这段文字 } /// TTL 缓存槽位:(采集时刻, scope key, 以技能名为键的索引)。 -type SkillCache = Mutex)>>; +type SkillCache = Mutex)>>; /// 技能索引:带 scope key 的 TTL 缓存,负责扫描/查询/列表。 pub struct SkillIndex { @@ -403,15 +403,30 @@ pub struct SkillIndex { ttl: Duration, } -/// 缓存 scope key:workspace + project_dir 唯一确定一份技能索引。 -fn cache_key(workspace: &Path, project_dir: Option<&Path>) -> String { - format!( - "{}|{}", - workspace.display(), - project_dir - .map(|p| p.display().to_string()) - .unwrap_or_default() - ) +/// Scope and policy are part of cache identity; policy changes must take effect without TTL delay. +#[derive(PartialEq, Eq)] +struct SkillCacheKey { + workspace: PathBuf, + data_dir: PathBuf, + project_dir: Option, + disabled: Vec, +} + +fn cache_key( + workspace: &Path, + data_dir: &Path, + project_dir: Option<&Path>, + disabled: &[String], +) -> SkillCacheKey { + let mut disabled = disabled.to_vec(); + disabled.sort_unstable(); + disabled.dedup(); + SkillCacheKey { + workspace: workspace.to_path_buf(), + data_dir: data_dir.to_path_buf(), + project_dir: project_dir.map(Path::to_path_buf), + disabled, + } } /// 路径归一为正斜杠形态(origin 与目录前缀比较时统一 Windows 反斜杠差异)。 @@ -567,7 +582,7 @@ impl SkillIndex { project_dir: Option<&Path>, name: &str, ) -> Option { - let key = cache_key(workspace, project_dir); + let key = cache_key(workspace, data_dir, project_dir, disabled); { let g = self.cache.lock().unwrap(); if let Some((at, cached_key, map)) = g.as_ref() @@ -592,7 +607,7 @@ impl SkillIndex { disabled: &[String], project_dir: Option<&Path>, ) -> Vec { - let key = cache_key(workspace, project_dir); + let key = cache_key(workspace, data_dir, project_dir, disabled); // 路径统一正斜杠后做前缀归类(Windows 反斜杠与 origin 的 display 形态一致化) let norm = norm_slash; let proj_prefix = project_dir.map(|p| norm(&p.display().to_string())); @@ -1056,4 +1071,51 @@ mod tests { .any(|m| m.name == "later") ); } + + #[test] + fn disabled_changes_apply_to_cached_get_and_list_in_both_directions() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let idx = SkillIndex { + cache: Mutex::new(None), + ttl: Duration::from_secs(3600), + }; + let name = "repo-index"; + let disabled = vec![name.to_string()]; + // Exercise both get->list and list->get cache population paths. + for populate_with_get in [true, false] { + idx.invalidate(); + if populate_with_get { + assert!(idx.get(ws.path(), dd.path(), &[], None, name).is_some()); + } else { + assert!( + idx.list(ws.path(), dd.path(), &[], None) + .iter() + .any(|s| s.name == name) + ); + } + assert!( + idx.get(ws.path(), dd.path(), &disabled, None, name) + .is_none() + ); + assert!( + !idx.list(ws.path(), dd.path(), &disabled, None) + .iter() + .any(|s| s.name == name) + ); + assert!(idx.get(ws.path(), dd.path(), &[], None, name).is_some()); + assert!( + idx.list(ws.path(), dd.path(), &[], None) + .iter() + .any(|s| s.name == name) + ); + // Also change policy with list as the first reader of the changed configuration. + assert!( + !idx.list(ws.path(), dd.path(), &disabled, None) + .iter() + .any(|s| s.name == name) + ); + assert!(idx.get(ws.path(), dd.path(), &[], None, name).is_some()); + } + } } diff --git a/src-tauri/src/tools/http_request.rs b/src-tauri/src/tools/http_request.rs index 86878161..7c3fbaad 100644 --- a/src-tauri/src/tools/http_request.rs +++ b/src-tauri/src/tools/http_request.rs @@ -19,7 +19,7 @@ pub struct Args { /// query 参数(自动 urlencode 后拼接到 URL)。 #[serde(default)] query: Option>, - /// 请求体:JSON(object/array)或原始字符串。 + /// 请求体:JSON 值(字符串同样按 JSON 字符串发送)。 #[serde(default)] body: Option, /// 超时秒数,默认 60、钳制 1–120。 @@ -35,8 +35,8 @@ const JSON_PREVIEW_LIMIT: usize = 24 * 1024; const TEXT_PREVIEW_LIMIT: usize = 96 * 1024; /// http_request 工具:对 Web API 发起通用 HTTP 请求(JSON 友好)。 -/// 入参为 url(必填)+ method/headers/query/body/timeoutSeconds;Network 分级,执行前需用户确认。 -/// 安全语义:每跳经 guard_host 的 SSRF 守卫 + 节流,重定向手动逐跳跟随(关闭自动重定向), +/// 入参为 url(必填)+ method/headers/query/body/timeoutSeconds;Network 分级。 +/// 安全语义:统一请求执行器逐跳校验/固定地址并节流,重定向手动逐跳跟随(关闭自动重定向), /// 响应头脱敏(set-cookie / www-authenticate 置 [redacted]),响应体按类型截断预览。 pub struct HttpRequestTool; @@ -58,8 +58,8 @@ impl Tool for HttpRequestTool { "method": {"type": "string", "description": "默认 GET"}, "headers": {"type": "object", "additionalProperties": {"type": "string"}}, "query": {"type": "object", "additionalProperties": {"type": "string"}}, - "body": {"description": "请求体:JSON(object/array)或原始字符串"}, - "timeoutSeconds": {"type": "integer", "description": "默认 60"} + "body": {"description": "请求体:JSON 值,字符串同样按 JSON 字符串编码"}, + "timeoutSeconds": {"type": "integer", "description": "总预算秒数,默认 60,范围 1–120"} } }"# } @@ -80,87 +80,61 @@ impl Tool for HttpRequestTool { return ToolOutcome::err("E_ARGS", format!("不允许的方法 {method}")); } - // 组装 URL(追加 query) - let mut url = args.url.clone(); - if let Some(q) = &args.query - && !q.is_empty() - { - let qs = q - .iter() - .map(|(k, v)| format!("{k}={}", urlencode(v))) + let mut url = match reqwest::Url::parse(&args.url) { + Ok(url) => url, + Err(_) => return ToolOutcome::err("E_URL", "URL 无效"), + }; + if let Some(query) = &args.query { + let mut pairs: Vec<_> = query.iter().collect(); + pairs.sort_by_key(|(key, _)| *key); + let extra = pairs + .into_iter() + .map(|(key, value)| format!("{}={}", urlencode(key), urlencode(value))) .collect::>() .join("&"); - url.push(if url.contains('?') { '&' } else { '?' }); - url.push_str(&qs); - } - - let allow_private = ctx.core.cfg.read().unwrap().network.allow_private_network; - - let parsed = match reqwest::Url::parse(&url) { - Ok(u) => u, - Err(e) => return ToolOutcome::err("E_URL", format!("URL 无效:{e}")), - }; - // guarded_get 内部含 SSRF + 限流(但探针固定 GET——这里需要保留原方法),复用 guard_host 的逐跳逻辑: - // 关闭自动重定向发起完整请求,逐跳在本函数内手动处理。 - let timeout = - std::time::Duration::from_secs(args.timeout_seconds.unwrap_or(60).clamp(1, 120)); - // 代理感知 client(读锁快照 config);redirect none 保持逐跳校验语义,超时改挂在每个请求上 - let client = crate::provider::proxy::build_client_with( - &ctx.core.cfg.read().unwrap().clone(), - reqwest::redirect::Policy::none(), - ); - - let mut current = parsed; - let method_ref = method.clone(); - let mut resp = None; - for _hop in 0..=super::net::MAX_REDIRECTS { - let host = current.host_str().unwrap_or_default().to_string(); - let port = current.port_or_known_default().unwrap_or(80); - if let Err(e) = super::net::guard_host(&host, port, allow_private).await { - return ToolOutcome::err(e.code, e.message); + if !extra.is_empty() { + let query = url + .query() + .filter(|q| !q.is_empty()) + .map(|query| format!("{query}&{extra}")) + .unwrap_or(extra); + url.set_query(Some(&query)); } - super::web_fetch::throttle_pub().wait(&host).await; - - let mut req = client - .request(method_ref.parse().unwrap(), current.clone()) - .timeout(timeout); - if let Some(h) = &args.headers { - for (k, v) in h { - req = req.header(k, v); - } - } - if let Some(b) = &args.body { - req = req.json(b); - } - let r = match req.send().await { - Ok(r) => r, - Err(e) => return ToolOutcome::err("E_NETWORK", format!("请求失败:{e}")), + } + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in args.headers.unwrap_or_default() { + let Ok(name) = reqwest::header::HeaderName::from_bytes(name.as_bytes()) else { + return ToolOutcome::err("E_ARGS", "请求头名称无效"); }; - if r.status().is_redirection() { - let loc = r - .headers() - .get("location") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - match loc.and_then(|l| current.join(&l).ok()) { - Some(next) => { - current = next; - continue; - } - None => return ToolOutcome::err("E_URL", "重定向地址无效".to_string()), - } + let Ok(value) = reqwest::header::HeaderValue::from_str(&value) else { + return ToolOutcome::err("E_ARGS", "请求头值无效"); + }; + // The URL and HTTP stack own connection routing and framing. + if matches!( + name.as_str(), + "host" | "content-length" | "transfer-encoding" | "proxy-authorization" + ) { + return ToolOutcome::err("E_ARGS", "请求头不能覆盖连接目标或消息边界"); } - resp = Some(r); - break; + headers.insert(name, value); } - let Some(resp) = resp else { - return ToolOutcome::err("E_REDIRECTS", "重定向过多".to_string()); + let cfg = ctx.core.cfg.read().unwrap().clone(); + let request = super::net::request::HttpRequest { + url, + method: method.parse().unwrap(), + headers, + body: args.body, }; - - let status = resp.status(); + let timeout = + std::time::Duration::from_secs(args.timeout_seconds.unwrap_or(60).clamp(1, 120)); + let resp = match super::net::request::execute(&cfg, request, &ctx.cancel, timeout).await { + Ok(response) => response, + Err(error) => return ToolOutcome::err(error.code, error.message), + }; + let status = resp.status; // 响应头脱敏 let mut headers_out = serde_json::Map::new(); - for (k, v) in resp.headers() { + for (k, v) in &resp.headers { let key = k.as_str().to_ascii_lowercase(); if matches!(key.as_str(), "set-cookie" | "www-authenticate") { headers_out.insert(key, json!("[redacted]")); @@ -174,14 +148,7 @@ impl Tool for HttpRequestTool { .unwrap_or("") .to_string(); - let len = resp.content_length().unwrap_or(0); - if len > super::net::MAX_BODY_BYTES { - return ToolOutcome::err("E_TOO_LARGE", format!("响应体 {len} 字节超过 50MB 上限")); - } - let bytes = match resp.bytes().await { - Ok(b) => b, - Err(e) => return ToolOutcome::err("E_NETWORK", format!("读取响应失败:{e}")), - }; + let bytes = resp.body; let body_preview: Value; let mut binary = false; @@ -250,42 +217,66 @@ mod tests { type Received = Arc>>; - /// 强制直连:Windows 系统代理(注册表,如 Clash 监听 127.0.0.1:7890)会拦截回环流量 - /// 并改写请求(User-Agent、头部大小写)。NO_PROXY 条目对 IP 主机按 IP 匹配(裸 "*" 只覆盖 - /// 域名),所以显式列出回环字面量;client 每次调用新建,因此对所有后续请求生效。 - fn force_direct_connections() { - static ONCE: std::sync::Once = std::sync::Once::new(); - ONCE.call_once(|| { - let v = "127.0.0.1,localhost,::1"; - // FIXME: 审计环境变量访问是否只发生在单线程代码中。 - unsafe { std::env::set_var("NO_PROXY", v) }; - // FIXME: 审计环境变量访问是否只发生在单线程代码中。 - unsafe { std::env::set_var("no_proxy", v) }; - }); + struct HttpFixture { + // Keep the port reserved until the client and worker have both finished. + _listener: Arc, + worker: std::thread::JoinHandle<()>, + } + + impl HttpFixture { + fn join(self) -> std::thread::Result<()> { + self.worker.join() + } } /// 一次性串行 HTTP 服务:对前 N 个连接各回一个响应,并记录每个原始请求(头 + 体)。 /// `mk_responses` 拿到绑定地址,重定向目标可引用它而避免端口竞态。 fn start_server( mk_responses: impl FnOnce(SocketAddr) -> Vec>, - ) -> (SocketAddr, Received, std::thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + ) -> (SocketAddr, Received, HttpFixture) { + let listener = Arc::new(TcpListener::bind("127.0.0.1:0").unwrap()); + listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap(); let responses = mk_responses(addr); let received: Received = Arc::new(Mutex::new(Vec::new())); let recv = received.clone(); + let server_listener = listener.clone(); let handle = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(65); for resp in responses { - let Ok((mut stream, _)) = listener.accept() else { - break; + let mut stream = loop { + match server_listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "HTTP fixture accept timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("HTTP fixture accept failed: {error}"), + } }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); recv.lock().unwrap().push(read_request(&mut stream)); let _ = stream.write_all(&resp); let _ = stream.flush(); - // drop 掉 stream 即关闭连接(Connection: close 语义) + let _ = stream.shutdown(std::net::Shutdown::Write); } }); - (addr, received, handle) + ( + addr, + received, + HttpFixture { + _listener: listener, + worker: handle, + }, + ) } /// 从流中读取一个 HTTP 请求(头 + Content-Length 定界的体)。 @@ -330,7 +321,6 @@ mod tests { } fn make_ctx(core: Arc) -> ToolCtx { - force_direct_connections(); let ws = tempfile::tempdir().unwrap(); let rt = core.get_or_create_session( "http-req-test", @@ -359,7 +349,14 @@ mod tests { data_dir: std::fs::canonicalize(dd.path()).unwrap(), }; let core = crate::core::agent::test_support::make_core(&roots); - core.cfg.write().unwrap().network.allow_private_network = allow_private; + { + let mut cfg = core.cfg.write().unwrap(); + cfg.network.allow_private_network = allow_private; + cfg.proxy = Some(crate::core::config::ProxyConfig { + mode: crate::core::config::ProxyMode::None, + url: String::new(), + }); + } core } @@ -386,6 +383,81 @@ mod tests { assert_eq!(urlencode("中"), "%E4%B8%AD"); } + #[tokio::test] + async fn redirect_303_switches_post_to_get_without_replaying_body() { + let ctx = make_ctx(core_with(true)); + let (addr, received, server) = start_server(|_| { + vec![ + response("HTTP/1.1 303 See Other", &[("Location", "/result")], b""), + response( + "HTTP/1.1 200 OK", + &[("Content-Type", "text/plain")], + b"done", + ), + ] + }); + let out = HttpRequestTool + .run( + &ctx, + json!({ + "url": format!("http://{addr}/submit"), "method": "POST", + "headers": {"content-type": "application/json"}, + "body": {"private": "payload"} + }), + ) + .await; + server.join().unwrap(); + assert!(out.ok, "{out:?}"); + let requests = received.lock().unwrap(); + assert!(requests[1].starts_with("GET /result "), "{}", requests[1]); + assert!(!requests[1].contains("payload")); + assert!(!requests[1].to_ascii_lowercase().contains("content-type:")); + } + + #[tokio::test] + async fn cross_origin_redirect_does_not_forward_custom_credentials() { + let ctx = make_ctx(core_with(true)); + let (target, received, target_server) = start_server(|_| { + vec![response( + "HTTP/1.1 200 OK", + &[("Content-Type", "text/plain")], + b"done", + )] + }); + let (source, _, source_server) = start_server(|_| { + vec![response( + "HTTP/1.1 302 Found", + &[("Location", &format!("http://{target}/result"))], + b"", + )] + }); + let out = HttpRequestTool.run(&ctx, json!({ + "url": format!("http://{source}/start"), + "headers": {"authorization": "Bearer secret", "cookie": "session=secret", "x-api-key": "custom-secret"} + })).await; + source_server.join().unwrap(); + target_server.join().unwrap(); + assert!(out.ok, "{out:?}"); + let request = &received.lock().unwrap()[0].to_ascii_lowercase(); + assert!( + !request.contains("secret"), + "credentials escaped: {request}" + ); + assert!(!request.contains("authorization:")); + assert!(!request.contains("cookie:")); + assert!(!request.contains("x-api-key:")); + } + + #[tokio::test] + async fn cancelled_request_does_not_start_network_work() { + let ctx = make_ctx(core_with(true)); + ctx.cancel.cancel(); + let out = HttpRequestTool + .run(&ctx, json!({"url": "http://127.0.0.1:9/"})) + .await; + assert_eq!(out.error.unwrap().code, "E_CANCELLED"); + } + #[tokio::test] async fn args_and_method_validation() { let core = core_with(true); @@ -440,10 +512,10 @@ mod tests { .run( &ctx, json!({ - "url": format!("http://{addr}/api"), + "url": format!("http://{addr}/api?existing=1#fragment"), "method": "post", "headers": {"x-token": "secret-token"}, - "query": {"a": "b c", "k": "中"}, + "query": {"a": "b c", "k": "中", "a&b": "v=1#end"}, "body": {"p": 1}, }), ) @@ -455,6 +527,12 @@ mod tests { assert!(req.starts_with("POST /api?"), "raw request: {req}"); assert!(req.contains("a=b%20c"), "raw request: {req}"); assert!(req.contains("k=%E4%B8%AD"), "raw request: {req}"); + assert!(req.contains("existing=1"), "raw request: {req}"); + assert!(req.contains("a%26b=v%3D1%23end"), "raw request: {req}"); + assert!( + !req.contains("#fragment"), + "fragments must not enter HTTP request targets" + ); assert!(req.contains("x-token: secret-token"), "raw request: {req}"); assert!(req.contains(r#"{"p":1}"#), "raw request: {req}"); // Outcome 形态 @@ -629,11 +707,10 @@ mod tests { async fn connection_refused_maps_to_e_network() { let core = core_with(true); let ctx = make_ctx(core); - // 先绑定再立即 drop:端口上已无监听(绕过系统代理后,拒绝连接会以 E_NETWORK - // 而非代理 502 浮出) - let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + // Reserve a bound, non-listening port so another fixture cannot steal it. + let probe = tokio::net::TcpSocket::new_v4().unwrap(); + probe.bind("127.0.0.1:0".parse().unwrap()).unwrap(); let addr = probe.local_addr().unwrap(); - drop(probe); let tool = HttpRequestTool; let out = tool .run(&ctx, json!({"url": format!("http://{addr}/x")})) @@ -645,13 +722,29 @@ mod tests { async fn timeout_seconds_clamped_and_maps_to_e_network() { let core = core_with(true); let ctx = make_ctx(core); - // 服务端接受连接但永不响应;timeoutSeconds=0 被钳到 1s 下限 + // timeoutSeconds=0 clamps to one second, including any throttle wait. let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { - let (_s, _) = listener.accept().unwrap(); - // 保持连接打开不响应 - std::thread::sleep(std::time::Duration::from_secs(5)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + // The total budget may expire in throttling, before any connection. + loop { + match listener.accept() { + Ok((stream, _)) => { + std::thread::sleep(std::time::Duration::from_secs(2)); + drop(stream); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept: {error}"), + } + } }); let tool = HttpRequestTool; let started = std::time::Instant::now(); diff --git a/src-tauri/src/tools/net.rs b/src-tauri/src/tools/net.rs index c4f9a7a6..623ca14b 100644 --- a/src-tauri/src/tools/net.rs +++ b/src-tauri/src/tools/net.rs @@ -6,9 +6,11 @@ use std::net::IpAddr; use std::sync::Mutex; use std::time::{Duration, Instant}; +pub(super) mod request; + /// 重定向跳数上限。 pub const MAX_REDIRECTS: usize = 10; -/// 响应体字节上限(50MB)。 +/// 解压后响应体字节上限(50 MiB)。 pub const MAX_BODY_BYTES: u64 = 50 * 1024 * 1024; /// 内网/保留地址段判定(v4 + v6)。 @@ -81,6 +83,7 @@ impl HostThrottle { } /// 单跳失败的结构化错误。 +#[derive(Debug)] pub struct HopError { /// 稳定错误码(E_SSRF_BLOCKED / E_DNS / E_NETWORK / E_TOO_LARGE)。 pub code: &'static str, @@ -94,9 +97,7 @@ pub async fn read_body_limited(resp: reqwest::Response, max: u64) -> Result item, - }; + let chunk = stream.next().await; let Some(item) = chunk else { break }; let bytes = item.map_err(|e| HopError { code: "E_NETWORK", @@ -113,48 +114,6 @@ pub async fn read_body_limited(resp: reqwest::Response, max: u64) -> Result Result<(), HopError> { - if allow_private { - return Ok(()); - } - // IP 字面量 - if let Ok(ip) = host.parse::() { - if is_private_ip(ip) { - return Err(HopError { - code: "E_SSRF_BLOCKED", - message: format!( - "目标 {host}:{port} 是内网/保留地址,已被 SSRF 守卫拒绝(如需访问本地服务,在设置中开启 allow_private_network)" - ), - }); - } - return Ok(()); - } - // 域名:解析 DNS 后逐个 IP 检查 - let addrs: Vec = tokio::net::lookup_host((host, port)) - .await - .map_err(|e| HopError { - code: "E_DNS", - message: format!("域名解析失败 {host}:{e}"), - })? - .collect(); - if addrs.is_empty() { - return Err(HopError { - code: "E_DNS", - message: format!("域名无解析结果:{host}"), - }); - } - for a in &addrs { - if is_private_ip(a.ip()) { - return Err(HopError { - code: "E_SSRF_BLOCKED", - message: format!("{host} 解析到内网地址 {},已被 SSRF 守卫拒绝", a.ip()), - }); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -241,60 +200,4 @@ mod tests { assert!(!is_private_ip("::ffff:8.8.8.8".parse().unwrap())); assert!(!is_private_ip("::ffff:1.1.1.1".parse().unwrap())); } - - /// guard_host:IP 字面量主机按 is_private_ip 分类;allow_private 直接短路放行。 - #[tokio::test] - async fn guard_host_blocks_private_literal_and_honors_allow() { - // 私网字面量且未开 allow_private → SSRF 拦截 - let err = guard_host("10.0.0.1", 80, false).await.unwrap_err(); - assert_eq!(err.code, "E_SSRF_BLOCKED"); - // allow_private 在任何拨号前短路 - assert!(guard_host("10.0.0.1", 80, true).await.is_ok()); - assert!(guard_host("127.0.0.1", 80, true).await.is_ok()); - // 公网字面量不拨号直接放行 - assert!(guard_host("8.8.8.8", 443, false).await.is_ok()); - // "localhost"(hosts 文件)解析到回环地址 → 拦截 - let err = guard_host("localhost", 80, false).await.unwrap_err(); - assert_eq!(err.code, "E_SSRF_BLOCKED"); - } - - /// read_body_limited:限额内完整流式读取;超限 → E_TOO_LARGE。 - #[tokio::test] - async fn read_body_limited_enforces_cap() { - // 一次性本地 HTTP 服务:每次调用在新端口上回 `body` - async fn serve_once(body: String) -> std::net::SocketAddr { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - std::thread::spawn(move || { - use std::io::{Read, Write}; - let (mut s, _) = listener.accept().unwrap(); - let mut buf = [0u8; 8192]; - let _ = s.read(&mut buf); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = s.write_all(resp.as_bytes()); - }); - addr - } - - let addr = serve_once("x".repeat(300)).await; - let client = reqwest::Client::new(); - let resp = client.get(format!("http://{addr}/b")).send().await.unwrap(); - let ok = match read_body_limited(resp, 1000).await { - Ok(b) => b, - Err(e) => panic!( - "within-limit read must succeed, got {}: {}", - e.code, e.message - ), - }; - assert_eq!(ok.len(), 300); - - let addr = serve_once("y".repeat(300)).await; - let resp = client.get(format!("http://{addr}/b")).send().await.unwrap(); - let err = read_body_limited(resp, 50).await.unwrap_err(); - assert_eq!(err.code, "E_TOO_LARGE"); - } } diff --git a/src-tauri/src/tools/net/request.rs b/src-tauri/src/tools/net/request.rs new file mode 100644 index 00000000..34c3bb37 --- /dev/null +++ b/src-tauri/src/tools/net/request.rs @@ -0,0 +1,356 @@ +//! One bounded, cancellable HTTP execution path for network tools. +//! LLM and MCP clients deliberately keep their separate endpoint policies. + +use super::{HopError, MAX_BODY_BYTES, MAX_REDIRECTS, is_private_ip, read_body_limited}; +use crate::core::config::{ConfigState, ProxyMode}; +use hyper_util::client::proxy::matcher::{Intercept, Matcher}; +use reqwest::header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap}; +use reqwest::{Method, StatusCode, Url}; +use serde_json::Value; +use std::net::SocketAddr; +use std::time::Duration; +use tokio_util::sync::CancellationToken; + +pub(in crate::tools) struct HttpRequest { + pub url: Url, + pub method: Method, + pub headers: HeaderMap, + pub body: Option, +} + +pub(in crate::tools) struct HttpResponse { + pub url: Url, + pub status: StatusCode, + pub headers: HeaderMap, + pub body: Vec, +} + +fn error(code: &'static str, message: impl Into) -> HopError { + HopError { + code, + message: message.into(), + } +} + +fn validate_url(url: &Url) -> Result<(), HopError> { + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(error("E_URL", "仅支持带主机名的 HTTP(S) URL")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(error("E_URL", "URL 不允许内嵌凭据,请使用请求头")); + } + Ok(()) +} + +async fn proxy_matcher(cfg: &ConfigState) -> Result { + let Some(proxy) = &cfg.proxy else { + // Native system discovery must not block cancellation/deadline polling. + // The OS lookup itself may finish after the request has been cancelled. + return tokio::task::spawn_blocking(Matcher::from_system) + .await + .map_err(|_| error("E_PROXY", "系统代理探测失败")); + }; + let selected = match proxy.mode { + ProxyMode::None => None, + ProxyMode::Manual if proxy.url.trim().is_empty() => None, + ProxyMode::Manual => Some(proxy.url.trim().to_owned()), + ProxyMode::System => system_proxy_url().await?, + }; + let Some(selected) = selected else { + return Ok(Matcher::builder().build()); + }; + let selected = normalize_proxy_url(&selected)?; + let matcher = Matcher::builder() + .all(selected.as_str()) + .no("localhost,127.0.0.1,::1") + .build(); + // Matcher silently discards unsupported proxies. Never interpret that as a + // user-approved direct route, even if reqwest accepted the URL constructor. + if matcher + .intercept(&"https://proxy-route-check.invalid/".parse().unwrap()) + .is_none() + { + return Err(error("E_PROXY", "代理配置不受支持,未改用直连")); + } + Ok(matcher) +} + +fn normalize_proxy_url(raw: &str) -> Result { + let invalid = || error("E_PROXY", "代理配置无效,未改用直连"); + let input = if raw.contains("://") { + raw.to_owned() + } else { + format!("http://{raw}") + }; + let mut url = Url::parse(&input).map_err(|_| invalid())?; + if !matches!( + url.scheme(), + "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" + ) { + return Err(invalid()); + } + let host = url.host_str().ok_or_else(invalid)?; + // Non-special SOCKS URLs need the same IDNA normalization as HTTP URLs. + let normalized = Url::parse(&format!("http://{host}")).map_err(|_| invalid())?; + url.set_host(normalized.host_str()).map_err(|_| invalid())?; + if url.scheme().starts_with("socks") && url.port().is_none() { + url.set_port(Some(1080)).map_err(|_| invalid())?; + } + reqwest::Proxy::all(url.as_str()).map_err(|_| invalid())?; + Ok(url) +} + +async fn system_proxy_url() -> Result, HopError> { + #[cfg(target_os = "macos")] + { + // The subprocess is owned by this future and killed on deadline/cancel. + let output = tokio::process::Command::new("/usr/sbin/scutil") + .arg("--proxy") + .kill_on_drop(true) + .output() + .await + .map_err(|_| error("E_PROXY", "系统代理探测失败"))?; + if !output.status.success() { + return Err(error("E_PROXY", "系统代理探测失败")); + } + Ok(crate::provider::proxy::parse_scutil( + &String::from_utf8_lossy(&output.stdout), + )) + } + #[cfg(not(target_os = "macos"))] + { + tokio::task::spawn_blocking(crate::provider::proxy::system_proxy_url) + .await + .map_err(|_| error("E_PROXY", "系统代理探测失败")) + } +} + +fn remote_dns(proxy: &Intercept) -> bool { + !matches!(proxy.uri().scheme_str(), Some("socks4" | "socks5")) +} + +fn check_proxy_target( + url: &Url, + proxy: Option<&Intercept>, + allow_private: bool, +) -> Result<(), HopError> { + let literal_target = url + .host_str() + .unwrap_or_default() + .trim_matches(['[', ']']) + .parse::() + .is_ok(); + if !allow_private && !literal_target && proxy.is_some_and(remote_dns) { + return Err(error( + "E_SSRF_PROXY_UNSUPPORTED", + "当前代理在远端解析目标域名,无法固定已校验 IP;请求未发送且未改用直连。请使用本地 DNS SOCKS 代理,或明确开启允许内网访问。", + )); + } + Ok(()) +} + +fn checked_addresses( + addrs: Vec, + allow_private: bool, +) -> Result, HopError> { + if addrs.is_empty() { + return Err(error("E_DNS", "目标域名无解析结果")); + } + if !allow_private && addrs.iter().any(|addr| is_private_ip(addr.ip())) { + return Err(error( + "E_SSRF_BLOCKED", + "目标解析到内网或保留地址,已被 SSRF 守卫拒绝", + )); + } + Ok(addrs) +} + +async fn resolve(url: &Url, allow_private: bool) -> Result, HopError> { + let host = url.host_str().unwrap_or_default().trim_matches(['[', ']']); + let port = url.port_or_known_default().unwrap_or(80); + let addrs = if let Ok(ip) = host.parse() { + vec![SocketAddr::new(ip, port)] + } else { + tokio::net::lookup_host((host, port)) + .await + .map_err(|_| error("E_DNS", "目标域名解析失败"))? + .collect() + }; + checked_addresses(addrs, allow_private) +} + +fn pinned_client( + url: &Url, + addresses: &[SocketAddr], + proxy: Option, + allow_private: bool, +) -> Result { + check_proxy_target(url, proxy.as_ref(), allow_private)?; + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .no_proxy(); + if let Some(proxy) = proxy { + // CONNECT and remote-DNS SOCKS proxies resolve the destination themselves. + // resolve_to_addrs cannot prove their target. Refuse rather than bypassing + // the user's route or claiming that a proxy address proves SSRF safety. + let mut configured = reqwest::Proxy::all(proxy.uri().to_string()) + .map_err(|_| error("E_PROXY", "代理配置无效"))?; + if let Some(auth) = proxy.basic_auth() { + configured = configured.custom_http_auth(auth.clone()); + } + if let Some((user, password)) = proxy.raw_auth() { + configured = configured.basic_auth(user, password); + } + builder = builder.proxy(configured); + } + if !addresses.is_empty() { + builder = builder.resolve_to_addrs(url.host_str().unwrap_or_default(), addresses); + } + builder + .build() + .map_err(|_| error("E_NETWORK", "无法建立受保护的 HTTP 客户端")) +} + +fn redirect(request: &mut HttpRequest, status: StatusCode, next: Url) -> Result<(), HopError> { + validate_url(&next)?; + if request.url.scheme() == "https" && next.scheme() == "http" { + return Err(error( + "E_REDIRECT_UNSAFE", + "拒绝 HTTPS 降级到 HTTP 的重定向", + )); + } + let becomes_get = (status == StatusCode::SEE_OTHER && request.method != Method::HEAD) + || (matches!(status.as_u16(), 301 | 302) && request.method == Method::POST); + if becomes_get { + request.method = Method::GET; + } + if becomes_get || status == StatusCode::SEE_OTHER { + request.body = None; + for name in [CONTENT_TYPE, CONTENT_LENGTH, CONTENT_ENCODING] { + request.headers.remove(name); + } + } + if request.url.origin() != next.origin() { + if request.body.is_some() { + return Err(error( + "E_REDIRECT_UNSAFE", + "跨源重定向需要重放请求体,已拒绝发送", + )); + } + // Custom header names can carry credentials too. Strip all caller headers. + request.headers.clear(); + } + request.url = next; + Ok(()) +} + +/// The deadline includes proxy discovery, DNS, throttling, every hop and decoded bytes. +pub(in crate::tools) async fn execute( + cfg: &ConfigState, + request: HttpRequest, + cancel: &CancellationToken, + timeout: Duration, +) -> Result { + execute_with_throttle( + cfg, + request, + cancel, + timeout, + super::super::web_fetch::throttle_pub(), + ) + .await +} + +async fn execute_with_throttle( + cfg: &ConfigState, + mut request: HttpRequest, + cancel: &CancellationToken, + timeout: Duration, + throttle: &super::HostThrottle, +) -> Result { + let operation = async { + validate_url(&request.url)?; + let proxies = proxy_matcher(cfg).await?; + for hop in 0..=MAX_REDIRECTS { + let uri = request + .url + .as_str() + .parse() + .map_err(|_| error("E_URL", "URL 无效"))?; + let proxy = proxies.intercept(&uri); + check_proxy_target( + &request.url, + proxy.as_ref(), + cfg.network.allow_private_network, + )?; + let addresses = + if cfg.network.allow_private_network && proxy.as_ref().is_some_and(remote_dns) { + // Preserve split-DNS/remote-only names: the configured proxy owns + // DNS here. Do not first resolve through the local network. + Vec::new() + } else { + resolve(&request.url, cfg.network.allow_private_network).await? + }; + let client = pinned_client( + &request.url, + &addresses, + proxy, + cfg.network.allow_private_network, + )?; + throttle + .wait(request.url.host_str().unwrap_or_default()) + .await; + let mut outgoing = client + .request(request.method.clone(), request.url.clone()) + .header(reqwest::header::USER_AGENT, crate::util::USER_AGENT) + .headers(request.headers.clone()); + if let Some(body) = &request.body { + outgoing = outgoing.json(body); + } + let response = outgoing.send().await.map_err(|err| { + error("E_NETWORK", format!("HTTP 请求失败:{}", err.without_url())) + })?; + let status = response.status(); + if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) { + if hop == MAX_REDIRECTS { + return Err(error("E_REDIRECTS", "重定向过多")); + } + let next = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| request.url.join(v).ok()) + .ok_or_else(|| error("E_URL", "重定向地址无效"))?; + redirect(&mut request, status, next)?; + continue; + } + if response + .content_length() + .is_some_and(|len| len > MAX_BODY_BYTES) + { + return Err(error("E_TOO_LARGE", "响应体超过 50MiB 上限")); + } + let headers = response.headers().clone(); + let body = read_body_limited(response, MAX_BODY_BYTES).await?; + return Ok(HttpResponse { + url: request.url, + status, + headers, + body, + }); + } + Err(error("E_REDIRECTS", "重定向过多")) + }; + tokio::select! { + biased; + _ = cancel.cancelled() => Err(error("E_CANCELLED", "网络请求已取消")), + result = tokio::time::timeout(timeout, operation) => result + .unwrap_or_else(|_| Err(error("E_NETWORK", "网络请求超时(包括解析、节流、重定向和正文读取)"))), + } +} + +#[cfg(test)] +#[path = "request_tests.rs"] +mod tests; diff --git a/src-tauri/src/tools/net/request_tests.rs b/src-tauri/src/tools/net/request_tests.rs new file mode 100644 index 00000000..f78d4962 --- /dev/null +++ b/src-tauri/src/tools/net/request_tests.rs @@ -0,0 +1,706 @@ +use super::*; +use crate::core::config::ProxyConfig; +use serde_json::json; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +fn request(url: &str, method: Method, body: Option) -> HttpRequest { + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer fixture-only".parse().unwrap()); + headers.insert("x-custom-secret", "fixture-only".parse().unwrap()); + headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + if body.is_some() { + headers.insert(CONTENT_LENGTH, "7".parse().unwrap()); + } + headers.insert(CONTENT_ENCODING, "identity".parse().unwrap()); + HttpRequest { + url: Url::parse(url).unwrap(), + method, + headers, + body, + } +} + +fn local_config() -> ConfigState { + ConfigState { + proxy: Some(ProxyConfig { + mode: ProxyMode::None, + url: String::new(), + }), + network: crate::core::config::NetworkConfig { + allow_private_network: true, + }, + ..ConfigState::default() + } +} + +#[test] +fn redirects_apply_method_rules_and_remove_discarded_body_headers() { + for (status, method, expected) in [ + (StatusCode::SEE_OTHER, Method::HEAD, Method::HEAD), + (StatusCode::SEE_OTHER, Method::PUT, Method::GET), + (StatusCode::MOVED_PERMANENTLY, Method::POST, Method::GET), + (StatusCode::FOUND, Method::POST, Method::GET), + ] { + let mut req = request("https://fixture.invalid/a", method, Some(json!({"a": 1}))); + redirect( + &mut req, + status, + Url::parse("https://fixture.invalid/b").unwrap(), + ) + .unwrap(); + assert_eq!(req.method, expected); + assert!(req.body.is_none()); + for name in [CONTENT_TYPE, CONTENT_LENGTH, CONTENT_ENCODING] { + assert!(!req.headers.contains_key(name)); + } + assert!(req.headers.contains_key("authorization")); + assert_eq!(req.url.path(), "/b"); + } +} + +#[test] +fn preserving_redirects_retain_same_origin_requests_but_reject_cross_origin_replay() { + for status in [ + StatusCode::TEMPORARY_REDIRECT, + StatusCode::PERMANENT_REDIRECT, + ] { + let mut req = request( + "https://fixture.invalid/a", + Method::POST, + Some(json!({"a": 1})), + ); + let headers = req.headers.clone(); + redirect( + &mut req, + status, + Url::parse("https://fixture.invalid/b").unwrap(), + ) + .unwrap(); + assert_eq!(req.method, Method::POST); + assert_eq!(req.body, Some(json!({"a": 1}))); + assert_eq!(req.headers, headers); + for next in ["https://other.invalid/b", "https://fixture.invalid:444/b"] { + let err = redirect(&mut req, status, Url::parse(next).unwrap()).unwrap_err(); + assert_eq!(err.code, "E_REDIRECT_UNSAFE"); + assert_eq!(req.url.as_str(), "https://fixture.invalid/b"); + } + } + // 301/302 preserve non-POST methods and must not replay their body cross-origin. + for status in [StatusCode::MOVED_PERMANENTLY, StatusCode::FOUND] { + let mut req = request( + "https://fixture.invalid/a", + Method::PUT, + Some(json!({"a": 1})), + ); + assert_eq!( + redirect( + &mut req, + status, + Url::parse("https://other.invalid/").unwrap() + ) + .unwrap_err() + .code, + "E_REDIRECT_UNSAFE" + ); + } +} + +#[test] +fn cross_origin_get_strips_all_caller_headers_and_https_never_downgrades() { + let mut req = request( + "https://fixture.invalid/a", + Method::POST, + Some(json!({"a": 1})), + ); + redirect( + &mut req, + StatusCode::FOUND, + Url::parse("https://other.invalid/b").unwrap(), + ) + .unwrap(); + assert_eq!(req.method, Method::GET); + assert!(req.body.is_none()); + assert!(req.headers.is_empty()); + for status in [ + StatusCode::FOUND, + StatusCode::SEE_OTHER, + StatusCode::TEMPORARY_REDIRECT, + ] { + assert_eq!( + redirect( + &mut req, + status, + Url::parse("http://other.invalid/c").unwrap() + ) + .unwrap_err() + .code, + "E_REDIRECT_UNSAFE" + ); + assert_eq!(req.url.scheme(), "https"); + } +} + +#[tokio::test] +async fn checked_addresses_rejects_any_private_answer_including_mapped_ipv6() { + let public: SocketAddr = "8.8.8.8:443".parse().unwrap(); + let public_v6: SocketAddr = "[2606:4700::1111]:443".parse().unwrap(); + assert_eq!( + checked_addresses(vec![public, public_v6], false).unwrap(), + vec![public, public_v6] + ); + for private in [ + "127.0.0.1:443", + "10.0.0.1:443", + "[::1]:443", + "[fd12::1]:443", + "[::ffff:127.0.0.1]:443", + ] { + let private: SocketAddr = private.parse().unwrap(); + for answers in [vec![public, private], vec![private, public_v6]] { + assert_eq!( + checked_addresses(answers.clone(), false).unwrap_err().code, + "E_SSRF_BLOCKED" + ); + assert_eq!(checked_addresses(answers.clone(), true).unwrap(), answers); + } + } + assert_eq!(checked_addresses(vec![], true).unwrap_err().code, "E_DNS"); + for target in ["http://localhost/", "http://[::1]/", "http://10.0.0.1/"] { + let url = Url::parse(target).unwrap(); + assert_eq!( + resolve(&url, false).await.unwrap_err().code, + "E_SSRF_BLOCKED" + ); + assert!(!resolve(&url, true).await.unwrap().is_empty()); + } +} + +#[test] +fn proxy_routes_refuse_remote_dns_without_silently_bypassing_proxy() { + let url = Url::parse("https://fixture.invalid/").unwrap(); + let addresses = ["8.8.8.8:443".parse().unwrap()]; + for proxy_url in [ + "http://127.0.0.1:1080", + "https://127.0.0.1:1080", + "socks5h://127.0.0.1:1080", + "socks4a://127.0.0.1:1080", + ] { + let matcher = Matcher::builder().all(proxy_url).build(); + let proxy = matcher + .intercept(&url.as_str().parse().unwrap()) + .expect("configured route must exist"); + assert_eq!( + pinned_client(&url, &addresses, Some(proxy.clone()), false) + .err() + .unwrap() + .code, + "E_SSRF_PROXY_UNSUPPORTED" + ); + assert!(pinned_client(&url, &addresses, Some(proxy), true).is_ok()); + } + for proxy_url in ["socks4://127.0.0.1:1080", "socks5://127.0.0.1:1080"] { + let matcher = Matcher::builder().all(proxy_url).build(); + assert!( + pinned_client( + &url, + &addresses, + matcher.intercept(&url.as_str().parse().unwrap()), + false + ) + .is_ok() + ); + } + let literal = Url::parse("https://8.8.8.8/").unwrap(); + let matcher = Matcher::builder().all("http://127.0.0.1:1080").build(); + assert!( + pinned_client( + &literal, + &addresses, + matcher.intercept(&literal.as_str().parse().unwrap()), + false + ) + .is_ok() + ); +} + +#[tokio::test] +async fn explicit_proxy_and_no_proxy_routes_do_not_depend_on_process_environment() { + let matcher = Matcher::builder() + .all("http://127.0.0.1:1080") + .no("fixture.invalid,127.0.0.1,::1") + .build(); + for url in [ + "https://fixture.invalid/", + "https://sub.fixture.invalid/", + "http://127.0.0.1/", + "http://[::1]/", + ] { + assert!(matcher.intercept(&url.parse().unwrap()).is_none(), "{url}"); + } + assert!( + matcher + .intercept(&"https://notfixture.invalid/".parse().unwrap()) + .is_some() + ); + let mut cfg = local_config(); + assert!( + proxy_matcher(&cfg) + .await + .unwrap() + .intercept(&"https://fixture.invalid/".parse().unwrap()) + .is_none() + ); + cfg.proxy = Some(ProxyConfig { + mode: ProxyMode::Manual, + url: "http://127.0.0.1:1080".into(), + }); + assert!( + proxy_matcher(&cfg) + .await + .unwrap() + .intercept(&"https://fixture.invalid/".parse().unwrap()) + .is_some() + ); +} + +#[tokio::test] +async fn invalid_proxy_schemes_fail_instead_of_becoming_direct_routes() { + for input in ["ftp://127.0.0.1:1080", "file:///tmp/proxy", "not a proxy"] { + let mut cfg = local_config(); + cfg.proxy = Some(ProxyConfig { + mode: ProxyMode::Manual, + url: input.into(), + }); + let Err(error) = proxy_matcher(&cfg).await else { + panic!("invalid proxy accepted: {input}"); + }; + assert_eq!(error.code, "E_PROXY"); + } + for input in [ + "127.0.0.1:1080", + "http://bücher.invalid:1080", + "socks5://bücher.invalid", + ] { + let mut cfg = local_config(); + cfg.proxy = Some(ProxyConfig { + mode: ProxyMode::Manual, + url: input.into(), + }); + let matcher = proxy_matcher(&cfg).await.unwrap(); + let proxy = matcher + .intercept(&"https://fixture.invalid/".parse().unwrap()) + .expect(input); + assert!( + !proxy.uri().host().unwrap().contains('%'), + "proxy host must be normalized" + ); + if input.starts_with("socks5:") { + assert_eq!(proxy.uri().port_u16(), Some(1080)); + } + } +} + +#[tokio::test] +async fn execute_keeps_remote_dns_and_proxy_authentication_when_private_network_is_allowed() { + use base64::Engine; + let mut fixture = Fixture::new( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec(), + false, + ) + .await; + let mut cfg = local_config(); + cfg.proxy = Some(ProxyConfig { + mode: ProxyMode::Manual, + url: format!( + "http://fixture-user:fixture-pass@{}", + fixture.listener.local_addr().unwrap() + ), + }); + let response = execute( + &cfg, + request("http://remote-only.invalid/resource", Method::GET, None), + &CancellationToken::new(), + Duration::from_secs(3), + ) + .await + .unwrap(); + assert_eq!(response.body, b"ok"); + let headers = tokio::time::timeout(Duration::from_secs(2), fixture.sent.take().unwrap()) + .await + .unwrap() + .unwrap(); + let headers = String::from_utf8(headers).unwrap(); + assert!(headers.starts_with("GET http://remote-only.invalid/resource HTTP/1.1\r\n")); + let auth = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("proxy-authorization") + .then_some(value.trim()) + }) + .expect("proxy credentials must reach the configured proxy"); + let expected_auth = + base64::engine::general_purpose::STANDARD.encode("fixture-user:fixture-pass"); + assert_eq!(auth, format!("Basic {expected_auth}")); + fixture.finish().await; + + cfg.network.allow_private_network = false; + let err = execute( + &cfg, + request("http://remote-only.invalid/resource", Method::GET, None), + &CancellationToken::new(), + Duration::from_secs(3), + ) + .await + .err() + .unwrap(); + assert_eq!(err.code, "E_SSRF_PROXY_UNSUPPORTED"); +} + +/// The listener stays owned by the test even after the response task finishes. +struct Fixture { + listener: Arc, + stop: CancellationToken, + task: tokio::task::JoinHandle<()>, + sent: Option>>, +} + +impl Fixture { + async fn new(response: Vec, hold_open: bool) -> Self { + let listener = Arc::new(TcpListener::bind("127.0.0.1:0").await.unwrap()); + let server_listener = listener.clone(); + let stop = CancellationToken::new(); + let server_stop = stop.clone(); + let (sent_tx, sent) = oneshot::channel(); + let task = tokio::spawn(async move { + let (mut socket, _) = + tokio::time::timeout(Duration::from_secs(5), server_listener.accept()) + .await + .unwrap() + .unwrap(); + let header = tokio::time::timeout(Duration::from_secs(2), async { + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + assert!(header.len() < 65536, "request headers too large"); + header.push(socket.read_u8().await.unwrap()); + } + socket.write_all(&response).await.unwrap(); + header + }) + .await + .unwrap(); + let _ = sent_tx.send(header); + if hold_open { + let _ = tokio::time::timeout(Duration::from_secs(5), server_stop.cancelled()).await; + } + tokio::time::timeout(Duration::from_secs(2), socket.shutdown()) + .await + .unwrap() + .unwrap(); + }); + Self { + listener, + stop, + task, + sent: Some(sent), + } + } + + fn url(&self) -> String { + format!("http://{}/fixture", self.listener.local_addr().unwrap()) + } + + async fn finish(mut self) { + self.stop.cancel(); + tokio::time::timeout(Duration::from_secs(3), &mut self.task) + .await + .unwrap() + .unwrap(); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.stop.cancel(); + self.task.abort(); + } +} + +#[tokio::test] +async fn total_deadline_includes_throttle_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let throttle = super::super::HostThrottle::new(); + throttle.wait("127.0.0.1").await; + let started = std::time::Instant::now(); + let result = execute_with_throttle( + &local_config(), + request(&url, Method::GET, None), + &CancellationToken::new(), + Duration::from_millis(50), + &throttle, + ) + .await; + let err = result.err().expect("throttled request should time out"); + assert_eq!(err.code, "E_NETWORK"); + assert!(err.message.contains("超时")); + assert!(started.elapsed() < Duration::from_millis(800)); + assert!( + tokio::time::timeout(Duration::from_millis(80), listener.accept()) + .await + .is_err(), + "request must not connect after timeout" + ); +} + +#[tokio::test] +async fn total_deadline_includes_body_and_precancellation_never_connects() { + let mut fixture = Fixture::new( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nx".to_vec(), + true, + ) + .await; + let url = fixture.url(); + let started = std::time::Instant::now(); + let operation = tokio::spawn(async move { + let mut throttle = super::super::HostThrottle::new(); + throttle.min_interval = Duration::ZERO; + execute_with_throttle( + &local_config(), + request(&url, Method::GET, None), + &CancellationToken::new(), + Duration::from_secs(3), + &throttle, + ) + .await + }); + tokio::time::timeout(Duration::from_millis(2500), fixture.sent.take().unwrap()) + .await + .expect("request must reach body reading before deadline") + .unwrap(); + let result = tokio::time::timeout(Duration::from_secs(4), operation) + .await + .unwrap() + .unwrap(); + let err = result.err().expect("unfinished body should time out"); + assert_eq!(err.code, "E_NETWORK"); + assert!(err.message.contains("超时")); + assert!(started.elapsed() < Duration::from_secs(4)); + fixture.finish().await; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let result = execute( + &local_config(), + request( + &format!("http://{}/", listener.local_addr().unwrap()), + Method::GET, + None, + ), + &cancel, + Duration::from_secs(1), + ) + .await; + assert_eq!(result.err().unwrap().code, "E_CANCELLED"); + assert!( + tokio::time::timeout(Duration::from_millis(80), listener.accept()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn body_limit_applies_to_chunked_and_decompressed_bytes() { + for limit in [12, 16] { + let fixture = Fixture::new(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n8\r\n12345678\r\n8\r\nabcdefgh\r\n0\r\n\r\n".to_vec(), false).await; + let response = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(3)) + .build() + .unwrap() + .get(fixture.url()) + .send() + .await + .unwrap(); + let result = + tokio::time::timeout(Duration::from_secs(2), read_body_limited(response, limit)) + .await + .unwrap(); + if limit == 16 { + assert_eq!(result.unwrap(), b"12345678abcdefgh"); + } else { + assert_eq!(result.unwrap_err().code, "E_TOO_LARGE"); + } + fixture.finish().await; + } + + use std::io::Write; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&vec![b'x'; 4096]).unwrap(); + let compressed = encoder.finish().unwrap(); + assert!(compressed.len() < 128); + let mut wire = format!("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", compressed.len()).into_bytes(); + wire.extend(compressed); + let fixture = Fixture::new(wire, false).await; + let response = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(3)) + .gzip(true) + .build() + .unwrap() + .get(fixture.url()) + .send() + .await + .unwrap(); + let err = tokio::time::timeout(Duration::from_secs(2), read_body_limited(response, 128)) + .await + .unwrap() + .unwrap_err(); + assert_eq!(err.code, "E_TOO_LARGE"); + fixture.finish().await; +} + +#[tokio::test] +async fn cancellation_interrupts_an_incomplete_body_after_headers_arrive() { + let mut fixture = Fixture::new( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nx".to_vec(), + true, + ) + .await; + let cancel = CancellationToken::new(); + let run_cancel = cancel.clone(); + let url = fixture.url(); + let operation = tokio::spawn(async move { + let mut throttle = super::super::HostThrottle::new(); + throttle.min_interval = Duration::ZERO; + execute_with_throttle( + &local_config(), + request(&url, Method::GET, None), + &run_cancel, + Duration::from_secs(10), + &throttle, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), fixture.sent.take().unwrap()) + .await + .expect("response must start before cancellation") + .unwrap(); + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), operation) + .await + .expect("cancellation must not wait for the request deadline") + .unwrap(); + assert_eq!(result.err().unwrap().code, "E_CANCELLED"); + fixture.finish().await; +} + +#[tokio::test] +async fn local_dns_socks_sends_the_validated_ip_instead_of_the_hostname() { + // This fixture is the entire proxy: it never dials the requested public address. + let listener = Arc::new(TcpListener::bind("127.0.0.1:0").await.unwrap()); + let server_listener = listener.clone(); + let server = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(3), async { + let (mut socket, _) = server_listener.accept().await.unwrap(); + assert_eq!(socket.read_u8().await.unwrap(), 5); + let count = socket.read_u8().await.unwrap(); + let mut methods = vec![0; count as usize]; + socket.read_exact(&mut methods).await.unwrap(); + assert!(methods.contains(&0)); + socket.write_all(&[5, 0]).await.unwrap(); + let mut connect = [0; 4]; + socket.read_exact(&mut connect).await.unwrap(); + assert_eq!( + connect, + [5, 1, 0, 1], + "SOCKS must receive an IPv4 target, not a DNS name" + ); + let mut ip = [0; 4]; + socket.read_exact(&mut ip).await.unwrap(); + let port = socket.read_u16().await.unwrap(); + socket + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) + .await + .unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + assert!(headers.len() < 65536); + headers.push(socket.read_u8().await.unwrap()); + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + socket.shutdown().await.unwrap(); + (ip, port) + }) + .await + .unwrap() + }); + let url = Url::parse("http://pin-target.invalid:18080/fixture").unwrap(); + let matcher = Matcher::builder() + .all(format!("socks5://{}", listener.local_addr().unwrap())) + .build(); + let client = pinned_client( + &url, + &["8.8.8.8:18080".parse().unwrap()], + matcher.intercept(&url.as_str().parse().unwrap()), + false, + ) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(3), client.get(url).send()) + .await + .unwrap() + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), response.bytes()) + .await + .unwrap() + .unwrap() + .as_ref(), + b"ok" + ); + let (ip, port) = server.await.unwrap(); + assert_eq!(ip, [8, 8, 8, 8]); + assert_eq!(port, 18080); + drop(listener); +} + +#[tokio::test] +async fn no_proxy_route_connects_to_the_pinned_address_without_using_configured_proxy() { + let mut fixture = Fixture::new( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec(), + false, + ) + .await; + let address = fixture.listener.local_addr().unwrap(); + let url = Url::parse(&format!("http://fixture.invalid:{}/", address.port())).unwrap(); + let matcher = Matcher::builder() + .all("http://127.0.0.1:1") + .no("fixture.invalid") + .build(); + let client = pinned_client( + &url, + &[address], + matcher.intercept(&url.as_str().parse().unwrap()), + true, + ) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(3), client.get(url).send()) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let headers = tokio::time::timeout(Duration::from_secs(2), fixture.sent.take().unwrap()) + .await + .unwrap() + .unwrap(); + let headers = String::from_utf8(headers).unwrap().to_ascii_lowercase(); + assert!(headers.contains(&format!("host: fixture.invalid:{}", address.port()))); + fixture.finish().await; +} diff --git a/src-tauri/src/tools/pathutil.rs b/src-tauri/src/tools/pathutil.rs index 5c453636..bab1c965 100644 --- a/src-tauri/src/tools/pathutil.rs +++ b/src-tauri/src/tools/pathutil.rs @@ -149,15 +149,18 @@ pub fn resolve_read(roots: &WriteRoots, raw: &str) -> Result, + /// End-to-end budget, including redirects and body streaming. + #[serde(default)] + timeout_seconds: Option, } /// web_fetch 工具:抓取网页并抽取主可读内容(标题 + 正文)。 -/// 入参为 url(必填)+ 可选 maxChars;Network 分级,执行前需用户确认。 +/// 入参为 url(必填)+ 可选 maxChars / timeoutSeconds;Network 分级。 /// 安全语义:手动重定向逐跳 SSRF 校验 + 按主机 1s 节流;HTML 走 Readability 抽取, /// 失败退化为粗剥标签;API 调用场景应改用 http_request。 pub struct WebFetchTool; @@ -41,73 +42,6 @@ pub fn throttle_pub() -> &'static HostThrottle { /// 返回正文的默认字符上限。 pub const DEFAULT_MAX_CHARS: usize = 60_000; -/// 手动重定向的 GET:逐跳 SSRF 校验,跨源重定向剥离 Authorization/Cookie。 -/// `client` 由调用方按 config 构建代理感知的 redirect-none client(build_client_with)。 -pub async fn guarded_get( - client: reqwest::Client, - url: &str, - allow_private: bool, -) -> Result { - let mut current = url.to_string(); - for _hop in 0..=MAX_REDIRECTS { - let parsed = reqwest::Url::parse(¤t).map_err(|e| HopError { - code: "E_URL", - message: format!("URL 无效:{e}"), - })?; - let host = parsed.host_str().unwrap_or_default().to_string(); - let port = parsed.port_or_known_default().unwrap_or(80); - let scheme = parsed.scheme().to_string(); - if scheme != "http" && scheme != "https" { - return Err(HopError { - code: "E_URL", - message: format!("不支持的协议:{scheme}"), - }); - } - guard_host(&host, port, allow_private).await?; - throttle().wait(&host).await; - - let resp = client - .get(¤t) - .header("User-Agent", crate::util::USER_AGENT) - .send() - .await - .map_err(|e| HopError { - code: "E_NETWORK", - message: format!("请求失败:{e}"), - })?; - - if resp.status().is_redirection() { - let loc = resp - .headers() - .get("location") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - match loc { - Some(loc) => { - let next = parsed.join(&loc).map_err(|e| HopError { - code: "E_URL", - message: format!("重定向地址无效:{e}"), - })?; - // 跨源重定向不再回显凭据(我们本就从不发送);直接继续 - current = next.to_string(); - continue; - } - None => { - return Err(HopError { - code: "E_URL", - message: "重定向缺少 Location 头".into(), - }); - } - } - } - return Ok(resp); - } - Err(HopError { - code: "E_REDIRECTS", - message: format!("重定向超过 {MAX_REDIRECTS} 次"), - }) -} - /// HTML → 纯文本正文(dom_smoothie Readability)。 pub fn extract_article(html: &str, url: &str) -> Result<(String, String), String> { let mut readability = dom_smoothie::Readability::new(html.to_string(), Some(url), None) @@ -150,7 +84,8 @@ impl Tool for WebFetchTool { "required": ["url"], "properties": { "url": {"type": "string"}, - "maxChars": {"type": "integer", "description": "默认 60000"} + "maxChars": {"type": "integer", "description": "默认 60000"}, + "timeoutSeconds": {"type": "integer", "description": "总预算秒数,默认 60,范围 1–120"} } }"# } @@ -167,19 +102,26 @@ impl Tool for WebFetchTool { .max_chars .unwrap_or(DEFAULT_MAX_CHARS) .clamp(1_000, 500_000); - // 读锁快照 config → 构建代理感知的 redirect-none client(逐跳 SSRF 校验在本函数内做) let cfg = ctx.core.cfg.read().unwrap().clone(); - let allow_private = cfg.network.allow_private_network; - let client = - crate::provider::proxy::build_client_with(&cfg, reqwest::redirect::Policy::none()); - - let resp = match guarded_get(client, &args.url, allow_private).await { - Ok(r) => r, - Err(e) => return ToolOutcome::err(e.code, e.message), + let url = match reqwest::Url::parse(&args.url) { + Ok(url) => url, + Err(_) => return ToolOutcome::err("E_URL", "URL 无效"), + }; + let request = super::net::request::HttpRequest { + url, + method: reqwest::Method::GET, + headers: reqwest::header::HeaderMap::new(), + body: None, }; - let status = resp.status(); + let timeout = + std::time::Duration::from_secs(args.timeout_seconds.unwrap_or(60).clamp(1, 120)); + let resp = match super::net::request::execute(&cfg, request, &ctx.cancel, timeout).await { + Ok(response) => response, + Err(error) => return ToolOutcome::err(error.code, error.message), + }; + let status = resp.status; let content_type = resp - .headers() + .headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or_default() @@ -199,14 +141,10 @@ impl Tool for WebFetchTool { ), ); } - let body = match read_body_limited(resp, MAX_BODY_BYTES).await { - Ok(b) => b, - Err(e) => return ToolOutcome::err(e.code, e.message), - }; - let body_text = String::from_utf8_lossy(&body).into_owned(); + let body_text = String::from_utf8_lossy(&resp.body).into_owned(); let (title, text) = if content_type.contains("html") || content_type.contains("xhtml") { - extract_article(&body_text, &args.url).unwrap_or_else(|e| { + extract_article(&body_text, resp.url.as_str()).unwrap_or_else(|e| { let fallback = strip_tags(&body_text); ( String::new(), @@ -221,6 +159,7 @@ impl Tool for WebFetchTool { let truncated = text.chars().count() > max_chars; ToolOutcome::ok(json!({ "url": args.url, + "final_url": resp.url.as_str(), "title": if title.is_empty() { Value::Null } else { json!(title) }, "content": clipped, "chars": clipped.chars().count(), @@ -252,17 +191,22 @@ mod tests { #[tokio::test] async fn ssrf_blocks_localhost() { - let client = reqwest::Client::new(); - let err = guarded_get(client, "http://127.0.0.1:1/x", false) - .await - .unwrap_err(); + let cfg = crate::core::config::ConfigState::default(); + let request = super::super::net::request::HttpRequest { + url: "http://127.0.0.1:1/x".parse().unwrap(), + method: reqwest::Method::GET, + headers: reqwest::header::HeaderMap::new(), + body: None, + }; + let err = super::super::net::request::execute( + &cfg, + request, + &tokio_util::sync::CancellationToken::new(), + std::time::Duration::from_secs(1), + ) + .await + .err() + .expect("private URL must fail"); assert_eq!(err.code, "E_SSRF_BLOCKED"); - // 放行开关 - assert!( - !guarded_get(reqwest::Client::new(), "http://127.0.0.1:1/x", true) - .await - .is_err() - || true - ); } } From 297927be82d6ccfbf240ed1880921122d418ecc8 Mon Sep 17 00:00:00 2001 From: Yangshifu1024 Date: Sat, 17 Oct 2026 00:37:17 +0800 Subject: [PATCH 2/9] feat(mcp): require source-bound launch approval --- docs/mcp-and-skills.md | 15 +- docs/settings-guide.md | 4 +- docs/testing.md | 2 + src-tauri/src/host/commands/mcp.rs | 110 +++++-- src-tauri/src/lib.rs | 2 + src-tauri/src/mcp/manager.rs | 297 ++++++++++++++--- src-tauri/src/mcp/mod.rs | 4 +- src-tauri/src/mcp/service.rs | 131 ++++++++ src-tauri/src/mcp/trust.rs | 267 ++++++++++++++++ src-tauri/src/mcp/trust_integration_tests.rs | 317 +++++++++++++++++++ src-tauri/src/mcp/trust_tests.rs | 260 +++++++++++++++ src-tauri/src/tools/http_request.rs | 14 +- ui/src/__tests__/settings.mcp.test.tsx | 103 +++++- ui/src/features/panels/McpLaunchControl.tsx | 135 ++++++++ ui/src/features/panels/McpStatusTable.tsx | 11 +- ui/src/features/panels/SettingsPage.tsx | 26 +- ui/src/features/panels/settingsRegistry.ts | 5 + ui/src/i18n/en-US.ts | 7 + ui/src/i18n/zh-CN.ts | 7 + ui/src/ipc/client.ts | 11 +- ui/src/ipc/types.ts | 5 + 21 files changed, 1643 insertions(+), 90 deletions(-) create mode 100644 src-tauri/src/mcp/trust.rs create mode 100644 src-tauri/src/mcp/trust_integration_tests.rs create mode 100644 src-tauri/src/mcp/trust_tests.rs create mode 100644 ui/src/features/panels/McpLaunchControl.tsx diff --git a/docs/mcp-and-skills.md b/docs/mcp-and-skills.md index 0921b1ed..5d6a93ab 100644 --- a/docs/mcp-and-skills.md +++ b/docs/mcp-and-skills.md @@ -25,9 +25,17 @@ server 名允许较宽字符;模型函数名归一为合法字符,碰撞加 作用域先合并,再按 `enabled` 过滤连接项:项目同名配置即使 `enabled:false` 仍覆盖全局项,不会回落连接全局服务器。 +### 启动信任 + +stdio 启动本机进程和 streamable-http 建立远端连接都需要用户先在服务器卡片「批准启动」。许可只存在当前 CodeWave 进程内,绑定 `(scope, project_id, name, canonical_config_path, configuration_hash)`;配置文件中的 enabled/read_only/always_allow 不授予启动权限,也不能自行声明 trusted。确认框列出来源、版本及实际启动摘要(command/args/cwd 或脱敏 URL);env/header 仅展示键名,已识别的敏感参数和 URL 凭据隐藏。 + +自动 warm、显式连接/重连、保存后的差量应用及直接 ensure 共用启动门;许可不存在时保留停止状态且不产生进程或连接。正式批准在提交锁内重读当前磁盘配置,版本失配拒绝,不把旧批准用于新目标。保存后删除、禁用或修改条目会使旧许可失效;不变条目和其他来源保持原授权。现阶段没有外部文件监控,外部编辑在后续重读/差量接纳时检查,不能宣称磁盘修改会立即回收所有在途连接。 + +「撤销启动授权」清除正式许可、待确认及活跃 probe,撤下工具并取消握手/服务;迟到 Ready 和旧 binding 不再可用。「断开」只释放连接,不撤销本次进程许可,重连仍可复用原许可。已经发生的外部副作用不会回滚。普通配置切换仍保留已接纳调用在原 peer 上最多 5 秒排空、不重放的契约。持久信任、技能/角色统一来源与项目提示信任尚未交付,不能把 MCP 启动门等同完整项目信任。 + ## 2. 连接池、运行态与事件 -[manager.rs](../src-tauri/src/mcp/manager.rs) 的 pool key 是 `(scope, project_id, name, canonical_config_path)`(路径身份只在宿主内部使用);会话持有其可见 server 集合。warm 先登记引用再并行连接,IPC 返回只表示接收请求,不表示所有 server 已 Ready。 +[manager.rs](../src-tauri/src/mcp/manager.rs) 的 pool key 是 `(scope, project_id, name, canonical_config_path)`;会话持有其可见 server 集合,设置页读取来源路径和启动许可版本用于确认。warm 先登记引用再为已获许可的配置并行连接,IPC 返回只表示接收请求,不表示所有 server 已 Ready。 状态为 Starting / Ready / Stopped / Evicted / Error;错误序列化形态与前端类型保持一致。`mcp:status` 是增量通知,载荷含 scope/name、工具数量与详情、过滤数、pid/error/note;恢复或发现丢事件时用 snapshot 取权威快照,不能靠最后一个事件重建整个池。 @@ -37,7 +45,7 @@ server 名允许较宽字符;模型函数名归一为合法字符,碰撞加 - [process.rs](../src-tauri/src/mcp/process.rs) 在 Unix 使用进程组,Windows 使用 Job Object/隐藏窗口与退出清理;不能只杀启动器而留下 server 子进程。 - test 使用当前条目草稿的独立临时连接,不读磁盘同名配置,不改变正式池/工具/pid;详情见下节。 -当前 IPC 为 `mcp_list_config`、`mcp_save_config`、`mcp_connect`、`mcp_disconnect`、`mcp_reconnect`、`mcp_test`、`mcp_snapshot`,入口见[host/commands/mcp.rs](../src-tauri/src/host/commands/mcp.rs)。旧 get_mcp_config/save_mcp_config/connect_mcp/mcp_status 适配已移除,不再作为迁移接口维护。 +当前 IPC 为 `mcp_list_config`、`mcp_save_config`、`mcp_set_launch_trust`、`mcp_connect`、`mcp_disconnect`、`mcp_reconnect`、`mcp_prepare_test`、`mcp_test`、`mcp_snapshot`,入口见[host/commands/mcp.rs](../src-tauri/src/host/commands/mcp.rs)。旧 get_mcp_config/save_mcp_config/connect_mcp/mcp_status 适配已移除,不再作为迁移接口维护。 保存通过同一提交锁协调文件版本检查、原子写与完整会话可见集差量接纳。`mcp_list_config` 返回不含原文的 `version`;`mcp_save_config` 必须回传 `expected_version`,冲突时保留草稿并要求重新读取。格式化/键序变化不重启连接,项目修改只触及真正读取该配置路径的 runtime;同项目 id 不同路径保持独立。全局变更不会重建被项目同名项覆盖的连接,disabled 项目覆盖继续遮蔽全局项。 @@ -45,6 +53,8 @@ server 名允许较宽字符;模型函数名归一为合法字符,碰撞加 ### 当前草稿测试 +测试先经 `mcp_prepare_test` 取得绑定来源、项目和完整草稿版本的单次票据;准备不执行命令或网络操作。用户审阅启动摘要并确认后,`mcp_test` 才消费票据建立临时连接。票据五分钟过期,错误配置/项目消费也使其失效;待确认与正在运行的 probe 合计最多 64。测试不继承正式许可,也不授予正式许可。取消确认或编辑草稿后不发起测试;已确认开始的测试仍按下述预算回收。 + 编辑器通过与保存共用的 `serializeMcpServer` 发送 `name + config`,包含 args(含空串/空格)、env/headers 多行原值、cwd、显式 transport、timeout、过滤、信任标记和未知字段。空名、重名、重复键、非法 timeout 在启动前阻断;不以静默丢字段使非法草稿看似有效。新建/重命名/disabled 条目无需保存即可测试;project 仅校验当前项目上下文,不读取合并后的同名 global 条目。 测试只做 initialize/tools/list;初始化和列表阶段各上限 30 秒,总预算 60 秒,回收另有 5 秒上限。临时 service 有 RAII 所有权,失败、超时和任务取消均回收;展示/日志不回显原始配置、env/headers 值或远端错误正文。成功只说明点击瞬间的草稿握手和工具发现成功,不能代替正式连接状态。 @@ -116,6 +126,7 @@ R08/R11 新增回归覆盖:配置版本冲突/非法输入无写入、等价 `v0.11.0` 对应提交的完整三平台 CI 已通过,包含真实 Node stdio/HTTP fixture 与宿主后端测试;各平台结果和跳过边界见[自动验证记录](./testing.md#可靠性修复自动验证记录2026-10-12)。本地 Linux 云环境仍缺 GLib/GTK/WebKit/DBus 等系统依赖,不能写成本地完整门禁通过,也不能将 CI/headless 结果写成 GUI 已验收。会话释放进程树回收已有三平台自动证据;初始化/tools-list 期间 probe 取消回收为 Unix 专属测试,Windows 对应边界仍待补验。 手工清单(尚未执行): +0. 重启后未批准服务器不启动;分别批准 stdio/HTTP,核对摘要、来源和本次进程有效期;取消确认零启动。撤销 Ready/正在握手的服务,工具消失且进程回收;刷新只重读状态与授权元数据,不覆盖未保存草稿。修改配置后须重新批准,其他来源授权不变。 1. A/B 两项目与临时会话共用 global MCP,B 发起长 echo 时保存 A 私有配置;B/global pid 与工具保持不变。 2. 同一 server 请求等待批准时修改 endpoint 或作用域;批准旧请求应得到“配置已变”,新 endpoint 收到零请求。 3. 编辑已保存可用命令为不存在的命令,不保存直接测试应失败;取消后正式连接保持原 pid。新建/重命名草稿也能测试。 diff --git a/docs/settings-guide.md b/docs/settings-guide.md index 8cb1a54b..e95eac09 100644 --- a/docs/settings-guide.md +++ b/docs/settings-guide.md @@ -80,7 +80,7 @@ - 字体使用 `--ws-font-sans` / `--ws-font-content` / `--ws-font-mono`,颜色走 `--ws-*` 与 antd token,不新增硬编码字体链或彩色强调色。外观页按语言、主题、字体排列;主题使用预览卡,三组分别为界面、内容、代码,默认字号 14/14/12px,各带字体与字号预览和还原入口;作用范围见[界面指南](./chat-and-interface.md#12x-字体与字号三槽)。 - 供应商当前是响应式卡片:标题为名称与协议,正文为端点和模型摘要(上下文/输出、图片/视频、active 标记),卡片底部编辑/删除。编辑先在局部表单暂存,不能退回旧“字段变化直接污染页级草稿”行为;字段校验与输出预算见 [供应商与网络](./providers-and-network.md)。 - 网络代理卡片的 `.proxy-mode-list` 必须显式 `font-size: var(--ws-font-size)`,变量由 theme bridge 桥接 antd fontSize。Radio.Group 自带 font-size:0,单靠 DOM 文本存在不能保证标题肉眼可见。manual 校验只看支持协议前缀,空串允许保存;界面默认显示系统代理不等于后端把 null 自动改为显式配置。 -- MCP 当前是服务器卡片与编辑弹框,支持用户级/项目级、测试、断开/重连和工具说明。状态按作用域及服务器名匹配;刷新只重读状态。结构化编辑须保留未知字段,解析不了时保留原始 JSON 供修复,不能降成空列表覆盖原文。详细界面约束见下一节,后端入口为 [mcp.rs](../src-tauri/src/host/commands/mcp.rs)。 +- MCP 当前是服务器卡片与编辑弹框,支持用户级/项目级、启动批准/撤销、测试、断开/重连和工具说明。状态按作用域及服务器名匹配;刷新只重读状态和启动授权元数据,不覆盖草稿。结构化编辑须保留未知字段,解析不了时保留原始 JSON 供修复,不能降成空列表覆盖原文。详细界面约束见下一节,后端入口为 [mcp.rs](../src-tauri/src/host/commands/mcp.rs)。 - 技能启停修改草稿;仅后端标记可删的托管技能提供删除,兼容目录/内置技能不可删。设置空态使用 `settings.skillsEmpty`,不得借用会话空态。 - 保留期修改走主配置保存;“立即清理”使用已保存保留期,有未保存保留期改动时禁用。旧格式历史清理必须保留没有可读新格式副本的旧文件,详见 [会话与项目指南](./sessions-and-projects.md)。 - 关于页的版本与检查更新并排且各有锚点;目录/仓库/许可证操作失败在触发行提示。版本、目录及许可证是可搜索的只读入口。写入后检查使用 `post_write_check.*` 四项,旧 LSP 配置和引导卡已经退役,详见 [写入后检查](./tools-and-safety.md)。 @@ -99,7 +99,7 @@ - 新建/编辑使用一个弹框,包含基本信息与按 stdio/streamable_http 切换的连接字段;参数、环境变量和请求头使用紧凑键值行,窄窗口可上下堆叠,长值软换行且最多六行。 - 环境变量/请求头的常见凭证名称默认遮罩,可临时显隐;新增敏感值输入不中断,失焦再遮罩。增删行重置显隐状态,避免同名下一行继承上一行明文状态。遮罩只影响显示,不加密或改动落盘值。 -- 保存并重连只处理 MCP 文档;后端 `saved:false` 时不移动已保存基线、不关闭弹框,保留输入。保存成功再更新基线、关闭,并在有会话时发起连接与刷新。取消恢复打开时的已保存内容。 +- 保存并重连只处理 MCP 文档;后端 `saved:false` 时不移动已保存基线、不关闭弹框,保留输入。保存成功再更新基线、关闭并刷新;后端差量接纳只启动已有匹配许可的条目,配置变化须另行「批准启动」。取消恢复打开时的已保存内容。启动许可仅本次进程有效,与 read_only/always_allow 的调用审批策略分开;确认展示实际命令/参数/cwd 或脱敏端点及 env/header 键名。 - 删除是底栏左侧描边危险按钮,按钮旁 Popconfirm 的确认按钮使用危险色;取消/保存在右侧。删除从已保存文档移除条目后保存,不顺带提交弹框未保存字段。 - 原始 JSON 无法解析时仍保留原文修复入口,新建不能拿空列表覆盖它;结构化序列化保留未知顶层/条目字段。 - **测试当前草稿**:保存和测试共用条目序列化;IPC 发送完整 name/config,新建或重命名无需先保存。测试只临时启动命令/连接地址并 initialize/tools-list,不读取磁盘同名项、不改变正式池;未保存不代表没有启动副作用。单份草稿单飞,改字段、关闭重开或切上下文会丢弃旧结果。关闭编辑器不立即取消进程;测试总预算60秒,另加最多5秒回收。保存使用文件版本检查,冲突保留草稿;saved表示已写文件并接纳差量应用,Ready/Error仍由状态事件与快照确认。详见[MCP 与技能](./mcp-and-skills.md)。 diff --git a/docs/testing.md b/docs/testing.md index 9fa2e46a..00f5f86d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -111,6 +111,8 @@ Rust ignored 的完整名称: ### Rust、网络与系统资源 +使用非阻塞 listener 轮询的标准库 HTTP 夹具,accept 后要显式 `stream.set_nonblocking(false)` 再使用阻塞读取和 socket timeout:macOS 的原生 accept 会继承 O_NONBLOCK。不得把 `WouldBlock` 或读错误当作完整空请求并消费响应,否则会出现提前响应、POST RST 和重定向请求错位;读错误/提前 EOF 应让夹具明确失败。 + - mock TCP server 要持有 listener 直到测试结束、读完请求头再优雅关闭写半部;直接丢弃带未读数据的 socket 在 Windows 会变成 RST,提前释放临时端口还可能被并发测试复用。`midstream_disconnect_maps_to_network` 的偶发失败不能未经调查就归为代理问题。 - 真实 keyring 是机器级资源,相关测试串行且清理自己的条目;无 Secret Service 时验证明确的安全失败、旧配置保留和无新增明文,不伪造迁移成功或恢复明文回退。 - 定时等待用 Tokio 虚拟时钟测临界点和取消,进程退出/OS 信号等真实调度边界仍需集成测试。 diff --git a/src-tauri/src/host/commands/mcp.rs b/src-tauri/src/host/commands/mcp.rs index 89db47ce..48678211 100644 --- a/src-tauri/src/host/commands/mcp.rs +++ b/src-tauri/src/host/commands/mcp.rs @@ -79,15 +79,23 @@ pub struct McpServerView { extra_keys: Vec, source: String, overridden: Option, + source_path: String, + launch: crate::mcp::LaunchStatus, } -fn view_of(m: &MergedServer) -> McpServerView { +fn view_of( + m: &MergedServer, + core: &AgentCore, + session_id: Option<&str>, +) -> Result { + let source = scope_ref(core, session_id, m.source)?; + let key = crate::mcp::launch_key(core, &source, session_id, &m.name).map_err(err)?; let (transport, inferred) = match m.cfg.resolve_transport() { Ok(McpTransport::Stdio) => ("stdio", !m.cfg.transport_is_explicit()), Ok(McpTransport::StreamableHttp) => ("streamable_http", !m.cfg.transport_is_explicit()), Err(_) => ("unknown", false), }; - McpServerView { + Ok(McpServerView { name: m.name.clone(), transport: transport.to_string(), transport_inferred: inferred, @@ -105,7 +113,9 @@ fn view_of(m: &MergedServer) -> McpServerView { extra_keys: m.cfg.extra.keys().cloned().collect(), source: m.source.as_str().to_string(), overridden: m.overridden.map(|s| s.as_str().to_string()), - } + source_path: key.source.display().to_string(), + launch: core.mcp.launch_status(&key, &m.cfg), + }) } /// 一层配置的视图载荷。 @@ -124,8 +134,14 @@ pub struct McpConfigDocPayload { issues: Vec, } -fn doc_payload(sref: &ScopeRef, doc: &ScopeDoc, effective: &[MergedServer]) -> McpConfigDocPayload { - McpConfigDocPayload { +fn doc_payload( + sref: &ScopeRef, + doc: &ScopeDoc, + effective: &[MergedServer], + core: &AgentCore, + session_id: Option<&str>, +) -> Result { + Ok(McpConfigDocPayload { scope: sref.scope.as_str().to_string(), path: sref.path.display().to_string(), json: doc.raw.clone(), @@ -134,18 +150,25 @@ fn doc_payload(sref: &ScopeRef, doc: &ScopeDoc, effective: &[MergedServer]) -> M .servers .iter() .map(|(name, cfg)| { - view_of(&MergedServer { - name: name.clone(), - cfg: cfg.clone(), - source: sref.scope, - overridden: None, - issues: Vec::new(), - }) + view_of( + &MergedServer { + name: name.clone(), + cfg: cfg.clone(), + source: sref.scope, + overridden: None, + issues: Vec::new(), + }, + core, + session_id, + ) }) - .collect(), - effective: effective.iter().map(view_of).collect(), + .collect::>()?, + effective: effective + .iter() + .map(|server| view_of(server, core, session_id)) + .collect::>()?, issues: doc.issues.clone(), - } + }) } /// 合并后的生效条目(全局 ∪ 项目)。 @@ -170,7 +193,52 @@ pub async fn mcp_list_config( let sref = scope_ref(&core, session_id.as_deref(), scope)?; let doc = load_scope(&sref); let effective = effective_servers(&core, session_id.as_deref()); - Ok(doc_payload(&sref, &doc, &effective)) + doc_payload(&sref, &doc, &effective, &core, session_id.as_deref()) +} + +#[tauri::command] +pub async fn mcp_set_launch_trust( + core: Core<'_>, + session_id: Option, + scope: String, + name: String, + expected_version: String, + trusted: bool, +) -> Result { + let source = scope_ref( + &core, + session_id.as_deref(), + Scope::parse(&scope).map_err(err)?, + )?; + wire_sink(&core).await; + crate::mcp::set_launch_trust( + &core, + &source, + session_id.as_deref(), + &name, + &expected_version, + trusted, + ) + .await + .map_err(err) +} + +#[tauri::command] +pub async fn mcp_prepare_test( + core: Core<'_>, + session_id: Option, + scope: String, + name: String, + config: Value, +) -> Result { + let source = scope_ref( + &core, + session_id.as_deref(), + Scope::parse(&scope).map_err(err)?, + )?; + let key = crate::mcp::launch_key(&core, &source, session_id.as_deref(), &name).map_err(err)?; + let cfg = crate::mcp::validate_server(&name, config).map_err(err)?; + core.mcp.prepare_probe(&key, &cfg).map_err(err) } /// 保存某作用域的 MCP 配置。 @@ -269,14 +337,20 @@ pub async fn mcp_test( scope: String, name: String, config: Value, + confirmation: Option, ) -> Result { let scope = Scope::parse(&scope).map_err(err)?; // Validate project context but never read any on-disk server or change pool membership. - let _ = scope_ref(&core, session_id.as_deref(), scope)?; + let source = scope_ref(&core, session_id.as_deref(), scope)?; + let key = crate::mcp::launch_key(&core, &source, session_id.as_deref(), &name).map_err(err)?; let cfg = crate::mcp::validate_server(&name, config).map_err(err)?; let http = core.client.read().unwrap().clone(); Ok( - match crate::mcp::McpManager::test_connection(cfg, http).await { + match core + .mcp + .test_connection(&key, cfg, confirmation.as_deref().unwrap_or_default(), http) + .await + { Ok(n) => serde_json::json!({ "ok": true, "tools": n, "error": Value::Null }), Err(e) => serde_json::json!({ "ok": false, "tools": 0, "error": e }), }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2c2d47b..337a71d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -417,6 +417,8 @@ pub fn run() { host::commands::mcp_disconnect, host::commands::mcp_reconnect, host::commands::mcp_test, + host::commands::mcp_prepare_test, + host::commands::mcp_set_launch_trust, host::commands::mcp_snapshot, host::commands::list_agents, host::commands::list_skills, diff --git a/src-tauri/src/mcp/manager.rs b/src-tauri/src/mcp/manager.rs index 6d1a1aa7..82e92abf 100644 --- a/src-tauri/src/mcp/manager.rs +++ b/src-tauri/src/mcp/manager.rs @@ -37,6 +37,11 @@ use super::config::{McpServerConfig, McpTransport, Scope}; use super::error::McpError; use super::process::build_stdio_wrap; use super::tools::{McpTool, McpToolDef, build_tool_defs, filter_tools, normalize_schema}; +use super::trust::{LaunchPermit, LaunchRegistry, LaunchStatus, ProbeChallenge, untrusted}; + +#[cfg(test)] +#[path = "trust_integration_tests.rs"] +mod trust_integration_tests; /// 连接 + initialize + list_tools 的总超时。 pub const INIT_TIMEOUT: Duration = Duration::from_secs(30); @@ -155,6 +160,7 @@ pub type StatusSink = Arc; /// 池内条目。 struct ServerEntry { cfg: McpServerConfig, + launch: Option, state: McpState, error: Option, tools: Vec, @@ -174,7 +180,7 @@ struct ServerEntry { /// 最近一次使用序号(LRU) last_used: u64, note: Option, - /// `tools/list_changed` 脏标记(run 边界消费) + /// `tools/list_changed` 脏标记(LLM step 边界消费) dirty: Option>, /// 最近一次被淘汰的时刻(重拉防抖) last_evict: Option, @@ -212,6 +218,14 @@ fn changed_error() -> McpError { ) } +fn entry_ready(key: &PoolKey, entry: &ServerEntry) -> bool { + entry.state == McpState::Ready + && entry + .launch + .as_ref() + .is_some_and(|permit| permit.check(key, &entry.cfg).is_ok()) +} + fn validate_binding(inner: &Inner, session: &str, binding: &CallBinding) -> Result<(), McpError> { if !inner .sessions @@ -222,6 +236,9 @@ fn validate_binding(inner: &Inner, session: &str, binding: &CallBinding) -> Resu } let e = inner.entries.get(&binding.key).ok_or_else(changed_error)?; if e.state != McpState::Ready + || e.launch + .as_ref() + .is_none_or(|permit| permit.check(&binding.key, &e.cfg).is_err()) || e.generation != binding.generation || e.config_revision != binding.config_revision || e.policy_revision != binding.policy_revision @@ -233,8 +250,12 @@ fn validate_binding(inner: &Inner, session: &str, binding: &CallBinding) -> Resu } let tools: Vec<_> = inner.sessions[session] .iter() - .filter_map(|key| inner.entries.get(key)) - .filter(|e| e.state == McpState::Ready) + .filter_map(|key| { + inner + .entries + .get(key) + .filter(|entry| entry_ready(key, entry)) + }) .flat_map(|e| e.tools.iter().cloned()) .collect(); let (_, index) = build_tool_defs(&tools); @@ -283,6 +304,7 @@ impl Drop for ProbeGuard { /// MCP 连接管理器。 pub struct McpManager { inner: Mutex, + launch: LaunchRegistry, max_servers: usize, tick: AtomicU64, sink: RwLock>, @@ -296,8 +318,7 @@ impl Default for McpManager { /// 订阅 `tools/list_changed`:**只置脏标记**,绝不改共享状态。 /// -/// 工具集变更由 [`McpManager::tool_defs_for`] 在 **run 边界**消费——若在通知回调里直接改, -/// 一次 run 进行到一半工具集就变了,模型会拿旧 schema 去调新工具。 +/// 工具集变更在下一次工具快照/定义读取时消费;通知回调不直接修改共享 schema。 #[derive(Clone)] struct ToolListWatcher { dirty: Arc, @@ -323,7 +344,7 @@ struct Ready { service: ClientService, peer: Peer, pid: Option, - /// `tools/list_changed` 脏标记(run 边界消费) + /// `tools/list_changed` 脏标记(LLM step 边界消费) dirty: Arc, } @@ -370,6 +391,7 @@ fn payload_of(key: &PoolKey, e: &ServerEntry) -> McpStatusPayload { } fn clear_connection(e: &mut ServerEntry) { + e.launch = None; e.handshake_cancel.cancel(); e.handshake_cancel = CancellationToken::new(); e.connecting = false; @@ -386,6 +408,7 @@ impl McpManager { /// 新建管理器(`max_servers` = 连接池上限)。 pub fn new(max_servers: usize) -> Self { McpManager { + launch: LaunchRegistry::default(), inner: Mutex::new(Inner { entries: HashMap::new(), sessions: HashMap::new(), @@ -431,6 +454,60 @@ impl McpManager { self.tick() } + pub fn launch_status(&self, key: &PoolKey, cfg: &McpServerConfig) -> LaunchStatus { + self.launch.status(key, cfg) + } + + /// Called only after the host has checked the user's current source/version. + pub async fn set_launch_trust( + &self, + key: &PoolKey, + cfg: &McpServerConfig, + version: &str, + trusted: bool, + ) -> Result<(), McpError> { + if super::launch_version(cfg) != version || (trusted && !cfg.enabled) { + return Err(untrusted()); + } + let mut inner = self.inner.lock().await; + if trusted { + return self.launch.grant(key, cfg, version); + } + self.launch.revoke(key); + let retired = inner.entries.get_mut(key).map(|entry| { + let service = entry.service.take(); + clear_connection(entry); + entry.state = McpState::Stopped; + entry.error = None; + entry.note = Some(untrusted().message); + (service, payload_of(key, entry)) + }); + drop(inner); + if let Some((service, payload)) = retired { + if let Some(service) = service { + tokio::spawn(close_service(service)); + } + self.emit_key(key, "", &payload).await; + } + Ok(()) + } + + pub fn prepare_probe( + &self, + key: &PoolKey, + cfg: &McpServerConfig, + ) -> Result { + self.launch.prepare_probe(key, cfg) + } + + pub fn invalidate_launch_source( + &self, + source: &std::path::Path, + servers: &[(String, McpServerConfig)], + ) { + self.launch.invalidate_source(source, servers); + } + fn tick(&self) -> u64 { self.tick.fetch_add(1, Ordering::Relaxed) } @@ -603,6 +680,9 @@ impl McpManager { e.sessions.insert(session.clone()); let changed = e.cfg != *cfg; if changed { + if !self.launch.status(key, cfg).trusted { + self.launch.revoke(key); + } affected.insert(format!("{}:{}", key.scope_str(), key.name)); if let Some(svc) = e.service.take() { retired.push((svc, e.in_flight.clone())); @@ -828,8 +908,32 @@ impl McpManager { session: &str, expected_generation: Option, ) -> Result { + let permit = match self.launch.connection(key, &cfg) { + Ok(permit) => permit, + Err(error) => { + let payload = { + let mut inner = self.inner.lock().await; + inner.entries.get_mut(key).and_then(|entry| { + if entry.cfg != cfg + || expected_generation.is_some_and(|g| g != entry.generation) + { + return None; + } + entry.state = McpState::Stopped; + entry.error = None; + entry.note = Some(error.message.clone()); + Some(payload_of(key, entry)) + }) + }; + if let Some(payload) = payload { + self.emit_key(key, session, &payload).await; + } + return Err(error); + } + }; let (generation, handshake_cancel) = { let mut inner = self.inner.lock().await; + permit.check(key, &cfg)?; if let Some(e) = inner.entries.get_mut(key) { // Queued generation-owned work belongs to the key, not its initiating session. // A surviving shared reference may complete it after the initiator closes. @@ -857,6 +961,7 @@ impl McpManager { )); } e.generation = e.generation.wrapping_add(1); + e.launch = Some(permit.clone()); e.connecting = true; (e.generation, e.handshake_cancel.clone()) } else { @@ -872,7 +977,7 @@ impl McpManager { let connected = tokio::select! { biased; _ = handshake_cancel.cancelled() => Err(changed_error()), - result = Self::spawn_connect(key, &cfg, http) => result, + result = Self::spawn_connect(key, &cfg, http, &permit) => result, }; match connected { Ok(ready) => { @@ -931,7 +1036,11 @@ impl McpManager { let payload = { let mut inner = self.inner.lock().await; inner.entries.get_mut(key).and_then(|e| { - if e.generation != generation { + if e.generation != generation + || e.launch + .as_ref() + .is_none_or(|permit| permit.check(key, &e.cfg).is_err()) + { return None; } e.tools = tools; @@ -986,10 +1095,24 @@ impl McpManager { key: &PoolKey, cfg: &McpServerConfig, http: reqwest::Client, + permit: &LaunchPermit, + ) -> Result { + permit.check(key, cfg)?; + tokio::select! { + biased; + _ = permit.cancel.cancelled() => Err(untrusted()), + ready = Self::connect_transport(key, cfg, http) => ready, + } + } + + async fn connect_transport( + key: &PoolKey, + cfg: &McpServerConfig, + http: reqwest::Client, ) -> Result { let transport = cfg.resolve_transport()?; let name = key.name.clone(); - // tools/list_changed 只置位;实际刷新发生在 run 边界(tool_defs_for) + // tools/list_changed only marks dirty; refresh before taking tool definitions. let dirty = Arc::new(AtomicBool::new(false)); let connect = async { match transport { @@ -1104,8 +1227,7 @@ impl McpManager { .get(session) .map(|s| s.iter().cloned().collect()) .unwrap_or_default(); - // 先把**所有**脏标记取走(含不在本会话可见集里的,否则永远清不掉), - // 再挑出本会话可见且已就绪的条目去刷新。 + // Consume only this session's dirty entries; other sessions own their refresh. let mut dirty = Vec::new(); for (k, e) in inner.entries.iter_mut() { if !keys.contains(k) { @@ -1115,13 +1237,13 @@ impl McpManager { .dirty .as_ref() .is_some_and(|d| d.swap(false, Ordering::SeqCst)); - if flagged && e.state == McpState::Ready && keys.contains(k) { + if flagged && entry_ready(k, e) && keys.contains(k) { dirty.push((k.clone(), e.cfg.clone())); } } (keys, dirty) }; - // run 边界刷新:工具集变更只在这里生效,绝不在通知回调里改共享状态 + // Refresh at the step snapshot boundary, never inside the notification callback. for (key, cfg) in dirty { self.refresh_tools(&key, &cfg).await; } @@ -1131,7 +1253,7 @@ impl McpManager { let mut tools = Vec::new(); for k in &keys { if let Some(e) = inner.entries.get_mut(k) - && e.state == McpState::Ready + && entry_ready(k, e) { e.last_used = tick; tools.extend(e.tools.iter().cloned()); @@ -1150,8 +1272,7 @@ impl McpManager { let keys = inner.sessions.get(session).cloned().unwrap_or_default(); let tools: Vec<_> = keys .iter() - .filter_map(|k| inner.entries.get(k)) - .filter(|e| e.state == McpState::Ready) + .filter_map(|k| inner.entries.get(k).filter(|entry| entry_ready(k, entry))) .flat_map(|e| e.tools.iter().cloned()) .collect(); let (defs, index) = build_tool_defs(&tools); @@ -1226,6 +1347,9 @@ impl McpManager { .get_mut(&binding.key) .ok_or_else(changed_error)?; entry.cfg.always_allow = true; + self.launch + .rebind_policy(&binding.key, &binding.cfg, &entry.cfg); + entry.launch = Some(self.launch.connection(&binding.key, &entry.cfg)?); entry.policy_revision += 1; let mut updated = binding.clone(); updated.cfg = entry.cfg.clone(); @@ -1233,7 +1357,7 @@ impl McpManager { Ok(updated) } - /// 重新列某条目的工具(`tools/list_changed` 后由 run 边界调用)。 + /// 重新列某条目的工具(`tools/list_changed` 后由工具快照/定义读取调用)。 /// /// 失败只 `warn` 并保留旧列表——刷新失败不该把可用连接打成错误态。 async fn refresh_tools(&self, key: &PoolKey, cfg: &McpServerConfig) { @@ -1242,6 +1366,7 @@ impl McpManager { inner .entries .get(key) + .filter(|entry| entry_ready(key, entry)) .and_then(|e| e.peer.clone().map(|p| (p, e.generation))) }; let Some((peer, generation)) = peer else { @@ -1413,13 +1538,19 @@ impl McpManager { /// 临时测试连接:起 → `tools/list` → **立即回收**,不并入连接池、不改正式状态。 pub async fn test_connection( + &self, + key: &PoolKey, cfg: McpServerConfig, + confirmation: &str, http: reqwest::Client, ) -> Result { - let key = PoolKey::global("__test__"); - let ready = tokio::time::timeout(PROBE_TIMEOUT, Self::spawn_connect(&key, &cfg, http)) - .await - .map_err(|_| McpError::handshake("Draft probe timed out (60s total budget)"))??; + let lease = self.launch.confirm_probe(confirmation, key, &cfg)?; + let ready = tokio::time::timeout( + PROBE_TIMEOUT, + Self::spawn_connect(key, &cfg, http, &lease.permit), + ) + .await + .map_err(|_| McpError::handshake("Draft probe timed out (60s total budget)"))??; let n = ready.tools.len(); close_service(ready.service).await; Ok(n) @@ -1443,6 +1574,7 @@ pub struct McpCallOutput { fn blank_entry(cfg: &McpServerConfig, tick: u64) -> ServerEntry { ServerEntry { + launch: None, cfg: cfg.clone(), state: McpState::Starting, error: None, @@ -1468,8 +1600,8 @@ fn blank_entry(cfg: &McpServerConfig, tick: u64) -> ServerEntry { /// rmcp 的服务错误 → 结构化分类。 /// -/// **只有** `TransportSend` / `TransportClosed` 判为连接类(可重连 + 只重放只读调用); -/// 协议错与超时一律 `Call`(可能已有副作用,绝不重放)。 +/// Only transport failures are classified as connection errors. Classification does +/// not trigger reconnection or replay; every failed call may already have had effects. fn classify_service_error(e: rmcp::service::ServiceError) -> McpError { use rmcp::service::ServiceError as E; match e { @@ -1557,6 +1689,45 @@ mod tests { type StatusLog = Arc>>; + impl McpManager { + pub(super) async fn warm_approved( + self: &Arc, + session: &str, + visible: Vec<(PoolKey, McpServerConfig)>, + http: reqwest::Client, + ) { + for (key, cfg) in &visible { + self.set_launch_trust(key, cfg, &super::super::launch_version(cfg), true) + .await + .unwrap(); + } + self.warm(session, visible, http).await; + } + } + + async fn probe_approved( + cfg: McpServerConfig, + http: reqwest::Client, + ) -> Result { + let manager = McpManager::default(); + let key = PoolKey::global("__test__"); + let challenge = manager.prepare_probe(&key, &cfg)?; + manager + .test_connection(&key, cfg, &challenge.id, http) + .await + } + + async fn spawn_approved( + key: &PoolKey, + cfg: &McpServerConfig, + http: reqwest::Client, + ) -> Result { + let registry = LaunchRegistry::default(); + registry.grant(key, cfg, &super::super::launch_version(cfg))?; + let permit = registry.connection(key, cfg)?; + McpManager::spawn_connect(key, cfg, http, &permit).await + } + /// 收集状态事件的 sink。 fn collector() -> (StatusSink, StatusLog) { let log: StatusLog = Arc::new(StdMutex::new(Vec::new())); @@ -1610,7 +1781,7 @@ mod tests { std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../scripts/mcp-test-server.mjs") } - fn node_cfg() -> McpServerConfig { + pub(super) fn node_cfg() -> McpServerConfig { serde_json::from_value(json!({ "command": "node", "args": [test_server_script().to_string_lossy()], @@ -1625,13 +1796,13 @@ mod tests { ); let manager = Arc::new(McpManager::default()); manager - .warm(session, vec![(key, cfg)], reqwest::Client::new()) + .warm_approved(session, vec![(key, cfg)], reqwest::Client::new()) .await; wait_ready(&manager, session, 1).await; manager } - async fn wait_ready(manager: &McpManager, session: &str, count: usize) { + pub(super) async fn wait_ready(manager: &McpManager, session: &str, count: usize) { tokio::time::timeout(Duration::from_secs(10), async { loop { let status = manager.status_for(session).await; @@ -1864,7 +2035,7 @@ mod tests { pidfile.display().to_string(), ); cfg.env.insert(phase.into(), "30000".into()); - let handle = tokio::spawn(McpManager::test_connection(cfg, reqwest::Client::new())); + let handle = tokio::spawn(probe_approved(cfg, reqwest::Client::new())); tokio::time::timeout(Duration::from_secs(5), async { while !pidfile.exists() { tokio::time::sleep(Duration::from_millis(10)).await; @@ -1900,14 +2071,14 @@ mod tests { let cfg = node_cfg(); let manager = ready_manager("a", global.clone(), cfg.clone()).await; manager - .warm( + .warm_approved( "a", vec![(global.clone(), cfg.clone()), (a.clone(), cfg.clone())], reqwest::Client::new(), ) .await; manager - .warm( + .warm_approved( "b", vec![(global.clone(), cfg.clone()), (b.clone(), cfg.clone())], reqwest::Client::new(), @@ -1952,8 +2123,19 @@ mod tests { assert_eq!(old.pid, new.pid); assert_eq!(old.generation, new.generation); } - wait_ready(&manager, "a", 2).await; let current_cfg = manager.inner.lock().await.entries[&a].cfg.clone(); + assert!(!manager.launch_status(&a, ¤t_cfg).trusted); + manager + .warm_approved( + "a", + vec![ + (global.clone(), cfg.clone()), + (a.clone(), current_cfg.clone()), + ], + reqwest::Client::new(), + ) + .await; + wait_ready(&manager, "a", 2).await; let no_op = manager .reconcile( vec![("a".into(), vec![(global, cfg), (a, current_cfg)])], @@ -1970,6 +2152,10 @@ mod tests { let manager = Arc::new(McpManager::default()); let key = PoolKey::global("server"); let cfg = bogus_cfg(); + manager + .set_launch_trust(&key, &cfg, &super::super::launch_version(&cfg), true) + .await + .unwrap(); manager .register_session("s", &[(key.clone(), cfg.clone())]) .await; @@ -1977,6 +2163,7 @@ mod tests { let mut inner = manager.inner.lock().await; let entry = inner.entries.get_mut(&key).unwrap(); entry.state = McpState::Ready; + entry.launch = Some(manager.launch.connection(&key, &cfg).unwrap()); entry.tools = vec![McpTool { server: "server".into(), name: "echo".into(), @@ -2045,6 +2232,12 @@ mod tests { let a = PoolKey::global("a"); let b = PoolKey::global("b"); let visible = vec![(a.clone(), node_cfg()), (b.clone(), node_cfg())]; + for (key, cfg) in &visible { + manager + .set_launch_trust(key, cfg, &super::super::launch_version(cfg), true) + .await + .unwrap(); + } manager .connect_named("s", visible.clone(), &["a".into()], reqwest::Client::new()) .await; @@ -2150,6 +2343,15 @@ mod tests { .await; assert_eq!(pending[0].1.len(), 1); assert!(pending[1].1.is_empty()); + manager + .set_launch_trust( + &key, + &changed, + &super::super::launch_version(&changed), + true, + ) + .await + .unwrap(); let generation = manager.inner.lock().await.entries[&key].generation; manager.release_session("initiator").await; manager @@ -2216,7 +2418,7 @@ mod tests { assert_eq!(c.text, "boom"); } - /// `tools/list_changed` 的脏标记必须在 **run 边界**被取走。 + /// `tools/list_changed` 的脏标记必须在工具定义读取时被取走。 /// /// 这里用一个起不来的 server(状态 Error)验证「标记照样清掉」—— /// 若只在就绪条目上清,坏连接的标记会永久残留、下次就绪时被误当成刚变更。 @@ -2224,7 +2426,7 @@ mod tests { async fn run_boundary_clears_tool_list_dirty_flag() { let mgr = Arc::new(McpManager::default()); let key = PoolKey::global("a"); - mgr.warm( + mgr.warm_approved( "s", vec![(key.clone(), bogus_cfg())], reqwest::Client::new(), @@ -2277,7 +2479,8 @@ mod tests { let mgr = Arc::new(McpManager::default()); // 会话 A 可见 fs;会话 B 完全看不到任何 MCP let visible = vec![(PoolKey::global("fs"), bogus_cfg())]; - mgr.warm("sA", visible, reqwest::Client::new()).await; + mgr.warm_approved("sA", visible, reqwest::Client::new()) + .await; // 等 warm 的内部任务跑完 tokio::time::sleep(Duration::from_millis(300)).await; let cancel = CancellationToken::new(); @@ -2306,7 +2509,7 @@ mod tests { let mgr = Arc::new(McpManager::default()); let (sink, log) = collector(); mgr.set_status_sink(sink).await; - mgr.warm( + mgr.warm_approved( "s1", vec![(PoolKey::global("a"), bogus_cfg())], reqwest::Client::new(), @@ -2358,7 +2561,7 @@ mod tests { } let mgr = Arc::new(McpManager::default()); let key = PoolKey::global("test"); - mgr.warm("s", vec![(key.clone(), node_cfg())], reqwest::Client::new()) + mgr.warm_approved("s", vec![(key.clone(), node_cfg())], reqwest::Client::new()) .await; let pid = tokio::time::timeout(Duration::from_secs(10), async { loop { @@ -2408,7 +2611,7 @@ mod tests { .insert("s".to_string(), HashSet::from([key.clone()])); 1 }; - let ready = McpManager::spawn_connect(&key, &cfg, reqwest::Client::new()) + let ready = spawn_approved(&key, &cfg, reqwest::Client::new()) .await .expect("test server should connect"); let pid = ready.pid.expect("stdio PID"); @@ -2433,7 +2636,7 @@ mod tests { let mgr = Arc::new(McpManager::default()); for i in 0..5 { let s = format!("s{i}"); - mgr.warm( + mgr.warm_approved( &s, vec![(PoolKey::global("a"), bogus_cfg())], reqwest::Client::new(), @@ -2458,7 +2661,7 @@ mod tests { let (sink, log) = collector(); mgr.set_status_sink(sink).await; for i in 0..4 { - mgr.warm( + mgr.warm_approved( &format!("s{i}"), vec![(PoolKey::global(format!("srv{i}")), bogus_cfg())], reqwest::Client::new(), @@ -2471,7 +2674,7 @@ mod tests { mgr.release_session(&format!("s{i}")).await; } // 第 5 个条目触发淘汰 - mgr.warm( + mgr.warm_approved( "sx", vec![(PoolKey::global("srv-new"), bogus_cfg())], reqwest::Client::new(), @@ -2491,7 +2694,7 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let mgr = Arc::new(McpManager::new(2)); - mgr.warm( + mgr.warm_approved( "s0", vec![(PoolKey::global("srv0"), bogus_cfg())], reqwest::Client::new(), @@ -2500,7 +2703,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(200)).await; // 再塞 3 个新键:池上限 2,但 srv0 被引用,不得淘汰 for i in 1..4 { - mgr.warm( + mgr.warm_approved( &format!("s{i}"), vec![(PoolKey::global(format!("srv{i}")), bogus_cfg())], reqwest::Client::new(), @@ -2533,7 +2736,7 @@ mod tests { let mgr = Arc::new(McpManager::default()); let (sink, log) = collector(); mgr.set_status_sink(sink).await; - mgr.warm( + mgr.warm_approved( "s-main", vec![(PoolKey::global("wavetest.proxy"), node_cfg())], reqwest::Client::new(), @@ -2591,7 +2794,7 @@ mod tests { return; } let mgr = Arc::new(McpManager::default()); - mgr.warm( + mgr.warm_approved( "s", vec![(PoolKey::global("t"), node_cfg())], reqwest::Client::new(), @@ -2649,7 +2852,7 @@ mod tests { let cfg: McpServerConfig = serde_json::from_value(json!({ "url": format!("http://127.0.0.1:{port}/mcp") })) .unwrap(); - mgr.warm( + mgr.warm_approved( "s", vec![(PoolKey::global("h"), cfg)], reqwest::Client::new(), @@ -2684,7 +2887,7 @@ mod tests { eprintln!("skip:node 不可用"); return; } - let n = McpManager::test_connection(node_cfg(), reqwest::Client::new()) + let n = probe_approved(node_cfg(), reqwest::Client::new()) .await .expect("临时测试连接应成功"); assert!(n >= 2, "应发现 echo / add 两个工具,实际 {n}"); @@ -2692,7 +2895,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_connection_reports_spawn_failure() { - let e = McpManager::test_connection(bogus_cfg(), reqwest::Client::new()) + let e = probe_approved(bogus_cfg(), reqwest::Client::new()) .await .unwrap_err(); assert_eq!(e.kind, McpErrorKind::Spawn); @@ -2784,7 +2987,7 @@ setInterval(()=>{}, 1000); })) .unwrap(); let mgr = Arc::new(McpManager::default()); - mgr.warm( + mgr.warm_approved( "s", vec![(PoolKey::global("t"), cfg)], reqwest::Client::new(), diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 6ad80763..b07d4285 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -22,6 +22,7 @@ mod manager; mod process; mod service; mod tools; +mod trust; pub use config::{ CONFIG_COMMIT, ConfigIssue, DEFAULT_TIMEOUT_MS, IssueLevel, McpServerConfig, McpTransport, @@ -40,4 +41,5 @@ pub use tools::{ pub(crate) use process::wrap_process_tree; -pub use service::{SaveReceipt, save_configuration, visible_servers}; +pub use service::{SaveReceipt, launch_key, save_configuration, set_launch_trust, visible_servers}; +pub use trust::{LaunchStatus, ProbeChallenge, launch_version}; diff --git a/src-tauri/src/mcp/service.rs b/src-tauri/src/mcp/service.rs index a713be2a..3b6303b7 100644 --- a/src-tauri/src/mcp/service.rs +++ b/src-tauri/src/mcp/service.rs @@ -7,6 +7,86 @@ use super::{ use crate::core::agent::{AgentCore, SessionRuntime}; use serde::Serialize; +pub fn launch_key( + core: &AgentCore, + source: &ScopeRef, + session_id: Option<&str>, + name: &str, +) -> Result { + let key = match source.scope { + Scope::Global => PoolKey::global(name), + Scope::Project => { + let rt = session_id + .and_then(|id| core.session(id)) + .ok_or_else(|| McpError::config("Project session closed"))?; + if rt.zombie.load(std::sync::atomic::Ordering::SeqCst) + || !rt.project_dir.as_deref().is_some_and(|path| { + source_identity(&project_scope(path).path) == source_identity(&source.path) + }) + { + return Err(McpError::config("Project source changed")); + } + PoolKey::project(rt.project_id.clone().unwrap_or_default(), name) + } + }; + Ok(key.with_source(&source.path)) +} + +/// A host action, separate from enabled/read_only/always_allow and scope-file contents. +pub async fn set_launch_trust( + core: &AgentCore, + source: &ScopeRef, + session_id: Option<&str>, + name: &str, + expected: &str, + trusted: bool, +) -> Result { + let _commit = CONFIG_COMMIT.lock().await; + let key = launch_key(core, source, session_id, name)?; + let doc = load_scope(source); + let cfg = doc + .servers + .iter() + .find(|(server, _)| server == name) + .map(|(_, cfg)| cfg) + .ok_or_else(|| McpError::config("MCP server no longer exists"))?; + if doc.issues.iter().any(|issue| { + issue.level == IssueLevel::Error + && issue.server.as_deref().is_none_or(|server| server == name) + }) { + return Err(McpError::config( + "MCP configuration must be corrected before launch approval", + )); + } + core.mcp + .set_launch_trust(&key, cfg, expected, trusted) + .await?; + if trusted { + let sessions: Vec<_> = core + .sessions + .iter() + .filter_map(|entry| { + let rt = entry.value(); + if rt.zombie.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + let visible = visible_servers(rt); + visible + .iter() + .any(|(visible, _)| *visible == key) + .then(|| (rt.id.clone(), visible)) + }) + .collect(); + let http = core.client.read().unwrap().clone(); + for (session, visible) in sessions { + core.mcp + .connect_named(&session, visible, &[name.into()], http.clone()) + .await; + } + } + Ok(core.mcp.launch_status(&key, cfg)) +} + pub fn visible_servers(rt: &SessionRuntime) -> Vec<(PoolKey, McpServerConfig)> { let global = global_scope(&rt.data_dir); let project = rt.project_dir.as_deref().map(project_scope); @@ -92,6 +172,8 @@ pub async fn save_configuration( crate::util::atomic::atomic_write(&source.path, text.as_bytes()).map_err(|_| { McpError::config("MCP configuration could not be saved; existing connections unchanged") })?; + core.mcp + .invalidate_launch_source(&source_identity(&source.path), &parsed.servers); // Only runtimes actually reading this file are affected. The canonical path distinguishes relocated projects. let identity = source_identity(&source.path); let sessions = core @@ -124,6 +206,55 @@ mod tests { use crate::core::agent::test_support::make_core; use crate::tools::pathutil::WriteRoots; + #[tokio::test] + async fn launch_approval_rereads_disk_and_saves_revoke_old_versions() { + let dir = tempfile::tempdir().unwrap(); + let core = make_core(&WriteRoots { + workspace: dir.path().to_path_buf(), + data_dir: dir.path().to_path_buf(), + extra: vec![], + }); + let source = global_scope(dir.path()); + let original = r#"{"mcpServers":{"saved":{"command":"fixture","read_only":true,"always_allow":true}}}"#; + std::fs::write(&source.path, original).unwrap(); + let cfg = load_scope(&source).servers.remove(0).1; + let key = launch_key(&core, &source, None, "saved").unwrap(); + let version = super::super::launch_version(&cfg); + assert!(!core.mcp.launch_status(&key, &cfg).trusted); + set_launch_trust(&core, &source, None, "saved", &version, true) + .await + .unwrap(); + assert!(core.mcp.launch_status(&key, &cfg).trusted); + assert!( + core.mcp.all_status().await.is_empty(), + "approval without a visible session must not start a process" + ); + let changed = r#"{"mcpServers":{"saved":{"command":"replacement"}}}"#; + std::fs::write(&source.path, changed).unwrap(); + assert!( + set_launch_trust(&core, &source, None, "saved", &version, true) + .await + .is_err() + ); + let changed_cfg = load_scope(&source).servers.remove(0).1; + assert!(!core.mcp.launch_status(&key, &changed_cfg).trusted); + set_launch_trust( + &core, + &source, + None, + "saved", + &super::super::launch_version(&changed_cfg), + true, + ) + .await + .unwrap(); + save_configuration(&core, &source, original, &scope_version(changed)) + .await + .unwrap(); + assert!(!core.mcp.launch_status(&key, &changed_cfg).trusted); + assert!(!core.mcp.launch_status(&key, &cfg).trusted); + } + #[tokio::test] async fn invalid_or_conflicting_save_never_changes_disk_or_creates_validation_files() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/mcp/trust.rs b/src-tauri/src/mcp/trust.rs new file mode 100644 index 00000000..9253f82d --- /dev/null +++ b/src-tauri/src/mcp/trust.rs @@ -0,0 +1,267 @@ +//! Host-owned launch grants. Configuration files cannot authorize their own execution. +//! Grants last for this process; probes use bounded, single-use confirmations. + +use super::{McpError, McpServerConfig, PoolKey}; +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +pub fn launch_version(cfg: &McpServerConfig) -> String { + fn canonical(value: Value) -> Value { + match value { + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (key, canonical(value))) + .collect::>() + .into_iter() + .collect(), + ), + Value::Array(items) => Value::Array(items.into_iter().map(canonical).collect()), + value => value, + } + } + let value = canonical(serde_json::to_value(cfg).expect("MCP configuration is JSON")); + super::scope_version(&value.to_string()) +} + +pub(super) fn untrusted() -> McpError { + McpError::config_hint( + "MCP launch requires approval for this source and configuration version", + "Review this server in Settings → MCP and approve its launch. Grants expire when CodeWave exits.", + ) +} + +#[derive(Clone)] +pub(super) struct LaunchPermit { + key: PoolKey, + version: String, + pub cancel: CancellationToken, +} + +impl LaunchPermit { + fn new(key: &PoolKey, cfg: &McpServerConfig) -> Self { + Self { + key: key.clone(), + version: launch_version(cfg), + cancel: CancellationToken::new(), + } + } + + pub fn check(&self, key: &PoolKey, cfg: &McpServerConfig) -> Result<(), McpError> { + if self.key != *key || self.version != launch_version(cfg) || self.cancel.is_cancelled() { + return Err(untrusted()); + } + Ok(()) + } +} + +#[derive(Serialize)] +pub struct LaunchStatus { + pub version: String, + pub trusted: bool, +} + +#[derive(Serialize)] +pub struct ProbeChallenge { + pub id: String, + pub version: String, +} + +struct Probe { + permit: LaunchPermit, + issued: Instant, +} + +#[derive(Default)] +struct Inner { + grants: HashMap, + probes: HashMap, + active_probes: HashMap, +} + +#[derive(Default)] +pub(super) struct LaunchRegistry(Mutex); + +pub(super) struct ProbeLease<'a> { + pub permit: LaunchPermit, + registry: &'a LaunchRegistry, + id: String, +} + +impl Drop for ProbeLease<'_> { + fn drop(&mut self) { + if let Some(permit) = self + .registry + .0 + .lock() + .unwrap() + .active_probes + .remove(&self.id) + { + permit.cancel.cancel(); + } + } +} + +impl LaunchRegistry { + pub fn status(&self, key: &PoolKey, cfg: &McpServerConfig) -> LaunchStatus { + LaunchStatus { + version: launch_version(cfg), + trusted: self.connection(key, cfg).is_ok(), + } + } + + pub fn connection( + &self, + key: &PoolKey, + cfg: &McpServerConfig, + ) -> Result { + let inner = self.0.lock().unwrap(); + let permit = inner.grants.get(key).ok_or_else(untrusted)?; + permit.check(key, cfg)?; + Ok(permit.clone()) + } + + pub fn grant( + &self, + key: &PoolKey, + cfg: &McpServerConfig, + expected: &str, + ) -> Result<(), McpError> { + if launch_version(cfg) != expected { + return Err(untrusted()); + } + let mut inner = self.0.lock().unwrap(); + if inner + .grants + .get(key) + .is_some_and(|permit| permit.check(key, cfg).is_ok()) + { + return Ok(()); + } + if let Some(old) = inner + .grants + .insert(key.clone(), LaunchPermit::new(key, cfg)) + { + old.cancel.cancel(); + } + Ok(()) + } + + pub fn revoke(&self, key: &PoolKey) { + let mut inner = self.0.lock().unwrap(); + if let Some(permit) = inner.grants.remove(key) { + permit.cancel.cancel(); + } + inner.probes.retain(|_, probe| { + if probe.permit.key != *key { + return true; + } + probe.permit.cancel.cancel(); + false + }); + for permit in inner + .active_probes + .values() + .filter(|permit| permit.key == *key) + { + permit.cancel.cancel(); + } + } + + pub fn invalidate_source( + &self, + source: &std::path::Path, + servers: &[(String, McpServerConfig)], + ) { + let inner = self.0.lock().unwrap(); + let keys: Vec<_> = inner + .grants + .values() + .chain(inner.probes.values().map(|probe| &probe.permit)) + .chain(inner.active_probes.values()) + .filter(|permit| { + permit.key.source == source + && !servers.iter().any(|(name, cfg)| { + *name == permit.key.name + && cfg.enabled + && permit.check(&permit.key, cfg).is_ok() + }) + }) + .map(|permit| permit.key.clone()) + .collect(); + drop(inner); + for key in keys { + self.revoke(&key); + } + } + + /// A user-approved policy-only patch keeps the original launch identity. + pub fn rebind_policy(&self, key: &PoolKey, before: &McpServerConfig, after: &McpServerConfig) { + let mut allowed = before.clone(); + allowed.always_allow = after.always_allow; + if allowed != *after { + return; + } + let mut inner = self.0.lock().unwrap(); + if let Some(permit) = inner.grants.get_mut(key) + && permit.check(key, before).is_ok() + { + permit.version = launch_version(after); + } + } + + pub fn prepare_probe( + &self, + key: &PoolKey, + cfg: &McpServerConfig, + ) -> Result { + let mut inner = self.0.lock().unwrap(); + inner + .probes + .retain(|_, probe| probe.issued.elapsed() < Duration::from_secs(300)); + if inner.probes.len() + inner.active_probes.len() >= 64 { + return Err(McpError::config("Too many pending or active MCP probes")); + } + let permit = LaunchPermit::new(key, cfg); + let result = ProbeChallenge { + id: uuid::Uuid::new_v4().to_string(), + version: permit.version.clone(), + }; + inner.probes.insert( + result.id.clone(), + Probe { + permit, + issued: Instant::now(), + }, + ); + Ok(result) + } + + pub fn confirm_probe( + &self, + id: &str, + key: &PoolKey, + cfg: &McpServerConfig, + ) -> Result, McpError> { + let mut inner = self.0.lock().unwrap(); + let probe = inner.probes.remove(id).ok_or_else(untrusted)?; + if probe.issued.elapsed() >= Duration::from_secs(300) { + return Err(untrusted()); + } + probe.permit.check(key, cfg)?; + inner.active_probes.insert(id.into(), probe.permit.clone()); + Ok(ProbeLease { + permit: probe.permit, + registry: self, + id: id.into(), + }) + } +} + +#[cfg(test)] +#[path = "trust_tests.rs"] +mod tests; diff --git a/src-tauri/src/mcp/trust_integration_tests.rs b/src-tauri/src/mcp/trust_integration_tests.rs new file mode 100644 index 00000000..fca8e68d --- /dev/null +++ b/src-tauri/src/mcp/trust_integration_tests.rs @@ -0,0 +1,317 @@ +use super::tests::{node_cfg, wait_ready}; +use super::*; +use serde_json::json; +use std::path::Path; + +fn marked_config(path: &Path) -> McpServerConfig { + let mut cfg = node_cfg(); + cfg.env + .insert("CODEWAVE_TEST_PID_FILE".into(), path.display().to_string()); + cfg +} + +async fn wait_stopped(manager: &McpManager, session: &str) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let statuses = manager.status_for(session).await; + if !statuses.is_empty() + && statuses + .iter() + .all(|s| s.state == McpState::Stopped && s.pid.is_none() && s.tools == 0) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("unapproved connection did not settle without launching"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn every_entry_requires_launch_approval_and_probe_does_not_grant_it() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let cfg = marked_config(&pid_file); + let key = PoolKey::project("trust-entry", "fixture").with_source(&dir.path().join("mcp.json")); + let manager = Arc::new(McpManager::default()); + let http = reqwest::Client::new(); + let visible = vec![(key.clone(), cfg.clone())]; + + manager.warm("s", visible.clone(), http.clone()).await; + wait_stopped(&manager, "s").await; + manager + .connect_named( + "s", + visible.clone(), + std::slice::from_ref(&key.name), + http.clone(), + ) + .await; + wait_stopped(&manager, "s").await; + // An altered configuration forces reconcile to queue connection work too. + let mut changed = cfg.clone(); + changed + .env + .insert("FIXTURE_VERSION".into(), "changed".into()); + manager + .reconcile( + vec![("s".into(), vec![(key.clone(), changed.clone())])], + http.clone(), + ) + .await; + wait_stopped(&manager, "s").await; + assert!( + manager + .ensure(&key, changed, http.clone(), "s") + .await + .is_err() + ); + assert!( + manager + .test_connection(&key, cfg.clone(), "", http.clone()) + .await + .is_err() + ); + assert!(manager.tool_snapshot_for("s").await.is_empty()); + assert!( + !pid_file.exists(), + "an unapproved entry point launched the server" + ); + + let challenge = manager.prepare_probe(&key, &cfg).unwrap(); + assert_eq!( + manager + .test_connection(&key, cfg.clone(), &challenge.id, http.clone()) + .await + .unwrap(), + 2 + ); + assert!( + pid_file.exists(), + "approved probe did not actually execute Node" + ); + assert!(!manager.launch_status(&key, &cfg).trusted); + assert!(manager.tool_snapshot_for("s").await.is_empty()); + assert!( + manager + .test_connection(&key, cfg.clone(), &challenge.id, http.clone()) + .await + .is_err() + ); + std::fs::remove_file(&pid_file).unwrap(); + manager.warm("s", visible.clone(), http.clone()).await; + wait_stopped(&manager, "s").await; + assert!( + !pid_file.exists(), + "probe approval leaked into formal launch rights" + ); + + assert!( + manager + .set_launch_trust(&key, &cfg, "stale-version", true) + .await + .is_err() + ); + assert!(!pid_file.exists()); + manager + .set_launch_trust(&key, &cfg, &super::super::launch_version(&cfg), true) + .await + .unwrap(); + manager.warm("s", visible, http).await; + wait_ready(&manager, "s", 1).await; + let tools = manager.tool_snapshot_for("s").await; + assert_eq!(tools.len(), 2); + let echo = tools + .iter() + .find(|tool| tool.function_name.ends_with("__echo")) + .expect("real server echo tool"); + let binding = manager + .request_binding("s", &echo.function_name) + .await + .unwrap(); + let result = manager + .call_bound( + "s", + &binding, + json!({"text": "approved"}), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.text, "echo: approved"); + manager.disconnect_all().await; +} + +#[cfg(unix)] +async fn wait_pid_file(path: &Path) -> String { + tokio::time::timeout(Duration::from_secs(7), async { + loop { + if let Ok(pid) = std::fs::read_to_string(path) + && pid.trim().parse::().is_ok() + { + break pid.trim().to_owned(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("real Node process never wrote its PID") +} + +#[cfg(unix)] +async fn wait_process_gone(pid: &str) { + tokio::time::timeout(Duration::from_secs(7), async { + loop { + let status = tokio::time::timeout( + Duration::from_secs(1), + tokio::process::Command::new("kill") + .args(["-0", pid]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(), + ) + .await + .expect("PID check timed out") + .expect("kill -0 unavailable"); + if !status.success() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("revoked server process remained alive"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revocation_removes_tools_rejects_old_binding_and_prevents_relaunch() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let calls = dir.path().join("calls"); + let mut cfg = marked_config(&pid_file); + cfg.env + .insert("CODEWAVE_TEST_CALL_LOG".into(), calls.display().to_string()); + let key = PoolKey::global("trust-revoke").with_source(&dir.path().join("mcp.json")); + let version = super::super::launch_version(&cfg); + let manager = Arc::new(McpManager::default()); + manager + .set_launch_trust(&key, &cfg, &version, true) + .await + .unwrap(); + manager + .warm( + "s", + vec![(key.clone(), cfg.clone())], + reqwest::Client::new(), + ) + .await; + wait_ready(&manager, "s", 1).await; + let original_pid = std::fs::read_to_string(&pid_file).unwrap(); + let tools = manager.tool_snapshot_for("s").await; + let echo = tools + .iter() + .find(|tool| tool.function_name.ends_with("__echo")) + .unwrap(); + let binding = manager + .request_binding("s", &echo.function_name) + .await + .unwrap(); + manager + .set_launch_trust(&key, &cfg, &version, false) + .await + .unwrap(); + assert!(manager.tool_snapshot_for("s").await.is_empty()); + assert!( + manager + .call_bound( + "s", + &binding, + json!({"text": "must-not-run"}), + &CancellationToken::new() + ) + .await + .is_err() + ); + assert!(!calls.exists(), "stale dispatch reached the server"); + #[cfg(unix)] + wait_process_gone(original_pid.trim()).await; + // Windows still exercises revoke/no-relaunch; this is not a process-reaping assertion there. + std::fs::remove_file(&pid_file).unwrap(); + manager + .warm( + "s", + vec![(key.clone(), cfg.clone())], + reqwest::Client::new(), + ) + .await; + wait_stopped(&manager, "s").await; + assert!(!pid_file.exists()); + assert!(!manager.launch_status(&key, &cfg).trusted); + #[cfg(not(unix))] + let _ = original_pid; + manager.disconnect_all().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revocation_interrupts_formal_and_probe_initialization_without_late_ready() { + for probe in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let mut cfg = marked_config(&pid_file); + cfg.env + .insert("CODEWAVE_TEST_INIT_DELAY_MS".into(), "30000".into()); + let key = PoolKey::global("trust-initializing").with_source(&dir.path().join("mcp.json")); + let version = super::super::launch_version(&cfg); + let manager = Arc::new(McpManager::default()); + let task = if probe { + let challenge = manager.prepare_probe(&key, &cfg).unwrap(); + let manager = manager.clone(); + let key = key.clone(); + let cfg = cfg.clone(); + tokio::spawn(async move { + manager + .test_connection(&key, cfg, &challenge.id, reqwest::Client::new()) + .await + }) + } else { + manager + .set_launch_trust(&key, &cfg, &version, true) + .await + .unwrap(); + manager + .register_session("s", &[(key.clone(), cfg.clone())]) + .await; + let manager = manager.clone(); + let key = key.clone(); + let cfg = cfg.clone(); + tokio::spawn( + async move { manager.ensure(&key, cfg, reqwest::Client::new(), "s").await }, + ) + }; + let pid = wait_pid_file(&pid_file).await; + manager + .set_launch_trust(&key, &cfg, &version, false) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_secs(3), task) + .await + .expect("revocation did not interrupt initialization") + .unwrap() + .is_err() + ); + wait_process_gone(&pid).await; + assert!(manager.tool_snapshot_for("s").await.is_empty()); + assert!( + manager + .status_for("s") + .await + .iter() + .all(|status| status.state == McpState::Stopped && status.pid.is_none()) + ); + assert!(!manager.launch_status(&key, &cfg).trusted); + manager.disconnect_all().await; + } +} diff --git a/src-tauri/src/mcp/trust_tests.rs b/src-tauri/src/mcp/trust_tests.rs new file mode 100644 index 00000000..f84beeeb --- /dev/null +++ b/src-tauri/src/mcp/trust_tests.rs @@ -0,0 +1,260 @@ +use super::*; +use crate::mcp::Scope; +use serde_json::json; + +fn config() -> McpServerConfig { + serde_json::from_value(json!({"command": "fixture-server", "args": ["--fixture"]})).unwrap() +} + +fn key(name: &str) -> PoolKey { + PoolKey { + scope: Scope::Project, + project_id: Some("project-a".into()), + name: name.into(), + source: std::path::PathBuf::from("project-a/.codewave/mcp.json"), + } +} + +fn grant(registry: &LaunchRegistry, key: &PoolKey, cfg: &McpServerConfig) -> LaunchPermit { + registry.grant(key, cfg, &launch_version(cfg)).unwrap(); + registry.connection(key, cfg).unwrap() +} + +#[test] +fn grants_bind_source_project_name_scope_and_configuration_until_revoked() { + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let cfg = config(); + assert!(!registry.status(&key, &cfg).trusted); + assert!(registry.grant(&key, &cfg, "stale-version").is_err()); + assert!(!registry.status(&key, &cfg).trusted); + let permit = grant(®istry, &key, &cfg); + for foreign in [ + PoolKey { + source: "other/mcp.json".into(), + ..key.clone() + }, + PoolKey { + project_id: Some("project-b".into()), + ..key.clone() + }, + PoolKey { + name: "other".into(), + ..key.clone() + }, + PoolKey { + scope: Scope::Global, + project_id: None, + ..key.clone() + }, + ] { + assert!(registry.connection(&foreign, &cfg).is_err()); + assert!(permit.check(&foreign, &cfg).is_err()); + } + let mut changed = cfg.clone(); + changed.args.push("--different".into()); + assert!(registry.connection(&key, &changed).is_err()); + let replacement = grant(®istry, &key, &changed); + assert!(permit.cancel.is_cancelled()); + assert!(!replacement.cancel.is_cancelled()); + registry + .grant(&key, &changed, &launch_version(&changed)) + .unwrap(); + assert!( + !replacement.cancel.is_cancelled(), + "identical approval must not interrupt live work" + ); + registry.revoke(&key); + assert!(replacement.cancel.is_cancelled()); + assert!(registry.connection(&key, &changed).is_err()); + assert!(!LaunchRegistry::default().status(&key, &changed).trusted); +} + +#[test] +fn launch_identity_ignores_json_order_and_defaults_but_tracks_effective_changes() { + let first: McpServerConfig = serde_json::from_str( + r#"{ + "command":"fixture-server", "env":{"B":"two","A":"one"}, + "custom":{"z":[{"b":2,"a":1}],"a":true} + }"#, + ) + .unwrap(); + let reordered: McpServerConfig = serde_json::from_str( + r#"{ + "custom":{"a":true,"z":[{"a":1,"b":2}]}, + "enabled":true,"read_only":false,"always_allow":false,"args":[], + "env":{"A":"one","B":"two"},"command":"fixture-server" + }"#, + ) + .unwrap(); + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let permit = grant(®istry, &key, &first); + assert_eq!(launch_version(&first), launch_version(&reordered)); + assert!(registry.connection(&key, &reordered).is_ok()); + let mut changes = Vec::new(); + let mut changed = first.clone(); + changed.command = Some("other-server".into()); + changes.push(changed); + let mut changed = first.clone(); + changed.env.insert("A".into(), "changed".into()); + changes.push(changed); + let mut changed = first.clone(); + changed.extra.insert("custom".into(), json!({"a": false})); + changes.push(changed); + for changed in changes { + assert!(registry.connection(&key, &changed).is_err()); + assert!(permit.check(&key, &changed).is_err()); + } +} + +#[test] +fn probes_are_single_use_and_do_not_create_or_inherit_connection_grants() { + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let cfg = config(); + let challenge = registry.prepare_probe(&key, &cfg).unwrap(); + assert_eq!(challenge.version, launch_version(&cfg)); + assert!(!registry.status(&key, &cfg).trusted); + let lease = registry.confirm_probe(&challenge.id, &key, &cfg).unwrap(); + assert!(lease.permit.check(&key, &cfg).is_ok()); + assert!(registry.connection(&key, &cfg).is_err()); + assert!(registry.confirm_probe(&challenge.id, &key, &cfg).is_err()); + let live = lease.permit.clone(); + drop(lease); + assert!(live.cancel.is_cancelled()); + let formal = grant(®istry, &key, &cfg); + assert!(registry.confirm_probe("no-challenge", &key, &cfg).is_err()); + assert!(!formal.cancel.is_cancelled()); +} + +#[test] +fn mismatched_and_expired_probe_confirmations_are_consumed() { + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let cfg = config(); + let foreign = PoolKey { + project_id: Some("other-project".into()), + ..key.clone() + }; + let challenge = registry.prepare_probe(&key, &cfg).unwrap(); + assert!( + registry + .confirm_probe(&challenge.id, &foreign, &cfg) + .is_err() + ); + assert!(registry.confirm_probe(&challenge.id, &key, &cfg).is_err()); + let challenge = registry.prepare_probe(&key, &cfg).unwrap(); + let mut changed = cfg.clone(); + changed.args.push("--changed".into()); + assert!( + registry + .confirm_probe(&challenge.id, &key, &changed) + .is_err() + ); + assert!(registry.confirm_probe(&challenge.id, &key, &cfg).is_err()); + let challenge = registry.prepare_probe(&key, &cfg).unwrap(); + registry + .0 + .lock() + .unwrap() + .probes + .get_mut(&challenge.id) + .unwrap() + .issued = Instant::now() - Duration::from_secs(301); + assert!(registry.confirm_probe(&challenge.id, &key, &cfg).is_err()); + assert!(registry.confirm_probe(&challenge.id, &key, &cfg).is_err()); +} + +#[test] +fn pending_and_active_probe_capacity_recovers_after_expiry_or_completion() { + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let cfg = config(); + let pending: Vec<_> = (0..64) + .map(|_| registry.prepare_probe(&key, &cfg).unwrap()) + .collect(); + assert!(registry.prepare_probe(&key, &cfg).is_err()); + let active = registry.confirm_probe(&pending[0].id, &key, &cfg).unwrap(); + assert!(registry.prepare_probe(&key, &cfg).is_err()); + registry + .0 + .lock() + .unwrap() + .probes + .get_mut(&pending[1].id) + .unwrap() + .issued = Instant::now() - Duration::from_secs(301); + let new = registry.prepare_probe(&key, &cfg).unwrap(); + assert!(registry.confirm_probe(&pending[1].id, &key, &cfg).is_err()); + assert!(registry.confirm_probe(&new.id, &key, &cfg).is_ok()); + drop(active); + assert!(registry.prepare_probe(&key, &cfg).is_ok()); +} + +#[test] +fn revoke_cancels_active_and_pending_probes_without_touching_other_entries() { + let registry = LaunchRegistry::default(); + let key = key("fixture"); + let other = PoolKey { + name: "other".into(), + ..key.clone() + }; + let cfg = config(); + let formal = grant(®istry, &key, &cfg); + let unrelated = grant(®istry, &other, &cfg); + let pending = registry.prepare_probe(&key, &cfg).unwrap(); + let challenge = registry.prepare_probe(&key, &cfg).unwrap(); + let lease = registry.confirm_probe(&challenge.id, &key, &cfg).unwrap(); + let other_challenge = registry.prepare_probe(&other, &cfg).unwrap(); + registry.revoke(&key); + assert!(formal.cancel.is_cancelled()); + assert!(lease.permit.cancel.is_cancelled()); + assert!(registry.confirm_probe(&pending.id, &key, &cfg).is_err()); + assert!( + registry + .confirm_probe(&other_challenge.id, &other, &cfg) + .is_ok() + ); + assert!(!unrelated.cancel.is_cancelled()); + assert!(registry.connection(&other, &cfg).is_ok()); +} + +#[test] +fn source_refresh_invalidates_changes_removals_and_disables_but_preserves_other_grants() { + let registry = LaunchRegistry::default(); + let cfg = config(); + let keys = [key("changed"), key("removed"), key("disabled"), key("kept")]; + let permits: Vec<_> = keys.iter().map(|key| grant(®istry, key, &cfg)).collect(); + let other_source = PoolKey { + source: "project-b/.codewave/mcp.json".into(), + ..keys[0].clone() + }; + let unrelated = grant(®istry, &other_source, &cfg); + let pending = registry.prepare_probe(&keys[1], &cfg).unwrap(); + let challenge = registry.prepare_probe(&keys[0], &cfg).unwrap(); + let active = registry + .confirm_probe(&challenge.id, &keys[0], &cfg) + .unwrap(); + let mut changed = cfg.clone(); + changed.command = Some("replacement".into()); + let mut disabled = cfg.clone(); + disabled.enabled = false; + registry.invalidate_source( + &keys[0].source, + &[ + ("changed".into(), changed), + ("disabled".into(), disabled), + ("kept".into(), cfg.clone()), + ], + ); + for permit in &permits[..3] { + assert!(permit.cancel.is_cancelled()); + } + assert!(active.permit.cancel.is_cancelled()); + assert!(registry.confirm_probe(&pending.id, &keys[1], &cfg).is_err()); + assert!(!permits[3].cancel.is_cancelled()); + assert!(registry.connection(&keys[3], &cfg).is_ok()); + assert!(!unrelated.cancel.is_cancelled()); + assert!(registry.connection(&other_source, &cfg).is_ok()); +} diff --git a/src-tauri/src/tools/http_request.rs b/src-tauri/src/tools/http_request.rs index 7c3fbaad..1147b24d 100644 --- a/src-tauri/src/tools/http_request.rs +++ b/src-tauri/src/tools/http_request.rs @@ -257,6 +257,9 @@ mod tests { Err(error) => panic!("HTTP fixture accept failed: {error}"), } }; + // macOS accept can inherit O_NONBLOCK from the polling listener. + // The request reader below uses blocking I/O and socket timeouts. + stream.set_nonblocking(false).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(5))) .unwrap(); @@ -295,13 +298,12 @@ mod tests { return String::from_utf8_lossy(&buf).into_owned(); } } - let n = match stream.read(&mut tmp) { - Ok(0) | Err(_) => break, - Ok(n) => n, - }; + let n = stream + .read(&mut tmp) + .expect("HTTP fixture request read failed"); + assert_ne!(n, 0, "HTTP fixture received an incomplete request"); buf.extend_from_slice(&tmp[..n]); } - String::from_utf8_lossy(&buf).into_owned() } fn find(haystack: &[u8], needle: &[u8]) -> Option { @@ -572,7 +574,7 @@ mod tests { assert!(out.ok, "{out:?}"); let reqs = received.lock().unwrap(); assert_eq!(reqs.len(), 2, "redirect must be followed hop by hop"); - assert!(reqs[0].starts_with("GET /start")); + assert!(reqs[0].starts_with("GET /start"), "raw requests: {reqs:?}"); assert!( reqs[1].starts_with("GET /final?x=1"), "Location must be joined onto the base URL" diff --git a/ui/src/__tests__/settings.mcp.test.tsx b/ui/src/__tests__/settings.mcp.test.tsx index 4d749802..7803779a 100644 --- a/ui/src/__tests__/settings.mcp.test.tsx +++ b/ui/src/__tests__/settings.mcp.test.tsx @@ -9,7 +9,7 @@ import { configSnapshot, configSaved } from "./configFixtures"; // 挂载方式与 settings.skills.test.tsx 同源(standalone + useUi 控制开关)。 import { describe, it, expect, vi, afterEach } from "vitest"; import { render, fireEvent, waitFor, cleanup, act } from "@testing-library/react"; -import { App as AntApp } from "antd"; // 必须与组件同源(主入口):es/app 子路径会产生另一个 context +import { App as AntApp, ConfigProvider } from "antd"; // 必须与组件同源(主入口):es/app 子路径会产生另一个 context import "../i18n"; import SettingsPage from "../features/panels/SettingsPage"; import { useUi } from "../stores/ui"; @@ -51,6 +51,7 @@ let calls: string[] = []; /** mcp_status 的返回值(每个用例自行设置;元素形态与 ipc/client.ts 的 mcpStatus 一致) */ let statusReply: { name: string; state: unknown; tools: number; pid?: number | null; scope?: "global" | "project"; tool_details?: { name: string; description: string }[] }[] = []; let effectiveReply: Array> = []; +let launchReply: McpServerView[] = []; /** mcp_list_config 的 json 字段(默认两个服务器;用例可改成 "{}" / 非法文本测空态与兜底模式) */ let configReply = MCP_CONFIG; let saveAllowed = true; @@ -75,11 +76,15 @@ async function baseInvoke(cmd: string, args?: any) { path: args?.scope === "project" ? "C:/proj/.codewave/mcp.json" : "C:/u/.codewave/mcp.json", json: configReply, version: "mcp-v1", - servers: [], + servers: launchReply, effective: effectiveReply, issues: [], }; case "mcp_save_config": return { saved: saveAllowed, issues: [], version: "mcp-v2", apply_revision: 2, affected_servers: ["global:fs"] }; + case "mcp_prepare_test": return { id: `probe-${calls.filter((call) => call.startsWith("mcp_prepare_test:")).length}`, version: "draft-v1" }; + case "mcp_set_launch_trust": + launchReply = launchReply.map((server) => server.name === args.name ? { ...server, launch: { ...server.launch, trusted: args.trusted } } : server); + return { version: args.expectedVersion, trusted: args.trusted }; case "mcp_test": return testReply; case "mcp_snapshot": return { session: "s1", servers: JSON.parse(JSON.stringify(statusReply.map((s) => ({ scope: "global", ...s })))) }; @@ -109,6 +114,7 @@ afterEach(async () => { projectLoadFails = false; statusReply = []; effectiveReply = []; + launchReply = []; configReply = MCP_CONFIG; testReply = { ok: true, tools: 2, error: null }; useSessions.setState({ activeKey: null }); @@ -150,6 +156,19 @@ function buttonByText(text: string): HTMLElement { return btn as HTMLElement; } +async function confirmMcpDialog(text = "运行并测试") { + let button: HTMLElement | undefined; + await waitFor(() => { + // antd retains a closing portal briefly; confirm the newest dialog. + button = Array.from(document.querySelectorAll(".ant-modal-confirm button")) + .filter((item) => item.textContent?.replace(/\s/g, "") === text).at(-1); + expect(button).toBeTruthy(); + }); + const dialog = button!.closest(".ant-modal-confirm")!; + await act(async () => { fireEvent.click(button!); }); + await waitFor(() => expect(dialog.isConnected).toBe(false)); +} + async function openMcpTab(edit = false, editIndex = 0) { // 状态是**会话级**的(连接池按会话可见集 keyed):没有活跃会话就没有可读的连接状态, // 故这里先立一个活跃会话,再打开 MCP 页。 @@ -157,9 +176,10 @@ async function openMcpTab(edit = false, editIndex = 0) { useSettings.setState({ config: makeConfig(), snapshot: configSnapshot(makeConfig()), loaded: true }); useUi.setState({ settingsOpen: true, settingsTab: "mcp" }); render( - + // happy-dom does not finish CSS animations; keep protocol assertions independent of motion. + - , + , ); await waitFor(() => expect(document.querySelector('[data-setting-id="app.mcp_status"]')).toBeTruthy()); if (edit && document.querySelector('.mcp-server-card')) { @@ -626,6 +646,7 @@ describe("设置页 MCP 页:作用域切换与临时测试连接", () => { await openMcpTab(true); const beforeConnect = calls.filter((c) => c.startsWith("mcp_connect")).length; fireEvent.click(buttonByText("测试")); + await confirmMcpDialog(); await waitFor(() => expect(calls.some((c) => c.startsWith("mcp_test"))).toBe(true)); const call = calls.find((c) => c.startsWith("mcp_test"))!; @@ -645,6 +666,7 @@ describe("设置页 MCP 页:作用域切换与临时测试连接", () => { testReply = { ok: false, tools: 0, error: { message: "握手超时(30s)" } }; await openMcpTab(true); fireEvent.click(buttonByText("测试")); + await confirmMcpDialog(); await waitFor(() => expect(document.body.textContent).toContain("临时测试失败:握手超时(30s)"), ); @@ -820,17 +842,80 @@ describe("设置页 MCP 页:args / env / headers 表格", () => { describe("current draft probe identity and save receipts", () => { + it("requires explicit confirmation, and editing or cancelling a prepared draft never launches it", async () => { + await openMcpTab(true); + fireEvent.click(buttonByText("测试")); + await waitFor(() => expect(calls.some((call) => call.startsWith("mcp_prepare_test:"))).toBe(true)); + expect(calls.some((call) => call.startsWith("mcp_test:"))).toBe(false); + await confirmMcpDialog("取消"); + expect(calls.some((call) => call.startsWith("mcp_test:"))).toBe(false); + fireEvent.click(buttonByText("测试")); + await waitFor(() => expect(calls.filter((call) => call.startsWith("mcp_prepare_test:"))).toHaveLength(2)); + fireEvent.change(document.querySelector('input[placeholder="npx -y @modelcontextprotocol/server-fs"]')!, { target: { value: "changed-before-confirmation" } }); + await waitFor(() => expect(document.querySelector(".ant-modal-confirm" )).toBeNull()); + expect(calls.some((call) => call.startsWith("mcp_test:"))).toBe(false); + }); + + it("approves and revokes the displayed saved source/version without letting configuration self-authorize", async () => { + launchReply = [{ + name: "fs", source: "global", source_path: "C:/u/.codewave/mcp.json", overridden: null, + transport: "stdio", transport_inferred: true, enabled: true, read_only: true, always_allow: true, + command: "npx", args: ["actual-server", "--token", "option-secret"], env: { API_KEY: "env-secret" }, cwd: "C:/actual/cwd", url: null, headers: {}, timeout_ms: null, + tools: { mode: "all", list: [] }, extra_keys: [], launch: { version: "launch-v1", trusted: false }, + }]; + await openMcpTab(); + await waitFor(() => expect(buttonByText("批准启动")).toBeTruthy()); + expect(calls.some((call) => call.startsWith("mcp_set_launch_trust:"))).toBe(false); + fireEvent.click(buttonByText("批准启动")); + await waitFor(() => expect(document.querySelector(".ant-modal-confirm")?.textContent).toContain("actual-server")); + const summary = document.querySelector(".ant-modal-confirm")!.textContent!; + expect(summary).toContain("C:/actual/cwd"); + expect(summary).toContain("API_KEY"); + expect(summary).not.toContain("env-secret"); + expect(summary).not.toContain("option-secret"); + await confirmMcpDialog("取消"); + expect(calls.some((call) => call.startsWith("mcp_set_launch_trust:"))).toBe(false); + fireEvent.click(buttonByText("批准启动")); + await confirmMcpDialog("批准启动"); + await waitFor(() => expect(calls.filter((call) => call.startsWith("mcp_set_launch_trust:"))).toHaveLength(1)); + expect(JSON.parse(calls.find((call) => call.startsWith("mcp_set_launch_trust:"))!.split("mcp_set_launch_trust:")[1])).toEqual({ scope: "global", name: "fs", expectedVersion: "launch-v1", trusted: true, sessionId: "s1" }); + await waitFor(() => expect(buttonByText("撤销启动授权")).toBeTruthy()); + fireEvent.click(buttonByText("撤销启动授权")); + await confirmMcpDialog("撤销启动授权"); + await waitFor(() => expect(calls.filter((call) => call.startsWith("mcp_set_launch_trust:"))).toHaveLength(2)); + expect(JSON.parse(calls.filter((call) => call.startsWith("mcp_set_launch_trust:"))[1].split("mcp_set_launch_trust:")[1]).trusted).toBe(false); + expect(calls.some((call) => call.startsWith("mcp_connect:"))).toBe(false); + }); + it("tests an unsaved renamed draft and sends the exact edited command", async () => { await openMcpTab(true); fireEvent.change(document.querySelector(".mcp-entry input")!, { target: { value: "new draft" } }); fireEvent.change(document.querySelector('input[placeholder="npx -y @modelcontextprotocol/server-fs"]')!, { target: { value: "missing-command" } }); fireEvent.click(buttonByText("测试")); + await confirmMcpDialog(); await waitFor(() => expect(calls.some((call) => call.startsWith("mcp_test:"))).toBe(true)); const payload = JSON.parse(calls.find((call) => call.startsWith("mcp_test:"))!.slice("mcp_test:".length)); - expect(payload).toEqual({ scope: "global", name: "new draft", sessionId: "s1", config: { command: "missing-command", args: ["-y", "@modelcontextprotocol/server-fs"] } }); + expect(payload).toEqual({ scope: "global", name: "new draft", sessionId: "s1", confirmation: "probe-1", config: { command: "missing-command", args: ["-y", "@modelcontextprotocol/server-fs"] } }); expect(calls.some((call) => call.startsWith("mcp_save_config:"))).toBe(false); }); + it("shows the HTTP draft target and header names without echoing URL or header credentials", async () => { + configReply = JSON.stringify({ mcpServers: { web: { + url: "https://user:url-secret@example.com/mcp?token=query-secret#fragment-secret", + headers: { Authorization: "Bearer header-secret" }, + } } }); + await openMcpTab(true); + fireEvent.click(buttonByText("测试")); + await waitFor(() => expect(document.querySelector(".ant-modal-confirm")?.textContent).toContain("example.com/mcp")); + const summary = document.querySelector(".ant-modal-confirm")!.textContent!; + expect(summary).toContain("streamable_http"); + expect(summary).toContain("Authorization"); + for (const secret of ["url-secret", "query-secret", "fragment-secret", "header-secret"]) expect(summary).not.toContain(secret); + await confirmMcpDialog(); + const request = calls.find((call) => call.startsWith("mcp_test:"))!; + expect(JSON.parse(request.slice("mcp_test:".length)).config.headers.Authorization).toBe("Bearer header-secret"); + }); + it("single-flights a draft, drops a late result after editing, and accepts only the newer draft", async () => { const pending: Array<(value: typeof testReply) => void> = []; (await invokeMock()).mockImplementation((cmd: string, args?: any) => { @@ -840,10 +925,12 @@ describe("current draft probe identity and save receipts", () => { await openMcpTab(true); fireEvent.click(buttonByText("测试")); fireEvent.click(buttonByText("测试")); - expect(pending).toHaveLength(1); + await confirmMcpDialog(); + await waitFor(() => expect(pending).toHaveLength(1)); fireEvent.change(document.querySelector('input[placeholder="npx -y @modelcontextprotocol/server-fs"]')!, { target: { value: "newer-command" } }); fireEvent.click(buttonByText("测试")); - expect(pending).toHaveLength(2); + await confirmMcpDialog(); + await waitFor(() => expect(pending).toHaveLength(2)); await act(async () => pending[1]({ ok: true, tools: 7, error: null })); expect(document.body.textContent).toContain("发现 7 个工具"); await act(async () => pending[0]({ ok: true, tools: 99, error: null })); @@ -859,6 +946,8 @@ describe("current draft probe identity and save receipts", () => { ? new Promise((resolve) => { settle = resolve; }) : baseInvoke(cmd, args)); await openMcpTab(true); fireEvent.click(buttonByText("测试")); + await confirmMcpDialog(); + await waitFor(() => expect(settle).toBeTypeOf("function")); fireEvent.click(document.querySelector(".settings-mcp-editor-modal .ant-modal-close")!); await waitFor(() => expect(document.querySelector(".mcp-entry")).toBeNull()); fireEvent.click(Array.from(document.querySelectorAll(".mcp-server-card button")).find((button) => button.textContent?.replace(/\s/g, "") === "编辑配置")!); diff --git a/ui/src/features/panels/McpLaunchControl.tsx b/ui/src/features/panels/McpLaunchControl.tsx new file mode 100644 index 00000000..45629bda --- /dev/null +++ b/ui/src/features/panels/McpLaunchControl.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from "react"; +import { App, Button, Descriptions, Space, Tooltip, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import { ipc } from "../../ipc/client"; +import type { McpLaunchStatus, McpScope, McpServerView } from "../../ipc/types"; +import { isSensitiveMcpKey } from "./McpKvTable"; + +type LaunchConfig = Partial>; + +/** Redact URL credentials/query values and recognized sensitive options in the launch summary. */ +function endpoint(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + for (const key of new Set(url.searchParams.keys())) url.searchParams.set(key, "••••"); + url.hash = ""; + return url.toString(); + } catch { return "—"; } +} + +function LaunchDetails({ config }: { config: LaunchConfig }) { + const { t } = useTranslation(); + let hideNext = false; + const args = (config.args ?? []).map((arg) => { + if (hideNext) { hideNext = false; return "••••"; } + const [key] = arg.split("=", 1); + if (isSensitiveMcpKey(key) && (arg.startsWith("-") || arg.includes("="))) { + if (arg.includes("=")) return `${key}=••••`; + hideNext = true; + return arg; + } + return arg.replace(/https?:\/\/[^\s"'<>]+/g, endpoint); + }); + const stdio = config.transport === "stdio" || (!config.transport && !!config.command); + return {config.command} }, + { key: "args", label: t("settings.mcpArgs"), children: {JSON.stringify(args)} }, + { key: "cwd", label: "cwd", children: config.cwd || t("settings.mcpLaunchDefaultCwd") }, + { key: "env", label: t("settings.mcpEnv"), children: Object.keys(config.env ?? {}).sort().join(", ") || "—" }, + ] : [ + { key: "url", label: t("settings.mcpUrl"), children: {endpoint(config.url ?? "")} }, + { key: "headers", label: t("settings.mcpHeaders"), children: Object.keys(config.headers ?? {}).sort().join(", ") || "—" }, + ]), + ]} />; +} + +/** Closing/changing the owner also closes its outstanding confirmation. */ +export function useMcpLaunchConfirmation(owner: string | number) { + const { modal } = App.useApp(); + const { t } = useTranslation(); + const pending = useRef(new Map<() => void, string | number>()); + const currentOwner = useRef(owner); + currentOwner.current = owner; + useEffect(() => () => { + for (const [close, identity] of pending.current) if (identity === owner) close(); + }, [owner]); + return (kind: "launch" | "probe" | "revoke", name: string, source: string, version: string, config: LaunchConfig) => + new Promise((resolve) => { + if (currentOwner.current !== owner) { resolve(false); return; } + let settled = false; + const settle = (answer: boolean) => { + if (settled) return; + settled = true; + pending.current.delete(close); + resolve(answer && currentOwner.current === owner); + }; + const close = () => { settle(false); dialog.destroy(); }; + const dialog = modal.confirm({ + title: kind === "probe" ? t("settings.mcpConfirmProbe") : kind === "revoke" ? t("settings.mcpConfirmRevoke") : t("settings.mcpConfirmLaunch"), + content: + {kind === "revoke" ? t("settings.mcpLaunchRevokeHint") : kind === "probe" ? t("settings.mcpProbeLaunchHint") : t("settings.mcpLaunchLifetime")} + {name} + {source} + + {t("settings.mcpLaunchVersion", { version: version.slice(0, 12) })} + , + okText: t(kind === "revoke" ? "settings.mcpRevokeLaunch" : kind === "probe" ? "settings.mcpRunProbe" : "settings.mcpApproveLaunch"), + cancelText: t("common.cancel"), + onOk: () => settle(true), + onCancel: () => settle(false), + afterClose: () => settle(false), + }); + pending.current.set(close, owner); + }); +} + +export default function McpLaunchControl({ name, scope, sessionId, source, launch, config, disabled, onChanged }: { + name: string; + scope: McpScope; + sessionId: string | null; + source: string; + launch: McpLaunchStatus; + config: LaunchConfig; + disabled?: boolean; + onChanged: () => Promise; +}) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const owner = `${sessionId ?? ""}\u0000${scope}\u0000${source}\u0000${name}\u0000${launch.version}\u0000${disabled}`; + const confirm = useMcpLaunchConfirmation(owner); + const identity = useRef(owner); + identity.current = owner; + const mounted = useRef(true); + const pending = useRef(false); + const [busy, setBusy] = useState(false); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + async function changeTrust() { + if (pending.current || disabled) return; + pending.current = true; + setBusy(true); + const requestOwner = owner; + try { + if (!await confirm(launch.trusted ? "revoke" : "launch", name, source, launch.version, config)) return; + if (!mounted.current || identity.current !== requestOwner) return; + await ipc.mcpSetLaunchTrust(scope, name, launch.version, !launch.trusted, sessionId ?? undefined); + if (mounted.current && identity.current === requestOwner) await onChanged(); + } catch (error) { + if (mounted.current && identity.current === requestOwner) message.error(String(error)); + } finally { + pending.current = false; + if (mounted.current) setBusy(false); + } + } + return + + ; +} diff --git a/ui/src/features/panels/McpStatusTable.tsx b/ui/src/features/panels/McpStatusTable.tsx index e57bdb76..da21645a 100644 --- a/ui/src/features/panels/McpStatusTable.tsx +++ b/ui/src/features/panels/McpStatusTable.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { Button, Card, Empty, Spin, Tag, Tooltip, Typography } from "antd"; import { PlusOutlined, QuestionCircleOutlined, ReloadOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; -import type { McpScope, McpStatusPayload } from "../../ipc/types"; +import type { McpScope, McpStatusPayload, McpServerView } from "../../ipc/types"; +import McpLaunchControl from "./McpLaunchControl"; import { SettingsSection } from "./settings/SettingsTheme"; export type McpStatusKind = "ready" | "starting" | "error" | "config_error" | "evicted" | "disconnected"; @@ -61,6 +62,10 @@ interface Props { hasSession: boolean; onDisconnect?: (name: string) => void; onReconnect?: (name: string) => void; + launchServers?: McpServerView[]; + sessionId?: string | null; + launchDisabled?: boolean; + onLaunchChanged?: () => Promise; } /** Show a question-mark tooltip only when the inline description is actually clipped. */ @@ -85,7 +90,7 @@ function ToolDescription({ name, description }: McpToolSummary) { ; } -export default function McpStatusTable({ rows, refreshing, pendingAction, onRefresh, onCreate, rawConfig, onEdit, editableNames, hasSession, onDisconnect, onReconnect }: Props) { +export default function McpStatusTable({ rows, refreshing, pendingAction, onRefresh, onCreate, rawConfig, onEdit, editableNames, hasSession, onDisconnect, onReconnect, launchServers, sessionId, launchDisabled, onLaunchChanged }: Props) { const { t } = useTranslation(); const [expandedName, setExpandedName] = useState(null); const stateText = (kind: McpStatusKind) => { @@ -111,6 +116,7 @@ export default function McpStatusTable({ rows, refreshing, pendingAction, onRefr const expanded = failed && expandedName === row.name; const source = sourceText(row); const actionPending = pendingAction?.(row.name); + const configured = launchServers?.find((server) => server.name === row.name && server.source === row.source); return {row.name} @@ -121,6 +127,7 @@ export default function McpStatusTable({ rows, refreshing, pendingAction, onRefr {stateText(row.kind)} } actions={[ + ...(configured?.launch && onLaunchChanged ? [] : []), ...(onEdit && editableNames?.has(row.name) ? [] : []), , , diff --git a/ui/src/features/panels/SettingsPage.tsx b/ui/src/features/panels/SettingsPage.tsx index 1418b530..6d3ca7db 100644 --- a/ui/src/features/panels/SettingsPage.tsx +++ b/ui/src/features/panels/SettingsPage.tsx @@ -40,6 +40,7 @@ import { useDisplayWidths } from "../shell/useDisplayWidths"; import { SettingsForm, SettingsFormItem, SettingsSection, SettingsThemeProvider, SettingsThemeScope } from "./settings/SettingsTheme"; import { AboutSettings } from "./AboutSettings"; import McpStatusTable, { mcpStatusRow, type McpStatusRow } from "./McpStatusTable"; +import { useMcpLaunchConfirmation } from "./McpLaunchControl"; import { draftTransport, emptyDraft, @@ -323,6 +324,8 @@ function SettingsPageController() { const [mcpRaw, setMcpRaw] = useState(""); const [mcpVersion, setMcpVersion] = useState(""); const mcpIdentity = useRef(0); + const confirmMcpLaunch = useMcpLaunchConfirmation(mcpIdentity.current); + const [mcpLaunchServers, setMcpLaunchServers] = useState([]); const mcpPendingIdentity = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); const [mcpSaving, setMcpSaving] = useState(false); @@ -344,6 +347,7 @@ function SettingsPageController() { setMcpOriginal(""); setMcpVersion(""); setMcpEffective([]); + setMcpLaunchServers([]); setMcpPath(""); setMcpProjectPath(null); void loadMcp("global"); @@ -1013,6 +1017,9 @@ function SettingsPageController() { setMcpEditing(false); setMcpEditorIndex(null); setMcpCreating(false); + const launchDoc = await ipc.mcpListConfig(mcpScope, sessionId ?? undefined); + if (identity !== mcpIdentity.current) return; + setMcpLaunchServers(launchDoc.servers ?? []); // 保存只接纳应用计划;此处只刷新状态,不重复建连。 if (sessionId) { const snapshotRequest = ++latestMcpSnapshotRequest; @@ -1156,6 +1163,7 @@ function SettingsPageController() { setMcpIssues(doc?.issues ?? []); setMcpPath(doc?.path ?? ""); setMcpEffective(doc?.effective ?? []); + setMcpLaunchServers(doc?.servers ?? []); setMcpTests({}); } @@ -1206,7 +1214,11 @@ function SettingsPageController() { mcpPendingIdentity.current = identity; setMcpTesting(true); setMcpTests({}); - const res = await ipc.mcpTest(mcpScope, name, config, sessionId ?? undefined); + const confirmation = await ipc.mcpPrepareTest(mcpScope, name, config, sessionId ?? undefined); + if (identity !== mcpIdentity.current || mcpPendingIdentity.current !== identity) return; + if (!await confirmMcpLaunch("probe", name, mcpPath, confirmation.version, config)) return; + if (identity !== mcpIdentity.current || mcpPendingIdentity.current !== identity) return; + const res = await ipc.mcpTest(mcpScope, name, config, confirmation.id, sessionId ?? undefined); if (identity !== mcpIdentity.current || mcpPendingIdentity.current !== identity) return; setMcpTests({ [name]: res.ok ? { ok: true, text: t("settings.mcpTestOk", { n: res.tools }) } @@ -1249,11 +1261,17 @@ function SettingsPageController() { async function refreshMcpStatus() { setMcpRefreshing(true); const snapshotRequest = ++latestMcpSnapshotRequest; + const identity = mcpIdentity.current; try { // 失败保留旧值:整份替成 [] 会把「一次 IPC 抖动」伪装成「所有服务器都没连接」, // 而「未连接」在本页是**正常态**文案(见状态表下方的说明),误导性最强 - const st = await readMcpStatus(); + const [st, configuration] = await Promise.all([ + readMcpStatus(), + ipc.mcpListConfig(mcpScope, sessionId ?? undefined).catch(() => null), + ]); if (snapshotRequest !== latestMcpSnapshotRequest || useSessions.getState().activeKey !== sessionId) return; + // Refresh launch policy without overwriting an editor draft or its save baseline. + if (identity === mcpIdentity.current) setMcpLaunchServers(configuration?.servers ?? []); if (st) applyMcpSnapshot(st); else message.error(t("settings.mcpStatusRefreshFailed")); } finally { @@ -1869,6 +1887,10 @@ function SettingsPageController() { hasSession={!!sessionId} onDisconnect={(n) => void runMcpAction(n, "disconnect")} onReconnect={(n) => void runMcpAction(n, "reconnect")} + launchServers={mcpLaunchServers} + sessionId={sessionId} + launchDisabled={mcpDirty || mcpSaving || mcpTesting} + onLaunchChanged={async () => { await loadMcp(mcpScope); await refreshMcpStatus(); }} /> {mcpEditing && invoke("mcp_reconnect", { sessionId, name }), - /** 临时测试连接:起 → tools/list → 立即回收,**不并入连接池、不改正式状态** */ - mcpTest: (scope: McpScope, name: string, config: Record, sessionId?: string) => - invoke("mcp_test", { scope, name, config, sessionId: sessionId ?? null }), + mcpSetLaunchTrust: (scope: McpScope, name: string, expectedVersion: string, trusted: boolean, sessionId?: string) => + invoke("mcp_set_launch_trust", { scope, name, expectedVersion, trusted, sessionId: sessionId ?? null }), + /** Prepare a single-use confirmation for the exact draft; this never connects. */ + mcpPrepareTest: (scope: McpScope, name: string, config: Record, sessionId?: string) => + invoke("mcp_prepare_test", { scope, name, config, sessionId: sessionId ?? null }), + /** Confirmed temporary probe; never joins or authorizes the persistent pool. */ + mcpTest: (scope: McpScope, name: string, config: Record, confirmation: string, sessionId?: string) => + invoke("mcp_test", { scope, name, config, confirmation, sessionId: sessionId ?? null }), /** 某会话的状态快照(断开 / 淘汰 / 批量停止后拉全量) */ mcpSnapshot: (sessionId: string) => invoke("mcp_snapshot", { sessionId }), diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index 7a169e75..c4be66f6 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -809,8 +809,13 @@ export interface McpServerView { source: McpScope; /** 被它覆盖掉的下层(无则 null) */ overridden: McpScope | null; + source_path: string; + launch: McpLaunchStatus; } +export interface McpLaunchStatus { version: string; trusted: boolean } +export interface McpProbeChallenge { id: string; version: string } + /** 一条配置问题 */ export interface McpConfigIssue { server: string | null; From 8e63d26754d34829b57372966805e7a0aa337aed Mon Sep 17 00:00:00 2001 From: Yangshifu1024 Date: Sat, 17 Oct 2026 00:56:06 +0800 Subject: [PATCH 3/9] feat(resources): audit skill and role provenance --- docs/mcp-and-skills.md | 13 +- docs/settings-guide.md | 8 + docs/subagents.md | 2 + src-tauri/src/agents/mod.rs | 16 +- src-tauri/src/host/commands/skills.rs | 62 +- src-tauri/src/lib.rs | 2 + src-tauri/src/resources.rs | 72 +++ src-tauri/src/skills/catalog.rs | 258 +++++++++ src-tauri/src/skills/catalog_tests.rs | 546 ++++++++++++++++++ src-tauri/src/skills/mod.rs | 284 ++++----- src-tauri/src/tools/skill.rs | 20 +- src-tauri/src/tools/subagent.rs | 2 +- ui/src/__tests__/SubagentModelsPanel.test.tsx | 5 +- ui/src/__tests__/app.smoke.test.tsx | 7 +- ui/src/__tests__/notify-reveal.test.tsx | 1 + ui/src/__tests__/settings.mcp.test.tsx | 1 + ui/src/__tests__/settings.network.test.tsx | 1 + ui/src/__tests__/settings.page.test.tsx | 1 + ui/src/__tests__/settings.skills.test.tsx | 191 +++++- ui/src/__tests__/shell.settings.test.tsx | 1 + ui/src/__tests__/theme.test.tsx | 1 + ui/src/features/panels/SettingsPage.tsx | 96 +-- ui/src/features/panels/SkillsPanel.tsx | 157 +++++ .../features/panels/SubagentModelsPanel.tsx | 20 +- .../panels/settings/settings-theme.css | 1 + ui/src/features/panels/settingsRegistry.ts | 17 + ui/src/i18n/en-US.ts | 10 +- ui/src/i18n/zh-CN.ts | 10 +- ui/src/ipc/client.ts | 8 +- ui/src/ipc/types.ts | 14 +- 30 files changed, 1552 insertions(+), 275 deletions(-) create mode 100644 src-tauri/src/resources.rs create mode 100644 src-tauri/src/skills/catalog.rs create mode 100644 src-tauri/src/skills/catalog_tests.rs create mode 100644 ui/src/features/panels/SkillsPanel.tsx diff --git a/docs/mcp-and-skills.md b/docs/mcp-and-skills.md index 5d6a93ab..06692dbc 100644 --- a/docs/mcp-and-skills.md +++ b/docs/mcp-and-skills.md @@ -91,11 +91,19 @@ MCP server 的 is_error 返回工具失败;文本和图片进入已有内容 `data_dir` 始终是全局目录;项目路径单独走 project_dir,不能再混为一谈。extra_roots 不自动扫描技能。列表展示顺序与覆盖优先级不同,不可按“列表排在前面”判断生效来源。内置 preview 也允许被同名自定义技能覆盖;同名时应核对实际来源。 -设置页可删除项目/全局托管技能,不删内置与 `.agents/.claude` 兼容目录。删除路径须在 canonical 后仍位于允许的托管根内;禁用与删除是两件事,不能因为技能已禁用就丢掉安全定位所需的信息。 +### 来源、版本与覆盖审计 + +[catalog.rs](../src-tauri/src/skills/catalog.rs) 一次扫描同时生成运行索引与管理候选目录。`list_skills/get_skill/reload_skills` 继续只返回启用的最高优先项;`inspect_skills` 返回全部合法候选、按名禁用状态、缺失来源的禁用名和 IO/解析诊断。最高项排在候选首位并标 selected,禁用按名字整体生效,不会回落到低层技能;设置页可展开被覆盖来源,禁用后重开仍可管理。 + +[resources.rs](../src-tauri/src/resources.rs) 的来源描述包含类别、canonical 身份和 `sha256:` 内容版本。文件版本来自实际读取的原始字节,CRLF 变化也产生新版本;不是 mtime。审计与 get/list 共用同一个缓存快照,实际 `skill` 的 `ToolOutcome.data.resource` 与该快照对账。来源类别描述发现位置,不证明作者可信,也不授予执行权限。 + +同一 canonical 文件重复扫描时只保留最高优先级来源,即使无 frontmatter、别名目录会产生不同 fallback 名也只保留最高来源的名字。同层同名冲突按规范化路径、再按原始路径稳定排序,最后一项胜出并提供诊断;这取代原来未定义的 read_dir 顺序。无效 YAML、非 UTF-8、断链及读取错误可见,普通无 SKILL.md 子目录不算错误。 + +设置页仅允许删除当前最高且可删的项目/全局托管来源,不删 builtin、兼容来源、链接条目或链接技能根。请求必须携带预览的来源身份和内容版本;后端在共享索引锁内重扫、核对当前最高项、重读字节,并复验 canonical 根、目标和链接状态。根级单文件 `skills/SKILL.md` 只删文件,目录技能则删除其目录及附属内容,不能删除整个 skills 根。成功后重新审计,显示真实下层回退;按名禁用状态保留。外部进程仍可能在最终路径检查后换链,这不是 OS 级原子删除或沙箱。 ### 缓存与禁用生效 -SkillIndex 的 get/list 缓存 TTL 为 10 秒;结构化 key 包含 workspace、全局 data_dir、project_dir 和排序去重后的 disabled 集。配置禁用/重新启用发布后,下一次 get/list 不能命中旧政策的缓存,不必等待 TTL 或依赖某个配置入口主动 invalidate;reload/delete 仍强制失效。不同全局数据目录和项目作用域不会串缓存。[tools/skill.rs](../src-tauri/src/tools/skill.rs) 的 `disabled_after_load_should_invalidate_within_ttl` 已启用,覆盖加载后禁用及重新启用。已进入运行历史/冻结系统提示的技能内容不会因此从模型记忆中撤回;这里保证后续获取拒绝,不承诺即时撤销在途步骤。 +SkillIndex 的 get/list/audit 缓存 TTL 为 10 秒;结构化 key 包含 workspace、全局 data_dir、project_dir 和排序去重后的 disabled 集。配置禁用/重新启用发布后,下一次 get/list/audit 不能命中旧政策的缓存,不必等待 TTL 或依赖某个配置入口主动 invalidate;reload/delete 仍强制失效。不同全局数据目录和项目作用域不会串缓存。显式提供的已关闭会话返回错误,不回落全局来源。[tools/skill.rs](../src-tauri/src/tools/skill.rs) 的 `disabled_after_load_should_invalidate_within_ttl` 已启用,覆盖加载后禁用及重新启用。已进入运行历史/冻结系统提示的技能内容不会因此从模型记忆中撤回;这里保证后续获取拒绝,不承诺即时撤销在途步骤。 ## 5. 技能加载、询问规范与输入触发 @@ -133,3 +141,4 @@ R08/R11 新增回归覆盖:配置版本冲突/非法输入无写入、等价 4. 慢 probe 期间改字段、关闭重开或切换会话;旧成功不得出现。关闭后按预算检查临时进程归零。 5. 两编辑器交错保存,以及设置保存与“始终允许”交错;冲突保留草稿,允许补丁不覆盖新 endpoint 或其它字段。 6. 在应用内分别验 Windows Job Object 与 Unix 进程组回收,尤其是初始化/tools-list 期间取消 probe;三平台会话释放回归通过不等于 Windows 已覆盖 Unix 专属 probe 取消用例。 +7. 技能页展开同名覆盖,核对来源和版本;禁用保存、重开、重新启用及取消草稿,运行状态与草稿提示应分开。删除最高托管来源后看到正确下层,旧版本确认不得删除新来源;切项目的迟到读取/确认不影响新项目。检查长路径与版本在窄窗/缩放/暗色下可读。GUI 与 Windows 根链接/路径行为仍待人工或对应平台验证。 diff --git a/docs/settings-guide.md b/docs/settings-guide.md index e95eac09..3366ab22 100644 --- a/docs/settings-guide.md +++ b/docs/settings-guide.md @@ -106,6 +106,14 @@ 源码:[SettingsPage.tsx](../ui/src/features/panels/SettingsPage.tsx) 的 `saveMcp/deleteMcpEntry/runMcpAction/testMcpServer/readMcpStatus`、[McpKvTable.tsx](../ui/src/features/panels/McpKvTable.tsx)、[McpStatusTable.tsx](../ui/src/features/panels/McpStatusTable.tsx)、[host/commands/mcp.rs](../src-tauri/src/host/commands/mcp.rs)。 +## 技能来源与覆盖 + +[SkillsPanel](../ui/src/features/panels/SkillsPanel.tsx) 使用 `inspect_skills` 管理目录,Composer 与模型索引继续使用只含启用最高项的原接口。设置显示来源类别、canonical 身份、完整版本、覆盖候选和诊断;缺文件的禁用名仍可清除禁用状态,不虚构安装路径。 + +「当前运行启用/禁用」来自后端审计,开关读本页草稿,相对保存基线变化另标待保存;修改开关不立即改变运行策略,保存成功后重读审计,取消恢复草稿。读取失败不伪装空目录。重新加载、切会话和删除均以身份/代数拒收迟到结果,旧确认不能发起新作用域删除。 + +删除预览携带最高来源身份/版本,确认说明目录技能会连同目录内容删除。成功后重新扫描展示回退,不直接从 UI 移除整个名称;禁用名单不会因删除而自动清除,避免下层回退意外启用。后端边界由 [MCP 与技能](./mcp-and-skills.md#来源版本与覆盖审计) 主维护。 + ## 新增或移动设置项 「界面 → 输出展示」注册为 `ui.output_display`,与聊天区顶部切换器共用 store 和 localStorage 偏好;默认详细,切换即时生效,不修改模型回复长度。简洁的聚合/文件统计/预览与历史边界由[聊天与界面](./chat-and-interface.md#52x-简洁与详细展示)主维护。 diff --git a/docs/subagents.md b/docs/subagents.md index 2f50659e..42fdfbff 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -18,6 +18,8 @@ `title` 是内部自动命名角色,工具入口硬拒绝经 subagent 委派;它由 title 模块单次零工具调用使用。旧 `dev` 不保留别名,未知角色按通用子代理运行,不能把未知字符串说成已注册专家。规范化支持大小写、空白、下划线;`pm/test` 别名通过注册表同源解析,不另外维护角色判定表。 +`AgentMeta.resource` 从实际注册定义的名称、描述、readonly 和正文计算来源版本,身份为 `builtin:agent:`;设置中的角色模型面板展示该身份和版本,子代理成功结果的 `ToolOutcome.data.role_resource` 引用同一模板。这里描述角色基座,不代表运行时权限或最终 prompt 的完整指纹;未知通用角色没有虚构内置来源。当前未加载项目/扩展角色,title 仍不在可委派列表。共用来源字段见 [资源来源](./mcp-and-skills.md#来源版本与覆盖审计)。 + 工具入参为自包含 `task`、`role`、`maxSteps`,另有短标签 description 与 cleanContext。schema 要求预算,执行层对缺失仍有默认 25 步并夹取 1–1000;兼容 `max_steps/clean_context` 键名。任务/角色为空拒绝,不悄悄执行空任务。 - cleanContext 默认 true;false 仅带父历史最后 6 条作背景,不等于复制全部会话。 diff --git a/src-tauri/src/agents/mod.rs b/src-tauri/src/agents/mod.rs index e38b4508..890360c9 100644 --- a/src-tauri/src/agents/mod.rs +++ b/src-tauri/src/agents/mod.rs @@ -21,6 +21,17 @@ pub struct AgentDef { pub readonly: bool, } +impl AgentDef { + pub fn resource(&self) -> crate::resources::ResourceOrigin { + crate::resources::ResourceOrigin::builtin( + "agent", + self.name, + &serde_json::to_vec(&(self.name, self.description, self.readonly, self.body)) + .expect("built-in role is JSON"), + ) + } +} + /// title 子代理的完整角色定义:会话自动命名的人设。单一事实源——注册表与 /// core/title.rs 的一次性 LLM 调用共用,防止人设与 prompt 漂移。 pub const TITLE_BODY: &str = r##"## 角色 @@ -283,11 +294,12 @@ pub fn find(role: &str) -> Option<&'static AgentDef> { builtin().iter().find(|d| d.name == key) } -/// 前端 composer $ 菜单的子代理条目:name + description 摘要,不含 body 正文。 +/// Delegable role summary with provenance; the body itself is not exposed in this view. #[derive(serde::Serialize)] pub struct AgentMeta { pub name: String, pub description: String, + pub resource: crate::resources::ResourceOrigin, } /// 可委派角色清单(kebab-case,按 builtin 注册顺序排列,剔除内部 title 角色)。 @@ -311,6 +323,7 @@ pub fn delegable() -> Vec { .map(|d| AgentMeta { name: d.name.to_string(), description: d.description.to_string(), + resource: d.resource(), }) .collect() } @@ -336,6 +349,7 @@ mod tests { assert!(!m.description.is_empty(), "{} 缺 description", m.name); // 与 subagent 工具同一查找:delegable 里的名字必须都能命中注册表 assert!(find(&m.name).is_some(), "{} 应可经 find 命中", m.name); + assert_eq!(m.resource, find(&m.name).unwrap().resource()); } } diff --git a/src-tauri/src/host/commands/skills.rs b/src-tauri/src/host/commands/skills.rs index 14d3e16b..12f9ea95 100644 --- a/src-tauri/src/host/commands/skills.rs +++ b/src-tauri/src/host/commands/skills.rs @@ -2,14 +2,23 @@ use super::util::Core; use std::path::PathBuf; /// 技能扫描上下文:有会话用会话快照 (workspace, data_dir, project_dir);无会话回退全局 data_dir。 -fn skill_scope(core: &Core<'_>, session_id: Option<&str>) -> (PathBuf, PathBuf, Option) { - match session_id.and_then(|id| core.session(id)) { - Some(rt) => ( - rt.workspace.clone(), - rt.data_dir.clone(), - rt.project_dir.clone(), - ), - None => (core.data_dir.clone(), core.data_dir.clone(), None), +fn skill_scope( + core: &Core<'_>, + session_id: Option<&str>, +) -> Result<(PathBuf, PathBuf, Option), String> { + match session_id { + Some(id) => { + let rt = core.session(id).ok_or("Session closed")?; + if rt.zombie.load(std::sync::atomic::Ordering::SeqCst) { + return Err("Session closed".into()); + } + Ok(( + rt.workspace.clone(), + rt.data_dir.clone(), + rt.project_dir.clone(), + )) + } + None => Ok((core.data_dir.clone(), core.data_dir.clone(), None)), } } @@ -20,13 +29,30 @@ pub async fn list_skills( core: Core<'_>, session_id: Option, ) -> Result, String> { - let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref()); + let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref())?; let disabled = core.cfg.read().unwrap().disabled_skills.clone(); Ok(core .skills .list(&workspace, &data_dir, &disabled, project_dir.as_deref())) } +/// Management view retains disabled and shadowed candidates; it never expands executable skills. +#[tauri::command] +pub async fn inspect_skills( + core: Core<'_>, + session_id: Option, + reload: Option, +) -> Result { + let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref())?; + let disabled = core.cfg.read().unwrap().disabled_skills.clone(); + if reload.unwrap_or(false) { + core.skills.invalidate(); + } + Ok(core + .skills + .audit(&workspace, &data_dir, &disabled, project_dir.as_deref())) +} + /// 读取单个技能详情(元数据 + SKILL.md 正文;被禁用或不存在时返回 None)。 #[tauri::command] pub async fn get_skill( @@ -34,7 +60,7 @@ pub async fn get_skill( session_id: Option, name: String, ) -> Result, String> { - let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref()); + let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref())?; let disabled = core.cfg.read().unwrap().disabled_skills.clone(); Ok(core.skills.get( &workspace, @@ -51,7 +77,7 @@ pub async fn reload_skills( core: Core<'_>, session_id: Option, ) -> Result, String> { - let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref()); + let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref())?; let disabled = core.cfg.read().unwrap().disabled_skills.clone(); core.skills.invalidate(); Ok(core @@ -65,10 +91,18 @@ pub async fn delete_skill( core: Core<'_>, session_id: Option, name: String, + expected_source: String, + expected_version: String, ) -> Result<(), String> { - let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref()); - core.skills - .delete_skill(&workspace, &data_dir, project_dir.as_deref(), &name) + let (workspace, data_dir, project_dir) = skill_scope(&core, session_id.as_deref())?; + core.skills.delete_skill( + &workspace, + &data_dir, + project_dir.as_deref(), + &name, + &expected_source, + &expected_version, + ) } /// 启用/禁用技能(写入 config 的 disabled_skills 并落盘)。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 337a71d8..b275ea8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod host; pub mod mcp; pub mod memory; pub mod provider; +pub mod resources; pub mod safety; pub mod skills; mod tools; @@ -422,6 +423,7 @@ pub fn run() { host::commands::mcp_snapshot, host::commands::list_agents, host::commands::list_skills, + host::commands::inspect_skills, host::commands::get_skill, host::commands::toggle_skill, host::commands::reload_skills, diff --git a/src-tauri/src/resources.rs b/src-tauri/src/resources.rs new file mode 100644 index 00000000..721f4652 --- /dev/null +++ b/src-tauri/src/resources.rs @@ -0,0 +1,72 @@ +//! Provenance describes loaded content; it is not an execution or author-trust grant. +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ResourceSource { + Builtin, + UserClaude, + UserAgents, + WorkspaceClaude, + WorkspaceAgents, + GlobalManaged, + ProjectManaged, +} + +impl ResourceSource { + pub fn priority(self) -> u8 { + match self { + Self::Builtin => 0, + Self::UserClaude => 1, + Self::UserAgents => 2, + Self::WorkspaceClaude => 3, + Self::WorkspaceAgents => 4, + Self::GlobalManaged => 5, + Self::ProjectManaged => 6, + } + } + + pub fn managed(self) -> bool { + matches!(self, Self::GlobalManaged | Self::ProjectManaged) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ResourceOrigin { + pub source: ResourceSource, + /// Canonical file identity, or a namespaced built-in identity. + pub identity: String, + /// SHA-256 of raw file bytes or registered built-in template data. + pub version: String, +} + +impl ResourceOrigin { + pub fn builtin(kind: &str, name: &str, bytes: &[u8]) -> Self { + Self { + source: ResourceSource::Builtin, + identity: format!("builtin:{kind}:{name}"), + version: content_version(bytes), + } + } + + pub fn file(source: ResourceSource, canonical: &Path, bytes: &[u8]) -> Self { + Self { + source, + identity: format!("file:{}", canonical.display()), + version: content_version(bytes), + } + } +} + +pub fn content_version(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} diff --git a/src-tauri/src/skills/catalog.rs b/src-tauri/src/skills/catalog.rs new file mode 100644 index 00000000..f8aac677 --- /dev/null +++ b/src-tauri/src/skills/catalog.rs @@ -0,0 +1,258 @@ +//! A single candidate scan feeds execution and management views. +use super::{Skill, SkillMeta, builtin_skills, parse_skill_md}; +use crate::resources::{ResourceOrigin, ResourceSource}; +use serde::Serialize; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize)] +pub struct SkillCandidate { + pub meta: SkillMeta, + pub selected: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkillAuditEntry { + pub name: String, + pub disabled: bool, + /// Highest precedence first. Disabling the name never selects a lower candidate. + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkillIssue { + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkillAudit { + pub entries: Vec, + pub issues: Vec, +} + +pub(super) struct SkillCatalogSnapshot { + pub effective: HashMap, + pub audit: SkillAudit, +} + +impl SkillCatalogSnapshot { + pub fn build( + home: Option<&Path>, + workspace: &Path, + data_dir: &Path, + disabled: &[String], + project_dir: Option<&Path>, + ) -> Self { + let mut skills = Vec::new(); + let mut issues = Vec::new(); + for (name, text) in builtin_skills() { + if let Some(mut skill) = parse_skill_md(text, name) { + skill.meta.origin = "".into(); + skill.meta.resource = Some(ResourceOrigin::builtin("skill", name, text.as_bytes())); + skills.push(skill); + } + } + let mut roots = Vec::new(); + if let Some(home) = home { + roots.push((home.join(".claude/skills"), ResourceSource::UserClaude)); + roots.push((home.join(".agents/skills"), ResourceSource::UserAgents)); + } + roots.extend([ + ( + workspace.join(".claude/skills"), + ResourceSource::WorkspaceClaude, + ), + ( + workspace.join(".agents/skills"), + ResourceSource::WorkspaceAgents, + ), + (data_dir.join("skills"), ResourceSource::GlobalManaged), + ]); + if let Some(project) = project_dir { + roots.push((project.join("skills"), ResourceSource::ProjectManaged)); + } + for (root, source) in roots { + scan(&root, source, &mut skills, &mut issues); + } + // Stable tie-breaking replaces the filesystem's unspecified read_dir order. + skills.sort_by_cached_key(|skill| { + let resource = skill.meta.resource.as_ref().expect("scanned provenance"); + ( + resource.source.priority(), + skill.meta.origin.replace('\\', "/"), + skill.meta.origin.clone(), + ) + }); + // Deduplicate physical files before grouping names, including aliases with fallback names. + let mut physical = HashSet::new(); + skills.reverse(); + skills.retain(|skill| { + physical.insert(skill.meta.resource.as_ref().unwrap().identity.clone()) + }); + skills.reverse(); + let mut names: BTreeMap> = BTreeMap::new(); + for skill in skills { + let candidates = names.entry(skill.meta.name.clone()).or_default(); + if candidates.last().is_some_and(|other| { + other.meta.resource.as_ref().unwrap().source + == skill.meta.resource.as_ref().unwrap().source + }) { + issues.push(SkillIssue { + path: skill.meta.origin.clone(), + message: format!("Duplicate skill name '{}': the last normalized path wins at equal precedence", skill.meta.name), + }); + } + candidates.push(skill); + } + for name in disabled { + names.entry(name.clone()).or_default(); + } + let mut effective = HashMap::new(); + let entries = names + .into_iter() + .map(|(name, mut candidates)| { + candidates.reverse(); + let disabled = disabled.contains(&name); + if !disabled && let Some(winner) = candidates.first() { + effective.insert(name.clone(), winner.clone()); + } + SkillAuditEntry { + name, + disabled, + candidates: candidates + .into_iter() + .enumerate() + .map(|(index, skill)| SkillCandidate { + meta: skill.meta, + selected: index == 0, + }) + .collect(), + } + }) + .collect(); + Self { + effective, + audit: SkillAudit { entries, issues }, + } + } +} + +/// Deletion roots are real children of the declared data/project scope, never root links. +pub(super) fn managed_root(root: &Path) -> Option { + if std::fs::symlink_metadata(root) + .ok()? + .file_type() + .is_symlink() + { + return None; + } + let parent = root.parent()?.canonicalize().ok()?; + let canonical = root.canonicalize().ok()?; + (canonical != parent && canonical.starts_with(&parent)).then_some(canonical) +} + +fn scan( + root: &Path, + source: ResourceSource, + skills: &mut Vec, + issues: &mut Vec, +) { + let issue = |path: &Path, message: String| SkillIssue { + path: path.display().to_string(), + message, + }; + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && std::fs::symlink_metadata(root).is_err() => + { + return; + } + Err(error) => { + issues.push(issue(root, error.to_string())); + return; + } + }; + let canonical_root = managed_root(root); + if source.managed() && canonical_root.is_none() { + issues.push(issue( + root, + "Linked or escaping managed skills root is read-only".into(), + )); + } + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + issues.push(issue(root, error.to_string())); + continue; + } + }; + let path = entry.path(); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + issues.push(issue(&path, error.to_string())); + continue; + } + }; + let file = if metadata.is_dir() { + let candidate = path.join("SKILL.md"); + match std::fs::metadata(&candidate) { + Ok(metadata) if metadata.is_file() => candidate, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && std::fs::symlink_metadata(&candidate).is_err() => + { + continue; + } + Err(error) => { + issues.push(issue(&candidate, error.to_string())); + continue; + } + Ok(_) => { + issues.push(issue(&candidate, "SKILL.md is not a regular file".into())); + continue; + } + } + } else if metadata.is_file() && path.extension().is_some_and(|extension| extension == "md") + { + path.clone() + } else { + continue; + }; + let result = (|| -> Result { + let bytes = std::fs::read(&file).map_err(|error| error.to_string())?; + let text = + std::str::from_utf8(&bytes).map_err(|_| "Skill is not valid UTF-8".to_string())?; + let fallback = if file != path { + entry.file_name().to_string_lossy().into_owned() + } else { + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }; + let mut skill = + parse_skill_md(text, &fallback).ok_or("Invalid skill frontmatter or name")?; + let canonical = file.canonicalize().map_err(|error| error.to_string())?; + skill.meta.origin = file.display().to_string(); + let linked_entry = std::fs::symlink_metadata(&path) + .is_ok_and(|meta| meta.file_type().is_symlink()) + || std::fs::symlink_metadata(&file).is_ok_and(|meta| meta.file_type().is_symlink()); + skill.meta.deletable = source.managed() + && !linked_entry + && canonical_root + .as_ref() + .is_some_and(|root| canonical.starts_with(root)); + skill.meta.resource = Some(ResourceOrigin::file(source, &canonical, &bytes)); + Ok(skill) + })(); + match result { + Ok(skill) => skills.push(skill), + Err(error) => issues.push(issue(&file, error)), + } + } +} diff --git a/src-tauri/src/skills/catalog_tests.rs b/src-tauri/src/skills/catalog_tests.rs new file mode 100644 index 00000000..cd0aa640 --- /dev/null +++ b/src-tauri/src/skills/catalog_tests.rs @@ -0,0 +1,546 @@ +use super::catalog::SkillCatalogSnapshot; +use super::*; +use crate::resources::ResourceSource; + +fn install(root: &Path, relative: &str, name: &str, body: &str) -> PathBuf { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + format!("---\nname: {name}\ndescription: {body}\n---\n{body}\n"), + ) + .unwrap(); + path +} + +#[test] +fn seven_sources_remain_auditable_while_disabled_winner_never_falls_back() { + let home = tempfile::tempdir().unwrap(); + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let pd = tempfile::tempdir().unwrap(); + for (base, path, body) in [ + ( + home.path(), + ".claude/skills/fixture/SKILL.md", + "user-claude", + ), + ( + home.path(), + ".agents/skills/fixture/SKILL.md", + "user-agents", + ), + ( + ws.path(), + ".claude/skills/fixture/SKILL.md", + "workspace-claude", + ), + ( + ws.path(), + ".agents/skills/fixture/SKILL.md", + "workspace-agents", + ), + (dd.path(), "skills/fixture/SKILL.md", "global"), + (pd.path(), "skills/fixture/SKILL.md", "project"), + ] { + install(base, path, "repo-index", body); + } + let build = |disabled: &[String]| { + SkillCatalogSnapshot::build( + Some(home.path()), + ws.path(), + dd.path(), + disabled, + Some(pd.path()), + ) + }; + let snapshot = build(&[]); + assert_eq!(snapshot.effective["repo-index"].body, "project\n"); + let entry = snapshot + .audit + .entries + .iter() + .find(|entry| entry.name == "repo-index") + .unwrap(); + assert_eq!( + entry + .candidates + .iter() + .map(|candidate| candidate.meta.resource.as_ref().unwrap().source) + .collect::>(), + [ + ResourceSource::ProjectManaged, + ResourceSource::GlobalManaged, + ResourceSource::WorkspaceAgents, + ResourceSource::WorkspaceClaude, + ResourceSource::UserAgents, + ResourceSource::UserClaude, + ResourceSource::Builtin, + ] + ); + assert_eq!( + entry + .candidates + .iter() + .filter(|candidate| candidate.selected) + .count(), + 1 + ); + assert_eq!( + entry.candidates[0].meta, + snapshot.effective["repo-index"].meta + ); + let disabled = build(&["repo-index".into(), "catalog-deleted-disabled".into()]); + assert!(!disabled.effective.contains_key("repo-index")); + let entry = disabled + .audit + .entries + .iter() + .find(|entry| entry.name == "repo-index") + .unwrap(); + assert!(entry.disabled); + assert_eq!(entry.candidates.len(), 7); + assert!( + entry.candidates[0].selected, + "disabling a name must retain its source winner" + ); + let orphan = disabled + .audit + .entries + .iter() + .find(|entry| entry.name == "catalog-deleted-disabled") + .unwrap(); + assert!(orphan.disabled && orphan.candidates.is_empty()); +} + +#[test] +fn audit_get_and_list_share_cached_bytes_until_explicit_reload() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let path = install( + dd.path(), + "skills/catalog-cache/SKILL.md", + "catalog-cache", + "before", + ); + let index = SkillIndex { + cache: Mutex::new(None), + ttl: Duration::from_secs(3600), + }; + let audit = index.audit(ws.path(), dd.path(), &[], None); + let cached = audit + .entries + .iter() + .find(|entry| entry.name == "catalog-cache") + .unwrap() + .candidates[0] + .meta + .clone(); + // New raw bytes normalize to the same body, but must produce a new version after reload. + let raw = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, raw.replace('\n', "\r\n")).unwrap(); + let get = index + .get(ws.path(), dd.path(), &[], None, "catalog-cache") + .unwrap(); + assert_eq!(get.meta, cached); + assert_eq!( + index + .list(ws.path(), dd.path(), &[], None) + .into_iter() + .find(|meta| meta.name == "catalog-cache") + .unwrap(), + cached + ); + index.invalidate(); + let reloaded = index + .get(ws.path(), dd.path(), &[], None, "catalog-cache") + .unwrap(); + assert_eq!(reloaded.body, get.body); + assert_ne!( + reloaded.meta.resource.as_ref().unwrap().version, + cached.resource.as_ref().unwrap().version + ); + let audit = index.audit(ws.path(), dd.path(), &[], None); + assert_eq!( + audit + .entries + .iter() + .find(|entry| entry.name == "catalog-cache") + .unwrap() + .candidates[0] + .meta, + reloaded.meta + ); +} + +#[test] +fn delete_requires_current_identity_and_bytes_then_reveals_lower_source() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let pd = tempfile::tempdir().unwrap(); + let global = install( + dd.path(), + "skills/catalog-cas/SKILL.md", + "catalog-cas", + "global", + ); + let project = install( + pd.path(), + "skills/catalog-cas/SKILL.md", + "catalog-cas", + "project", + ); + let index = SkillIndex::new(); + let get = || { + index + .get(ws.path(), dd.path(), &[], Some(pd.path()), "catalog-cas") + .unwrap() + }; + let old = get().meta.resource.unwrap(); + std::fs::write(&project, "---\nname: catalog-cas\n---\nchanged\n").unwrap(); + assert!( + index + .delete_skill( + ws.path(), + dd.path(), + Some(pd.path()), + "catalog-cas", + &old.identity, + &old.version + ) + .is_err() + ); + assert!(project.exists() && global.exists()); + let current = get().meta.resource.unwrap(); + let lower = + SkillCatalogSnapshot::build(None, ws.path(), dd.path(), &[], None).effective["catalog-cas"] + .meta + .resource + .clone() + .unwrap(); + assert!( + index + .delete_skill( + ws.path(), + dd.path(), + Some(pd.path()), + "catalog-cas", + &lower.identity, + ¤t.version + ) + .is_err() + ); + assert!(project.exists() && global.exists()); + index + .delete_skill( + ws.path(), + dd.path(), + Some(pd.path()), + "catalog-cas", + ¤t.identity, + ¤t.version, + ) + .unwrap(); + assert!(!project.parent().unwrap().exists()); + assert_eq!(get().body, "global\n"); + assert!( + index + .delete_skill( + ws.path(), + dd.path(), + Some(pd.path()), + "catalog-cas", + ¤t.identity, + ¤t.version + ) + .is_err() + ); + assert!( + global.exists(), + "stale project approval must not delete the revealed global skill" + ); +} + +#[test] +fn root_skill_deletion_preserves_managed_root_and_other_contents() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let root_file = install(dd.path(), "skills/SKILL.md", "catalog-root", "root-file"); + let sibling = install( + dd.path(), + "skills/catalog-sibling/SKILL.md", + "catalog-sibling", + "sibling", + ); + let keep = dd.path().join("skills/keep.txt"); + std::fs::write(&keep, "keep").unwrap(); + let index = SkillIndex::new(); + let resource = index + .get(ws.path(), dd.path(), &[], None, "catalog-root") + .unwrap() + .meta + .resource + .unwrap(); + index + .delete_skill( + ws.path(), + dd.path(), + None, + "catalog-root", + &resource.identity, + &resource.version, + ) + .unwrap(); + assert!(!root_file.exists()); + assert!(dd.path().join("skills").is_dir() && sibling.exists() && keep.exists()); + assert!( + index + .get(ws.path(), dd.path(), &[], None, "catalog-root") + .is_none() + ); + assert!( + index + .get(ws.path(), dd.path(), &[], None, "catalog-sibling") + .is_some() + ); +} + +#[test] +fn equal_precedence_conflicts_are_stable_and_invalid_candidates_are_diagnosed() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let later = install( + dd.path(), + "skills/z/SKILL.md", + "catalog-collision", + "z-winner", + ); + install( + dd.path(), + "skills/a/SKILL.md", + "catalog-collision", + "a-shadow", + ); + let invalid = dd.path().join("skills/broken.md"); + std::fs::write(&invalid, b"---\nname: [broken\n---\nbody").unwrap(); + let non_utf8 = dd.path().join("skills/binary.md"); + std::fs::write(&non_utf8, [0xff, 0xfe]).unwrap(); + for _ in 0..2 { + let snapshot = SkillCatalogSnapshot::build(None, ws.path(), dd.path(), &[], None); + assert_eq!(snapshot.effective["catalog-collision"].body, "z-winner\n"); + let entry = snapshot + .audit + .entries + .iter() + .find(|entry| entry.name == "catalog-collision") + .unwrap(); + assert_eq!(entry.candidates.len(), 2); + for path in [&later, &invalid, &non_utf8] { + assert!( + snapshot + .audit + .issues + .iter() + .any(|issue| issue.path == path.display().to_string() + && !issue.message.is_empty()), + "missing diagnostic for {}", + path.display() + ); + } + } +} + +#[cfg(unix)] +#[test] +fn physical_aliases_are_deduplicated_and_linked_entries_cannot_be_deleted() { + use std::os::unix::fs::symlink; + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + install( + ws.path(), + ".claude/skills/catalog-physical/SKILL.md", + "catalog-physical", + "physical", + ); + std::fs::create_dir_all(ws.path().join(".agents")).unwrap(); + symlink( + ws.path().join(".claude/skills"), + ws.path().join(".agents/skills"), + ) + .unwrap(); + std::fs::create_dir_all(dd.path().join("skills")).unwrap(); + let directory_target = install( + outside.path(), + "directory/SKILL.md", + "catalog-link-directory", + "outside-directory", + ); + let file_target = install( + outside.path(), + "file.md", + "catalog-link-file", + "outside-file", + ); + symlink( + directory_target.parent().unwrap(), + dd.path().join("skills/directory"), + ) + .unwrap(); + symlink(&file_target, dd.path().join("skills/file.md")).unwrap(); + let broken = dd.path().join("skills/broken-link.md"); + symlink(outside.path().join("missing.md"), &broken).unwrap(); + let snapshot = SkillCatalogSnapshot::build(None, ws.path(), dd.path(), &[], None); + assert!( + snapshot + .audit + .issues + .iter() + .any(|issue| issue.path == broken.display().to_string() && !issue.message.is_empty()), + "broken skill links must be diagnosed" + ); + let physical = snapshot + .audit + .entries + .iter() + .find(|entry| entry.name == "catalog-physical") + .unwrap(); + assert_eq!(physical.candidates.len(), 1); + assert_eq!( + physical.candidates[0] + .meta + .resource + .as_ref() + .unwrap() + .source, + ResourceSource::WorkspaceAgents + ); + let index = SkillIndex::new(); + for name in ["catalog-link-directory", "catalog-link-file"] { + let meta = &snapshot.effective[name].meta; + assert!(!meta.deletable); + let resource = meta.resource.as_ref().unwrap(); + assert!( + index + .delete_skill( + ws.path(), + dd.path(), + None, + name, + &resource.identity, + &resource.version + ) + .is_err() + ); + } + assert_eq!( + std::fs::read_to_string(directory_target).unwrap(), + "---\nname: catalog-link-directory\ndescription: outside-directory\n---\noutside-directory\n" + ); + assert!(file_target.exists()); +} + +#[cfg(unix)] +#[test] +fn linked_managed_root_is_loadable_but_never_authorizes_deletion_of_external_files() { + use std::os::unix::fs::symlink; + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root_file = install(outside.path(), "SKILL.md", "catalog-linked-root", "root"); + let nested = install( + outside.path(), + "nested/SKILL.md", + "catalog-linked-nested", + "nested", + ); + let untouched = outside.path().join("keep.txt"); + std::fs::write(&untouched, "outside must remain").unwrap(); + let originals: Vec<_> = [&root_file, &nested, &untouched] + .into_iter() + .map(|path| (path.clone(), std::fs::read(path).unwrap())) + .collect(); + symlink(outside.path(), dd.path().join("skills")).unwrap(); + let snapshot = SkillCatalogSnapshot::build(None, ws.path(), dd.path(), &[], None); + let index = SkillIndex::new(); + for name in ["catalog-linked-root", "catalog-linked-nested"] { + let skill = &snapshot.effective[name]; + assert!(!skill.meta.deletable); + assert!(index.get(ws.path(), dd.path(), &[], None, name).is_some()); + let resource = skill.meta.resource.as_ref().unwrap(); + assert!( + index + .delete_skill( + ws.path(), + dd.path(), + None, + name, + &resource.identity, + &resource.version + ) + .is_err() + ); + } + assert!(outside.path().is_dir()); + for (path, bytes) in originals { + assert_eq!(std::fs::read(path).unwrap(), bytes); + } + assert!( + std::fs::symlink_metadata(dd.path().join("skills")) + .unwrap() + .file_type() + .is_symlink() + ); +} + +#[cfg(unix)] +#[test] +fn nameless_physical_skill_keeps_only_its_highest_precedence_alias_name() { + use std::os::unix::fs::symlink; + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let original = ws + .path() + .join(".claude/skills/catalog-lower-alias/SKILL.md"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + std::fs::write(&original, "# Plain skill\nNo frontmatter.\n").unwrap(); + let higher = ws.path().join(".agents/skills/catalog-higher-alias"); + std::fs::create_dir_all(higher.parent().unwrap()).unwrap(); + symlink(original.parent().unwrap(), &higher).unwrap(); + let snapshot = SkillCatalogSnapshot::build(None, ws.path(), dd.path(), &[], None); + assert!(!snapshot.effective.contains_key("catalog-lower-alias")); + assert!( + !snapshot + .audit + .entries + .iter() + .any(|entry| entry.name == "catalog-lower-alias") + ); + let selected = &snapshot.effective["catalog-higher-alias"]; + assert_eq!(selected.body, "# Plain skill\nNo frontmatter.\n"); + let resource = selected.meta.resource.as_ref().unwrap(); + assert_eq!(resource.source, ResourceSource::WorkspaceAgents); + assert_eq!( + snapshot + .audit + .entries + .iter() + .flat_map(|entry| &entry.candidates) + .filter( + |candidate| candidate.meta.resource.as_ref().unwrap().identity == resource.identity + ) + .count(), + 1 + ); + let index = SkillIndex::new(); + assert!( + index + .get(ws.path(), dd.path(), &[], None, "catalog-lower-alias") + .is_none() + ); + assert!( + index + .get(ws.path(), dd.path(), &[], None, "catalog-higher-alias") + .is_some() + ); +} diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 9fff3934..38d3b1f8 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -8,9 +8,16 @@ use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::Mutex; use std::time::{Duration, Instant}; +mod catalog; +#[cfg(test)] +mod catalog_tests; +use catalog::SkillCatalogSnapshot; +pub use catalog::{SkillAudit, SkillAuditEntry, SkillCandidate, SkillIssue}; + /// 技能元数据(列表/注入索引用的轻量视图)。 #[derive(Debug, Clone, Serialize, PartialEq)] pub struct SkillMeta { @@ -26,6 +33,8 @@ pub struct SkillMeta { /// 是否可删除(仅托管目录:data_dir/skills 与 project_dir/skills;内置/兼容目录不可删) #[serde(default)] pub deletable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, } /// 完整技能:元数据 + 正文(调用时才注入全文)。 @@ -71,6 +80,7 @@ pub fn parse_skill_md(text: &str, fallback_name: &str) -> Option { when_to_use: fm.when_to_use.unwrap_or_default(), origin: String::new(), deletable: false, + resource: None, }, body: body.to_string(), } @@ -82,6 +92,7 @@ pub fn parse_skill_md(text: &str, fallback_name: &str) -> Option { when_to_use: String::new(), origin: String::new(), deletable: false, + resource: None, }, body: text.to_string(), } @@ -392,8 +403,8 @@ PDF 里的文字是「用哪个字形画在哪里」,而不是「这段文字 ] } -/// TTL 缓存槽位:(采集时刻, scope key, 以技能名为键的索引)。 -type SkillCache = Mutex)>>; +/// TTL cache: timestamp, scope/policy key, and one execution/inspection snapshot. +type SkillCache = Mutex)>>; /// 技能索引:带 scope key 的 TTL 缓存,负责扫描/查询/列表。 pub struct SkillIndex { @@ -449,24 +460,37 @@ impl SkillIndex { } } - /// 清空 TTL 缓存(下次 get/list 重建索引);「重新加载」与删除后同步均走此路径。 + /// Clear the shared get/list/audit snapshot; the next consumer rebuilds it. pub fn invalidate(&self) { *self.cache.lock().unwrap() = None; } /// 删除托管技能(仅 data_dir/skills 与 project_dir/skills;内置与 .claude/.agents 兼容来源拒绝)。 /// 全量索引查找、不施加 disabled 过滤(已禁用的托管技能同样可删,避免与「不存在」混淆)。 - /// 双防线:索引 deletable 桶标记 + canonicalize 后前缀校验(防路径穿越/符号链接绕过);成功后缓存失效。 + /// Bind deletion to the reviewed source/version, re-read its bytes, and check the managed root. + /// Path checks do not provide atomic exclusion against unrelated filesystem writers. pub fn delete_skill( &self, workspace: &Path, data_dir: &Path, project_dir: Option<&Path>, name: &str, + expected_source: &str, + expected_version: &str, ) -> Result<(), String> { + let mut cache = self.cache.lock().unwrap(); + *cache = None; let skill = Self::build(workspace, data_dir, &[], project_dir) .remove(name) .ok_or_else(|| format!("技能不存在:{name}"))?; + let resource = skill + .meta + .resource + .as_ref() + .ok_or("Skill has no source identity")?; + if resource.identity != expected_source || resource.version != expected_version { + return Err("Skill source or content changed; reload before deleting".into()); + } if !skill.meta.deletable { return Err(format!( "该技能来源不可删除(仅托管 .codewave/skills 可删):{}", @@ -476,31 +500,65 @@ impl SkillIndex { let canonical = PathBuf::from(&skill.meta.origin) .canonicalize() .map_err(|e| format!("来源路径已不存在:{e}"))?; - let in_managed = |base: &Path| { - base.join("skills") - .canonicalize() - .is_ok_and(|b| canonical.starts_with(b)) + let base = match resource.source { + crate::resources::ResourceSource::GlobalManaged => data_dir.join("skills"), + crate::resources::ResourceSource::ProjectManaged => { + project_dir.ok_or("Project scope closed")?.join("skills") + } + _ => return Err("This skill source cannot be deleted".into()), }; - let managed = in_managed(data_dir) || project_dir.is_some_and(in_managed); - if !managed { + let canonical_base = catalog::managed_root(&base) + .ok_or("Managed skill root is linked or outside its scope")?; + if !canonical.starts_with(&canonical_base) { return Err(format!("来源路径不在托管目录内:{}", skill.meta.origin)); } - // 目录形态 origin 指向 <目录>/SKILL.md:删除目标取父目录;单文件形态(xxx.md)即文件本身 - let target = if canonical.file_name().is_some_and(|n| n == "SKILL.md") { - canonical - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| canonical.clone()) - } else { - canonical.clone() + let origin = PathBuf::from(&skill.meta.origin); + let relative = origin + .strip_prefix(&base) + .map_err(|_| "Skill source scope changed")?; + // A single-file skills/SKILL.md must never turn into deletion of the skills root. + let target = match relative.components().count() { + 1 => origin.clone(), + 2 if origin.file_name().is_some_and(|name| name == "SKILL.md") => { + origin.parent().unwrap().to_path_buf() + } + _ => return Err("Unsupported skill deletion target".into()), }; + if target.canonicalize().map_err(|error| error.to_string())? == canonical_base + || std::fs::symlink_metadata(&target) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + { + return Err("Skill deletion cannot remove a source root or linked entry".into()); + } + let bytes = std::fs::read(&origin).map_err(|error| error.to_string())?; + let current = origin.canonicalize().map_err(|error| error.to_string())?; + if crate::resources::ResourceOrigin::file(resource.source, ¤t, &bytes) != *resource { + return Err("Skill source or content changed; reload before deleting".into()); + } + let target_canonical = target.canonicalize().map_err(|error| error.to_string())?; + if catalog::managed_root(&base).as_ref() != Some(&canonical_base) + || !target_canonical.starts_with(&canonical_base) + || target_canonical == canonical_base + || std::fs::symlink_metadata(&origin) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + || std::fs::symlink_metadata(&target) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + || (target != origin && current.parent() != Some(target_canonical.as_path())) + { + return Err("Skill deletion source changed or became linked".into()); + } let res = if target.is_dir() { std::fs::remove_dir_all(&target) } else { std::fs::remove_file(&target) }; res.map_err(|e| format!("删除失败:{e}"))?; - self.invalidate(); Ok(()) } @@ -532,45 +590,46 @@ impl SkillIndex { disabled: &[String], project_dir: Option<&Path>, ) -> HashMap { - let mut map: HashMap = HashMap::new(); - // 按低 → 高优先级插入(后者覆盖) - for (name, body) in builtin_skills() { - if let Some(mut s) = parse_skill_md(body, name) { - s.meta.origin = "".into(); - map.insert(s.meta.name.clone(), s); - } - } - if let Some(h) = home { - // 用户级兼容:~/.claude/skills < ~/.agents/skills(延续 .agents > .claude 相对顺序) - scan_dir_into(&h.join(".claude").join("skills"), &mut map); - scan_dir_into(&h.join(".agents").join("skills"), &mut map); - } - scan_dir_into(&workspace.join(".claude").join("skills"), &mut map); - scan_dir_into(&workspace.join(".agents").join("skills"), &mut map); - scan_dir_into(&data_dir.join("skills"), &mut map); - if let Some(pd) = project_dir { - scan_dir_into(&pd.join("skills"), &mut map); - } - for d in disabled { - map.remove(d); - } - // deletable 桶标记:仅托管目录(全局/项目 .codewave/skills)可删; - // 与删除第二道校验同源(/skills 前缀),尾部分隔符防字符串前缀混淆(C:/a/b 误匹配 C:/a/bc) - let mut managed: Vec = vec![format!( - "{}/", - norm_slash(&data_dir.join("skills").display().to_string()) - )]; - if let Some(pd) = project_dir { - managed.push(format!( - "{}/", - norm_slash(&pd.join("skills").display().to_string()) - )); - } - for s in map.values_mut() { - let o = norm_slash(&s.meta.origin); - s.meta.deletable = managed.iter().any(|p| o.starts_with(p.as_str())); + SkillCatalogSnapshot::build(home, workspace, data_dir, disabled, project_dir).effective + } + + fn snapshot( + &self, + workspace: &Path, + data_dir: &Path, + disabled: &[String], + project_dir: Option<&Path>, + ) -> Arc { + let key = cache_key(workspace, data_dir, project_dir, disabled); + let mut cache = self.cache.lock().unwrap(); + if let Some((at, cached_key, snapshot)) = cache.as_ref() + && *cached_key == key + && at.elapsed() < self.ttl + { + return snapshot.clone(); } - map + // Serialize rebuilds with invalidation so a late scan cannot restore a deleted catalog. + let snapshot = Arc::new(SkillCatalogSnapshot::build( + dirs::home_dir().as_deref(), + workspace, + data_dir, + disabled, + project_dir, + )); + *cache = Some((Instant::now(), key, snapshot.clone())); + snapshot + } + + pub fn audit( + &self, + workspace: &Path, + data_dir: &Path, + disabled: &[String], + project_dir: Option<&Path>, + ) -> SkillAudit { + self.snapshot(workspace, data_dir, disabled, project_dir) + .audit + .clone() } /// 按名取单个技能(命中 TTL 缓存则免扫描;未命中重建并回填缓存)。 @@ -582,20 +641,10 @@ impl SkillIndex { project_dir: Option<&Path>, name: &str, ) -> Option { - let key = cache_key(workspace, data_dir, project_dir, disabled); - { - let g = self.cache.lock().unwrap(); - if let Some((at, cached_key, map)) = g.as_ref() - && *cached_key == key - && at.elapsed() < self.ttl - { - return map.get(name).cloned(); - } - } - let map = Self::build(workspace, data_dir, disabled, project_dir); - let v = map.get(name).cloned(); - *self.cache.lock().unwrap() = Some((Instant::now(), key, map)); - v + self.snapshot(workspace, data_dir, disabled, project_dir) + .effective + .get(name) + .cloned() } /// 列出全部技能元数据(显示排序:内置 > 项目 .codewave/skills > 全局 ~/.codewave/skills > 其他, @@ -607,7 +656,6 @@ impl SkillIndex { disabled: &[String], project_dir: Option<&Path>, ) -> Vec { - let key = cache_key(workspace, data_dir, project_dir, disabled); // 路径统一正斜杠后做前缀归类(Windows 反斜杠与 origin 的 display 形态一致化) let norm = norm_slash; let proj_prefix = project_dir.map(|p| norm(&p.display().to_string())); @@ -628,51 +676,13 @@ impl SkillIndex { v.sort_by(|a, b| (bucket(&a.origin), &a.name).cmp(&(bucket(&b.origin), &b.name))); v }; - { - let g = self.cache.lock().unwrap(); - if let Some((at, cached_key, map)) = g.as_ref() - && *cached_key == key - && at.elapsed() < self.ttl - { - return sorted(map.values().map(|s| s.meta.clone()).collect()); - } - } - let map = Self::build(workspace, data_dir, disabled, project_dir); - let v = sorted(map.values().map(|s| s.meta.clone()).collect()); - *self.cache.lock().unwrap() = Some((Instant::now(), key, map)); - v - } -} - -/// 扫描一个技能目录并入索引:目录形态 `/SKILL.md`,单文件形态 `.md` 兼容。 -fn scan_dir_into(base: &PathBuf, map: &mut HashMap) { - let Ok(rd) = std::fs::read_dir(base) else { - return; - }; - for entry in rd.flatten() { - let dir = entry.path().join("SKILL.md"); - if !dir.is_file() { - // 单文件技能:name.md - let p = entry.path(); - if p.extension().and_then(|e| e.to_str()) == Some("md") - && p.is_file() - && let Ok(text) = std::fs::read_to_string(&p) - { - let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); - if let Some(mut s) = parse_skill_md(&text, stem) { - s.meta.origin = p.display().to_string(); - map.insert(s.meta.name.clone(), s); - } - } - continue; - } - if let Ok(text) = std::fs::read_to_string(&dir) { - let stem = entry.file_name().to_string_lossy().into_owned(); - if let Some(mut s) = parse_skill_md(&text, &stem) { - s.meta.origin = dir.display().to_string(); - map.insert(s.meta.name.clone(), s); - } - } + sorted( + self.snapshot(workspace, data_dir, disabled, project_dir) + .effective + .values() + .map(|s| s.meta.clone()) + .collect(), + ) } } @@ -702,6 +712,25 @@ pub fn prompt_listing(metas: &[SkillMeta]) -> String { mod tests { use super::*; + fn delete_current( + index: &SkillIndex, + workspace: &Path, + data_dir: &Path, + project_dir: Option<&Path>, + name: &str, + ) -> Result<(), String> { + let resource = SkillIndex::build(workspace, data_dir, &[], project_dir) + .remove(name) + .and_then(|skill| skill.meta.resource); + let identity = resource + .as_ref() + .map_or("", |resource| resource.identity.as_str()); + let version = resource + .as_ref() + .map_or("", |resource| resource.version.as_str()); + index.delete_skill(workspace, data_dir, project_dir, name, identity, version) + } + #[test] fn frontmatter_parsing() { let md = "---\nname: my-skill\ndescription: does things\nwhenToUse: when needed\n---\n# Body here\nStep 1."; @@ -820,7 +849,7 @@ mod tests { ) .unwrap(); // 工作区 .agents/skills(agents 约定);写成 CRLF 以延伸单测到真实读文件路径, - // 防止将来在 scan_dir_into 调用链上重新引入 LF 假设 + // Guard the catalog scan against reintroducing LF-only frontmatter parsing. let a = ws.path().join(".agents/skills/agent-only"); std::fs::create_dir_all(&a).unwrap(); std::fs::write( @@ -1000,20 +1029,13 @@ mod tests { "内置不可删" ); // 删除成功:目录形态整目录移除 + 缓存自动失效(下次 list 立即不可见) - idx.delete_skill(ws.path(), dd.path(), Some(pd.path()), "global-skill") - .unwrap(); + delete_current(&idx, ws.path(), dd.path(), Some(pd.path()), "global-skill").unwrap(); assert!(!dd.path().join("skills/global-skill").exists()); let metas2 = idx.list(ws.path(), dd.path(), &[], Some(pd.path())); assert!(!metas2.iter().any(|m| m.name == "global-skill")); // 拒绝:不可删来源 / 不存在 - assert!( - idx.delete_skill(ws.path(), dd.path(), Some(pd.path()), "compat-a") - .is_err() - ); - assert!( - idx.delete_skill(ws.path(), dd.path(), Some(pd.path()), "nope") - .is_err() - ); + assert!(delete_current(&idx, ws.path(), dd.path(), Some(pd.path()), "compat-a").is_err()); + assert!(delete_current(&idx, ws.path(), dd.path(), Some(pd.path()), "nope").is_err()); // 前缀混淆负例:临时会话(workspace == data_dir)下,工作区 .agents compat 技能 // origin 位于 data_dir 之下但不在 data_dir/skills 内 → 不可删(旧实现按 data_dir 整体前缀会误标) install(dd.path(), ".agents/skills/ddcompat", "ddcompat"); @@ -1028,12 +1050,10 @@ mod tests { ); // 已禁用的托管技能同样可删(全量索引查找,不与「不存在」混淆) install(pd.path(), "skills/dis", "dis"); - idx2.delete_skill(ws.path(), dd.path(), Some(pd.path()), "dis") - .unwrap(); + delete_current(&idx2, ws.path(), dd.path(), Some(pd.path()), "dis").unwrap(); assert!(!pd.path().join("skills/dis").exists()); // 项目托管技能可删 - idx.delete_skill(ws.path(), dd.path(), Some(pd.path()), "project-skill") - .unwrap(); + delete_current(&idx, ws.path(), dd.path(), Some(pd.path()), "project-skill").unwrap(); assert!(!pd.path().join("skills/project-skill").exists()); } diff --git a/src-tauri/src/tools/skill.rs b/src-tauri/src/tools/skill.rs index 8f0ae81e..f6904071 100644 --- a/src-tauri/src/tools/skill.rs +++ b/src-tauri/src/tools/skill.rs @@ -94,7 +94,9 @@ impl Tool for SkillTool { // 置于 caller-context 之后、闭合标签之前;规范文本自身不得含闭合标签(有测试守护)。 body.push_str(&format!("\n\n{ASK_INTERACTION_NORM}")); body.push_str("\n"); - ToolOutcome::ok(json!({ "skill": s.meta.name, "origin": s.meta.origin, "content": body })) + ToolOutcome::ok( + json!({ "skill": s.meta.name, "origin": s.meta.origin, "resource": s.meta.resource, "content": body }), + ) } } @@ -144,6 +146,22 @@ mod tests { let out = SkillTool.run(&ctx, json!({"skill": "demo"})).await; assert!(out.ok, "{out:?}"); assert_eq!(out.data["skill"], "demo"); + let audit = ctx + .core + .skills + .audit(&ctx.rt.workspace, &ctx.rt.data_dir, &[], None); + let resource = &audit + .entries + .iter() + .find(|entry| entry.name == "demo") + .unwrap() + .candidates[0] + .meta + .resource; + assert_eq!( + out.data["resource"], + serde_json::to_value(resource).unwrap() + ); let content = out.data["content"].as_str().unwrap(); assert!(content.contains("")); assert!(content.contains("DEMO BODY LINE")); diff --git a/src-tauri/src/tools/subagent.rs b/src-tauri/src/tools/subagent.rs index 1d7ecae3..8437b90a 100644 --- a/src-tauri/src/tools/subagent.rs +++ b/src-tauri/src/tools/subagent.rs @@ -666,7 +666,7 @@ impl Tool for SubagentTool { ToolOutcome::ok( // sub_id 放首位:它能在 tool_result 头部截断后幸存, // 供会话恢复关联过程历史文件([docs/subagents](../../../docs/subagents.md)) - json!({ "sub_id": sub_id, "role": args.role, "steps_budget": max_steps, + json!({ "sub_id": sub_id, "role": args.role, "role_resource": agent_def.map(|definition| definition.resource()), "steps_budget": max_steps, "steps_used": steps_used, "ended": ended_str, "report": clean_report, // tokens 同层输出:恢复路径回填卡片 token 数的唯一来源 //([docs/subagents](../../../docs/subagents.md)) diff --git a/ui/src/__tests__/SubagentModelsPanel.test.tsx b/ui/src/__tests__/SubagentModelsPanel.test.tsx index 9277e992..a12051e2 100644 --- a/ui/src/__tests__/SubagentModelsPanel.test.tsx +++ b/ui/src/__tests__/SubagentModelsPanel.test.tsx @@ -41,7 +41,7 @@ function ruleBody(selector: string): string { // 后端 agents::DELEGABLE_ROLES 顺序:剔除 title;任务约束「顺序 = 后端注册表」 // 与「只读角色 = explore / reviewer / code-reviewer」(后端 readonly=true)。 const fixtureAgents: AgentMeta[] = [ - { name: "explore", description: "只读源码调研" }, + { name: "explore", description: "只读源码调研", resource: { source: "builtin", identity: "builtin:agent:explore", version: "role-v1" } }, { name: "backend-dev", description: "后端实现" }, { name: "frontend-dev", description: "前端实现" }, { name: "app-dev", description: "App 实现" }, @@ -192,6 +192,9 @@ describe("SubagentModelsPanel:8 行下拉与只读角色", () => { "explore", "backend-dev", "frontend-dev", "app-dev", "reviewer", "product-manager", "code-reviewer", "tester", ]); + expect(document.body.textContent).toContain("builtin:agent:explore"); + expect(document.body.textContent).toContain("版本:role-v1"); + expect(document.body.textContent).not.toContain("title"); }); it("每行首项「继承父会话」存在且 value=null", async () => { diff --git a/ui/src/__tests__/app.smoke.test.tsx b/ui/src/__tests__/app.smoke.test.tsx index c231f7a3..4e23b08b 100644 --- a/ui/src/__tests__/app.smoke.test.tsx +++ b/ui/src/__tests__/app.smoke.test.tsx @@ -44,6 +44,9 @@ const fixtureMessages = [ { role: "assistant", content: [{ type: "text", text: "你好!有什么可以帮你?" }] }, ]; +const fixtureSkill = { name: "demo", description: "d", whenToUse: "w", origin: "x", deletable: false, + resource: { source: "workspace_agents" as const, identity: "x", version: "fixture-v1" } }; + // resolve_ask captor (read by the ask-submit regression case; avoids per-case overrides polluting the global mock) const resolveAskLog: any[] = []; @@ -79,7 +82,9 @@ vi.mock("@tauri-apps/api/core", () => ({ case "get_mcp_config": return "{}"; case "mcp_status": return []; case "list_scheduled_tasks": return []; // 任务列表:左栏任务区与任务页都从这里取(stores/tasks) - case "list_skills": return [{ name: "demo", description: "d", whenToUse: "w", origin: "x" }]; + case "inspect_skills": return { entries: [{ name: fixtureSkill.name, disabled: false, + candidates: [{ meta: fixtureSkill, selected: true }] }], issues: [] }; + case "list_skills": return [fixtureSkill]; case "list_agents": return [{ name: "backend-dev", description: "后端实现" }]; case "search_workspace_paths": return []; case "list_workspace_dir": return { entries: [] }; diff --git a/ui/src/__tests__/notify-reveal.test.tsx b/ui/src/__tests__/notify-reveal.test.tsx index 66aed0b6..692aecd0 100644 --- a/ui/src/__tests__/notify-reveal.test.tsx +++ b/ui/src/__tests__/notify-reveal.test.tsx @@ -54,6 +54,7 @@ vi.mock("@tauri-apps/api/core", () => ({ case "git_user_info": return { name: null, email: null }; case "get_token_breakdown": return null; case "mcp_status": return []; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; case "notify_system": return "native"; default: return null; diff --git a/ui/src/__tests__/settings.mcp.test.tsx b/ui/src/__tests__/settings.mcp.test.tsx index 7803779a..dbc28fef 100644 --- a/ui/src/__tests__/settings.mcp.test.tsx +++ b/ui/src/__tests__/settings.mcp.test.tsx @@ -88,6 +88,7 @@ async function baseInvoke(cmd: string, args?: any) { case "mcp_test": return testReply; case "mcp_snapshot": return { session: "s1", servers: JSON.parse(JSON.stringify(statusReply.map((s) => ({ scope: "global", ...s })))) }; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; default: throw new Error(`unmocked command: ${cmd}`); } diff --git a/ui/src/__tests__/settings.network.test.tsx b/ui/src/__tests__/settings.network.test.tsx index 7c8a2d52..4f059cf7 100644 --- a/ui/src/__tests__/settings.network.test.tsx +++ b/ui/src/__tests__/settings.network.test.tsx @@ -38,6 +38,7 @@ async function baseInvoke(cmd: string, _args?: any) { case "save_config": savedConfigs.push(JSON.parse(JSON.stringify(_args?.config))); return configSaved(_args?.config); case "get_mcp_config": return "{}"; case "mcp_status": return []; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; case "list_available_shells": return []; case "resolve_proxy": return "http://127.0.0.1:9999"; diff --git a/ui/src/__tests__/settings.page.test.tsx b/ui/src/__tests__/settings.page.test.tsx index b09a8a67..a0548654 100644 --- a/ui/src/__tests__/settings.page.test.tsx +++ b/ui/src/__tests__/settings.page.test.tsx @@ -69,6 +69,7 @@ async function baseInvoke(cmd: string, args?: any) { case "get_token_stats": return []; case "get_mcp_config": return "{}"; case "mcp_status": return []; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; case "list_agents": return []; case "search_workspace_paths": return []; diff --git a/ui/src/__tests__/settings.skills.test.tsx b/ui/src/__tests__/settings.skills.test.tsx index 33e41964..85877d40 100644 --- a/ui/src/__tests__/settings.skills.test.tsx +++ b/ui/src/__tests__/settings.skills.test.tsx @@ -1,14 +1,15 @@ import { configSnapshot, configSaved } from "./configFixtures"; -// 设置页技能页(自「工具与集成」拆出):来源短标签 + 重新加载(reload_skills 清缓存重扫)+ 托管技能删除(delete_skill + Popconfirm)。 +// 审计清单独立于启用索引;删除绑定真实来源和版本,重扫展示下层回退。 // 挂载方式与 shell.settings.test.tsx 同源(standalone + useUi 控制开关),两字按钮按去空白文本匹配。 import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { App as AntApp } from "antd"; // 必须与组件同源(主入口):es/app 子路径会产生另一个 context import "../i18n"; import SettingsPage from "../features/panels/SettingsPage"; import { useUi } from "../stores/ui"; import { useSettings } from "../stores/settings"; -import type { ConfigState } from "../ipc/types"; +import type { ConfigState, SkillInspection, SkillMeta } from "../ipc/types"; +import SkillsPanel from "../features/panels/SkillsPanel"; function makeConfig(overrides: Partial = {}): ConfigState { return { @@ -31,25 +32,36 @@ function makeConfig(overrides: Partial = {}): ConfigState { } // 三来源覆盖:内置(不可删/显示「内置」)、托管目录(可删/短标签取技能目录名)、compat 目录(不可删) -const fixtureSkills = [ +const fixtureSkills: SkillMeta[] = [ { name: "builtin-skill", description: "内置技能", whenToUse: "", origin: "", deletable: false }, - { name: "demo", description: "项目技能", whenToUse: "输入 /demo 时", origin: "C:/proj/.codewave/skills/demo-dir/SKILL.md", deletable: true }, + { name: "demo", description: "项目技能", whenToUse: "输入 /demo 时", origin: "C:/proj/.codewave/skills/demo-dir/SKILL.md", deletable: true, + resource: { source: "project_managed", identity: "C:/proj/.codewave/skills/demo-dir/SKILL.md", version: "project-v1" } }, { name: "compat", description: "兼容技能", whenToUse: "", origin: "C:/ws/.claude/skills/compat-dir/SKILL.md", deletable: false }, ]; let calls: string[] = []; +let deleted = false; +let configuredDisabled: string[] = []; +const fallback: SkillMeta = { name: "demo", description: "下层技能", whenToUse: "", origin: "", deletable: false, + resource: { source: "builtin", identity: "builtin:skill:demo", version: "builtin-v1" } }; + +function inspection(reload = false): SkillInspection { + const entries = structuredClone(fixtureSkills).map((meta) => ({ name: meta.name, disabled: configuredDisabled.includes(meta.name), + candidates: meta.name === "demo" ? deleted ? [{ meta: fallback, selected: true }] : [{ meta, selected: true }, { meta: fallback, selected: false }] : [{ meta, selected: true }] })); + if (reload && !deleted) entries.push({ name: "fresh", disabled: false, candidates: [{ meta: { name: "fresh", description: "新技能", whenToUse: "", origin: "", deletable: false }, selected: true }] }); + return { entries, issues: [] }; +} async function baseInvoke(cmd: string, args?: any) { calls.push(args ? `${cmd}:${JSON.stringify(args)}` : cmd); switch (cmd) { case "get_config": return configSnapshot(makeConfig()); - case "save_config": return configSaved(args?.config); + case "save_config": configuredDisabled = args.config.disabled_skills; return configSaved(args?.config); case "list_available_shells": return []; case "get_mcp_config": return "{}"; case "mcp_status": return []; - case "list_skills": return JSON.parse(JSON.stringify(fixtureSkills)); - case "reload_skills": return JSON.parse(JSON.stringify([...fixtureSkills, { name: "fresh", description: "新技能", whenToUse: "", origin: "C:/ws/.codewave/skills/fresh/SKILL.md", deletable: true }])); - case "delete_skill": return null; + case "inspect_skills": return inspection(args?.reload); + case "delete_skill": deleted = true; return null; default: throw new Error(`unmocked command: ${cmd}`); } } @@ -71,6 +83,8 @@ afterEach(async () => { useUi.setState({ settingsOpen: false, settingsTab: "appearance" }); useSettings.setState({ config: null, snapshot: null, loaded: false, rolePending: {}, roleErrors: {}, roleRetry: {} }); calls = []; + deleted = false; + configuredDisabled = []; }); function buttonByText(text: string): HTMLElement { @@ -81,8 +95,9 @@ function buttonByText(text: string): HTMLElement { return btn as HTMLElement; } -async function openSkillsTab() { - useSettings.setState({ config: makeConfig(), snapshot: configSnapshot(makeConfig()), loaded: true }); +async function openSkillsTab(config = makeConfig()) { + configuredDisabled = config.disabled_skills; + useSettings.setState({ config, snapshot: configSnapshot(config), loaded: true }); useUi.setState({ settingsOpen: true, settingsTab: "skills" }); render( @@ -109,22 +124,24 @@ describe("设置页技能页:禁用清单", () => { expect(delBtns).toHaveLength(1); }); - it("点击重新加载:调用 reload_skills 且返回列表立即渲染(新技能可见)", async () => { + it("点击重新加载:绕过缓存重查审计清单,新技能立即可见", async () => { await openSkillsTab(); fireEvent.click(buttonByText("重新加载")); - await waitFor(() => expect(calls.some((c) => c.startsWith("reload_skills"))).toBe(true)); + await waitFor(() => expect(calls.some((c) => c.startsWith("inspect_skills:") && c.includes('"reload":true'))).toBe(true)); // 重载返回的最新列表渲染(新技能出现;按描述定位避免与来源短标签同名撞车) await waitFor(() => expect(screen.getByText("新技能")).toBeTruthy()); // 成功提示 await waitFor(() => expect(document.body.textContent ?? "").toContain("已重新加载,共 4 个技能")); }); - it("删除:Popconfirm 列明路径,确认后调用 delete_skill 并移除该行", async () => { + it("删除:确认目录语义,提交来源和版本,重查后展示真实下层回退", async () => { await openSkillsTab(); fireEvent.click(document.querySelector('button[aria-label="删除技能"]') as HTMLElement); // Popconfirm 出现:标题含技能名,description 列明完整路径 await waitFor(() => expect(document.body.textContent ?? "").toContain("删除技能「demo」?")); expect(document.body.textContent ?? "").toContain("C:/proj/.codewave/skills/demo-dir/SKILL.md"); + expect(document.body.textContent ?? "").toContain("删除整个技能目录及内容"); + expect(document.body.textContent ?? "").toContain("单文件形态只删除该文件"); // antd 默认 locale(无 ConfigProvider):确认按钮 OK const ok = Array.from(document.querySelectorAll(".ant-popover button")).find( (b) => (b.textContent ?? "").replace(/\s/g, "") === "OK", @@ -132,17 +149,19 @@ describe("设置页技能页:禁用清单", () => { expect(ok).toBeTruthy(); fireEvent.click(ok); await waitFor(() => - expect(calls.some((c) => c.startsWith("delete_skill:") && c.includes('"name":"demo"'))).toBe(true), + expect(calls).toContain('delete_skill:{"sessionId":null,"name":"demo","expectedSource":"C:/proj/.codewave/skills/demo-dir/SKILL.md","expectedVersion":"project-v1"}'), ); // 行移除 + 成功提示 await waitFor(() => expect(screen.queryByText("demo-dir")).toBeNull()); + expect(screen.getByText("下层技能")).toBeTruthy(); + expect(screen.getByText("demo")).toBeTruthy(); await waitFor(() => expect(document.body.textContent ?? "").toContain("已删除技能:demo")); }); it("技能为空时显示「暂无技能」空态(批④ 修的缺陷:此前显示「暂无会话」)", async () => { const invoke = await invokeMock(); invoke.mockImplementation(async (cmd: string, args?: any) => - cmd === "list_skills" ? [] : baseInvoke(cmd, args), + cmd === "inspect_skills" ? { entries: [], issues: [] } : baseInvoke(cmd, args), ); useSettings.setState({ config: makeConfig(), snapshot: configSnapshot(makeConfig()), loaded: true }); useUi.setState({ settingsOpen: true, settingsTab: "skills" }); @@ -174,4 +193,144 @@ describe("设置页技能页:禁用清单", () => { // 失败路径:行保留(deletable 标记仍在,不误删 UI 状态) expect(screen.getByText("demo-dir")).toBeTruthy(); }); + + it("展开同名影子来源,只有 selected 来源可删除;缺失版本时删除不可用", async () => { + const invoke = await invokeMock(); + invoke.mockImplementation(async (cmd: string, args?: any) => { + if (cmd !== "inspect_skills") return baseInvoke(cmd, args); + const result = inspection(); + delete result.entries[1].candidates[0].meta.resource; + return result; + }); + await openSkillsTab(); + expect(screen.queryByText("builtin:skill:demo")).toBeNull(); + fireEvent.click(screen.getByText("被覆盖的来源(1)")); + await waitFor(() => expect(screen.getByText("builtin:skill:demo")).toBeTruthy()); + expect(screen.getByText("版本:builtin-v1")).toBeTruthy(); + expect(document.querySelector('button[aria-label="删除技能"]')?.hasAttribute("disabled")).toBe(true); + }); + + it("禁用技能重开仍显示,开关只改草稿;取消后保留已保存禁用状态", async () => { + await openSkillsTab(makeConfig({ disabled_skills: ["demo"] })); + const toggle = screen.getByRole("switch", { name: "启用技能 demo" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(screen.getByText("当前运行:禁用")).toBeTruthy(); + const reads = calls.filter((call) => call.startsWith("inspect_skills:")).length; + fireEvent.click(toggle); + expect(screen.getByText("待保存:启用")).toBeTruthy(); + expect(calls.some((call) => call.startsWith("toggle_skill") || call.startsWith("save_config"))).toBe(false); + expect(calls.filter((call) => call.startsWith("inspect_skills:")).length).toBe(reads); + fireEvent.click(buttonByText("取消")); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + expect(screen.queryByText("待保存:启用")).toBeNull(); + cleanup(); + await openSkillsTab(makeConfig({ disabled_skills: ["demo"] })); + expect(screen.getByRole("switch", { name: "启用技能 demo" }).getAttribute("aria-checked")).toBe("false"); + }); + + it("保存启停草稿通过现有配置提交;缺文件的禁用名称仍可重新启用", async () => { + const invoke = await invokeMock(); + invoke.mockImplementation(async (cmd: string, args?: any) => { + if (cmd !== "inspect_skills") return baseInvoke(cmd, args); + const result = inspection(); + result.entries.push({ name: "missing", disabled: true, candidates: [] }); + return result; + }); + await openSkillsTab(makeConfig({ disabled_skills: ["missing"] })); + expect(screen.getByText("来源文件不存在,保留禁用配置供检查或重新启用。")).toBeTruthy(); + fireEvent.click(screen.getByRole("switch", { name: "启用技能 missing" })); + fireEvent.click(buttonByText("保存")); + await waitFor(() => expect(calls.some((call) => call.startsWith("save_config:") && call.includes('"disabled_skills":[]'))).toBe(true)); + await waitFor(() => expect(screen.queryByText("待保存:启用")).toBeNull()); + }); + + it("读取失败明确显示且可重试;部分目录诊断不掩盖可用候选", async () => { + const invoke = await invokeMock(); + let failed = true; + invoke.mockImplementation(async (cmd: string, args?: any) => { + if (cmd !== "inspect_skills") return baseInvoke(cmd, args); + if (failed) throw new Error("目录读取被拒绝"); + return { ...inspection(), issues: [{ path: "/private/skills", message: "无读取权限" }] }; + }); + useSettings.setState({ config: makeConfig(), snapshot: configSnapshot(makeConfig()), loaded: true }); + useUi.setState({ settingsOpen: true, settingsTab: "skills" }); + render(); + await waitFor(() => expect(screen.getByText("目录读取被拒绝")).toBeTruthy()); + expect(screen.queryByText("暂无技能")).toBeNull(); + failed = false; + fireEvent.click(buttonByText("重新加载")); + await waitFor(() => expect(screen.getByText("demo")).toBeTruthy()); + expect(screen.getByText("/private/skills")).toBeTruthy(); + expect(screen.getByText("无读取权限")).toBeTruthy(); + }); + + it("切换会话后迟到的读取和删除结果不能覆盖新项目", async () => { + const invoke = await invokeMock(); + let settleOld: ((value: SkillInspection) => void) | undefined; + let settleDelete: (() => void) | undefined; + invoke.mockImplementation(async (cmd: string, args?: any) => { + if (cmd === "inspect_skills") { + if (args.sessionId === "old") return new Promise((resolve) => { settleOld = resolve; }); + const result = inspection(); + result.entries[1].name = "new-project"; + return result; + } + if (cmd === "delete_skill") return new Promise((resolve) => { settleDelete = resolve; }); + return baseInvoke(cmd, args); + }); + const props = { disabled: [], savedDisabled: [], onToggle: vi.fn() }; + const view = render(); + await waitFor(() => expect(settleOld).toBeTypeOf("function")); + view.rerender(); + await waitFor(() => expect(screen.getByText("new-project")).toBeTruthy()); + await act(async () => { settleOld!(inspection()); }); + await waitFor(() => expect(screen.queryByText("demo")).toBeNull()); + fireEvent.click(document.querySelector('button[aria-label="删除技能"]') as HTMLElement); + const ok = await waitFor(() => buttonByText("OK")); + fireEvent.click(ok); + await waitFor(() => expect(settleDelete).toBeTypeOf("function")); + view.rerender(); + await waitFor(() => expect(document.querySelector('[aria-busy="false"]')).toBeTruthy()); + await act(async () => { settleDelete!(); }); + await waitFor(() => expect(screen.queryByText("已删除技能:demo")).toBeNull()); + expect(screen.getByText("new-project")).toBeTruthy(); + }); + + it("运行状态来自实际审计,保存基线变化才重新读取,草稿变化不读取", async () => { + const invoke = await invokeMock(); + let actualDisabled = true; + invoke.mockImplementation(async (cmd: string, args?: any) => { + if (cmd !== "inspect_skills") return baseInvoke(cmd, args); + const result = inspection(); + result.entries = result.entries.filter((entry) => entry.name === "demo"); + result.entries[0].disabled = actualDisabled; + return result; + }); + const onToggle = vi.fn(); + const view = render(); + await waitFor(() => expect(screen.getByText("当前运行:禁用")).toBeTruthy()); + const countReads = () => invoke.mock.calls.filter(([cmd]: [string]) => cmd === "inspect_skills").length; + const reads = countReads(); + view.rerender(); + expect(screen.getByText("待保存:禁用")).toBeTruthy(); + expect(countReads()).toBe(reads); + actualDisabled = false; + view.rerender(); + await waitFor(() => expect(countReads()).toBe(reads + 1)); + await waitFor(() => expect(screen.getByText("当前运行:启用")).toBeTruthy()); + expect(screen.queryByText("待保存:禁用")).toBeNull(); + }); + + it("切换会话关闭旧删除确认,迟到确认不能发送删除 IPC", async () => { + const props = { disabled: [], savedDisabled: [], onToggle: vi.fn() }; + const view = render(); + await waitFor(() => expect(screen.getByText("demo")).toBeTruthy()); + fireEvent.click(document.querySelector('button[aria-label="删除技能"]') as HTMLElement); + const oldConfirm = await waitFor(() => buttonByText("OK")); + view.rerender(); + await waitFor(() => expect(document.querySelector('[aria-busy="false"]')).toBeTruthy()); + await act(async () => { fireEvent.click(oldConfirm); }); + expect(calls.some((call) => call.startsWith("delete_skill:"))).toBe(false); + expect(screen.getByText("demo-dir")).toBeTruthy(); + }); }); diff --git a/ui/src/__tests__/shell.settings.test.tsx b/ui/src/__tests__/shell.settings.test.tsx index ddea2780..cd70d900 100644 --- a/ui/src/__tests__/shell.settings.test.tsx +++ b/ui/src/__tests__/shell.settings.test.tsx @@ -48,6 +48,7 @@ async function baseInvoke(cmd: string, _args?: any) { case "list_available_shells": return JSON.parse(JSON.stringify(fixtureShells)); case "get_mcp_config": return "{}"; case "mcp_status": return []; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; default: throw new Error(`unmocked command: ${cmd}`); } diff --git a/ui/src/__tests__/theme.test.tsx b/ui/src/__tests__/theme.test.tsx index e898aa1b..16dfcd90 100644 --- a/ui/src/__tests__/theme.test.tsx +++ b/ui/src/__tests__/theme.test.tsx @@ -49,6 +49,7 @@ vi.mock("@tauri-apps/api/core", () => ({ return { system_tokens: 1, history_tokens: 2, tool_results_tokens: 0, tool_schema_tokens: 3, total_tokens: 6, context_window: 128000, ratio: 0.01 }; case "get_mcp_config": return "{}"; case "mcp_status": return []; + case "inspect_skills": return { entries: [], issues: [] }; case "list_skills": return []; case "list_agents": return []; case "search_workspace_paths": return []; diff --git a/ui/src/features/panels/SettingsPage.tsx b/ui/src/features/panels/SettingsPage.tsx index 6d3ca7db..0583a99c 100644 --- a/ui/src/features/panels/SettingsPage.tsx +++ b/ui/src/features/panels/SettingsPage.tsx @@ -13,7 +13,7 @@ import { fontSizeCss } from "../../utils/fonts"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import type { ComponentType, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; import { - Alert, App, Button, Empty, Input, InputNumber, Modal, Popconfirm, Radio, Select, Slider, Switch, Tooltip, Typography, + Alert, App, Button, Input, InputNumber, Modal, Popconfirm, Radio, Select, Slider, Switch, Tooltip, Typography, Tabs, } from "antd"; import { @@ -25,10 +25,9 @@ import { useTranslation } from "react-i18next"; import { ipc } from "../../ipc/client"; import { mcpStatusEventRevision } from "../../utils/mcpStatusRevision"; import { DEFAULT_POST_WRITE_CHECK } from "../../ipc/types"; -import type { CleanupOutcome, CleanupPreview, CleanupStatus, ConfigState, ConfigVersion, CredentialEdit, LegacyCleanupPreview, McpConfigIssue, McpScope, McpServerView, McpStatusPayload, PostWriteCheckSettings, ShellInfo, SkillMeta } from "../../ipc/types"; +import type { CleanupOutcome, CleanupPreview, CleanupStatus, ConfigState, ConfigVersion, CredentialEdit, LegacyCleanupPreview, McpConfigIssue, McpScope, McpServerView, McpStatusPayload, PostWriteCheckSettings, ShellInfo } from "../../ipc/types"; // 清理提示的去重口径与启动轻提示共用一份(详见 utils/cleanupNotice.ts):设置页展示过结果就写记录 import { markCleanupNoticeSeen } from "../../utils/cleanupNotice"; -import { originLabel } from "../../utils/skills"; import { fullscreenNavWidth } from "../../utils/layout"; import { respondExitRequest } from "../../utils/uiState"; import { useActiveId, useSessions } from "../../stores/sessions"; @@ -58,6 +57,7 @@ import { McpArgTable, McpKvTable } from "./McpKvTable"; import { AppearanceSettings } from "./FontSettings"; import ProvidersPanel, { validateProvider } from "./ProvidersPanel"; import SubagentModelsPanel from "./SubagentModelsPanel"; +import SkillsPanel from "./SkillsPanel"; import { INSTANT_APPLY_FIELD_IDS, MCP_FIELD_ID, @@ -304,10 +304,6 @@ function SettingsPageController() { const saveAttempt = useRef<{ payload: string; operationId: string } | null>(null); const [showAllCommands, setShowAllCommands] = useState(false); const [saving, setSaving] = useState(false); - const [skills, setSkills] = useState([]); - // 技能区异步操作 loading:reloadSkills 全局、删除按行(Popconfirm 确认按钮 loading) - const [skillsBusy, setSkillsBusy] = useState(false); - const [deletingName, setDeletingName] = useState(null); // 受控页:上收到 useUi([docs/providers-and-network](../../../../docs/providers-and-network.md)),外部可指定页打开设置页; // 下方保存校验跳页也走同一 store 状态([docs/providers-and-network](../../../../docs/providers-and-network.md)) const tab = normalizePageKey(useUi((s) => s.settingsTab)); @@ -495,7 +491,6 @@ function SettingsPageController() { // 项目层是否可用:探测一次(不可用则该作用域在切换器里禁用并给提示) const proj = await ipc.mcpListConfig("project", sessionId ?? undefined).catch(() => null); if (useSessions.getState().activeKey === sessionId) setMcpProjectPath(proj?.path ?? null); - setSkills(await ipc.listSkills(sessionId).catch(() => [])); const snapshotRequest = ++latestMcpSnapshotRequest; const st = await readMcpStatus(); if (snapshotRequest === latestMcpSnapshotRequest && useSessions.getState().activeKey === sessionId) { @@ -549,38 +544,6 @@ function SettingsPageController() { patchDraft({ disabled_skills: disabledSkills }); } - /** 重新加载技能:后端清空索引缓存重扫(绕过 10s TTL),新放入/修改的技能立即可见。 */ - async function reloadSkills() { - setSkillsBusy(true); - try { - const list = await ipc.reloadSkills(sessionId); - setSkills(list); - message.success(t("settings.skillsReloaded", { n: list.length })); - } catch (e) { - message.error(`${t("settings.skillsReloadFailed")}\n${String(e).replace(/^Error[:\s]*/i, "")}`); - } finally { - setSkillsBusy(false); - } - } - - /** 删除托管技能(可删性由后端 deletable 标记 + canonicalize 前缀双重校验;前端只传 name)。 */ - async function removeSkill(name: string) { - setDeletingName(name); - try { - await ipc.deleteSkill(sessionId, name); - message.success(t("settings.deleteSkillSuccess", { name })); - setSkills((prev) => prev.filter((s) => s.name !== name)); - // 顺带清理 draft.disabled_skills 残留名,避免脏名随下次保存持久化 - if (draft?.disabled_skills.includes(name)) { - patchDraft({ disabled_skills: draft.disabled_skills.filter((s) => s !== name) }); - } - } catch (e) { - message.error(`${t("settings.deleteSkillFailed")}\n${String(e).replace(/^Error[:\s]*/i, "")}`); - } finally { - setDeletingName(null); - } - } - // ---------- 会话保留期与清理([docs/sessions-and-projects](../../../../docs/sessions-and-projects.md)) ---------- /** 保留期下拉的草稿写入("never" = 不清理 → null);走页级保存,不是即时生效项。 @@ -2077,57 +2040,8 @@ function SettingsPageController() { // (技能行:名称 / 来源 / 开关 / 删除)。 body: draft && ( <> - {/* 技能清单复用设置页的分组标题与卡片,保留搜索锚点及删除权限。 */} - void reloadSkills()}>{t("settings.reloadSkills")} - }> -
-

{t("settings.skillsHint")}

- {skills.length === 0 && } - {skills.map((s) => { - const builtin = s.origin === ""; - const label = originLabel(s.origin); - return ( -
-
-
- {s.name} - {builtin ? ( - {t("common.builtin")} - ) : ( - label && {label} - )} -
- {s.description &&
{s.description}
} - {s.whenToUse &&
{t("settings.skillWhen")}{s.whenToUse}
} -
-
- toggleSkill(s.name, !v)} - /> - {s.deletable && ( - void removeSkill(s.name)} - > - - - )} -
-
- ); - })} -
-
+ ), }, diff --git a/ui/src/features/panels/SkillsPanel.tsx b/ui/src/features/panels/SkillsPanel.tsx new file mode 100644 index 00000000..db43867c --- /dev/null +++ b/ui/src/features/panels/SkillsPanel.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, App, Button, Collapse, Empty, Popconfirm, Space, Switch, Tag, Typography } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; +import { useTranslation } from "react-i18next"; +import { ipc } from "../../ipc/client"; +import type { ResourceOrigin, SkillInspection, SkillMeta } from "../../ipc/types"; +import { originLabel } from "../../utils/skills"; +import { SettingsSection } from "./settings/SettingsTheme"; + +export function ResourceDetails({ resource }: { resource: ResourceOrigin }) { + const { t } = useTranslation(); + const labels: Record = { + builtin: t("settings.resourceSource_builtin"), + user_claude: t("settings.resourceSource_user_claude"), + user_agents: t("settings.resourceSource_user_agents"), + workspace_claude: t("settings.resourceSource_workspace_claude"), + workspace_agents: t("settings.resourceSource_workspace_agents"), + global_managed: t("settings.resourceSource_global_managed"), + project_managed: t("settings.resourceSource_project_managed"), + }; + return + {labels[resource.source]} + {resource.identity} + {t("settings.resourceVersion", { version: resource.version })} + ; +} + +/** Inspection keeps disabled and shadowed candidates separate from the enabled composer index. + * Toggling stays in the page draft; deleting re-inspects the backend's actual fallback. */ +export default function SkillsPanel({ sessionId, disabled, savedDisabled, onToggle }: { + sessionId: string | null; + disabled: string[]; + savedDisabled: string[]; + onToggle: (name: string, disabled: boolean) => void; +}) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [inspection, setInspection] = useState({ entries: [], issues: [] }); + const [inspectionScope, setInspectionScope] = useState(sessionId); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(true); + const [deleting, setDeleting] = useState(null); + const owner = useRef(sessionId); + owner.current = sessionId; + const generation = useRef(0); + const deleteSequence = useRef(0); + const mounted = useRef(false); + const savedIdentity = JSON.stringify(savedDisabled); + const previousBaseline = useRef({ sessionId, savedIdentity }); + + useEffect(() => { + mounted.current = true; + setInspection({ entries: [], issues: [] }); + setInspectionScope(sessionId); + setDeleting(null); + void inspect(false); + return () => { mounted.current = false; generation.current += 1; deleteSequence.current += 1; }; + // The session is the inspection scope; draft changes must never reload or persist it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]); + + useEffect(() => { + const previous = previousBaseline.current; + previousBaseline.current = { sessionId, savedIdentity }; + if (previous.sessionId === sessionId && previous.savedIdentity !== savedIdentity) void inspect(false); + // Saved configuration can change actual availability; unsaved toggles never trigger a read. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, savedIdentity]); + + async function inspect(reload: boolean, announce = false) { + const request = ++generation.current; + const scope = sessionId; + const current = () => mounted.current && owner.current === scope && generation.current === request; + setBusy(true); + setError(null); + try { + const result = await ipc.inspectSkills(scope, reload); + if (!current()) return; + setInspection(result); + setInspectionScope(scope); + if (announce) message.success(t("settings.skillsReloaded", { n: result.entries.length })); + } catch (e) { + if (current()) setError(String(e).replace(/^Error[:\s]*/i, "")); + } finally { + if (current()) setBusy(false); + } + } + + async function remove(meta: SkillMeta) { + if (!mounted.current || owner.current !== sessionId || !meta.resource || !meta.deletable || busy || deleting) return; + const scope = sessionId; + const operation = ++deleteSequence.current; + const current = () => mounted.current && owner.current === scope && deleteSequence.current === operation; + setDeleting(meta.name); + try { + await ipc.deleteSkill(scope, meta.name, meta.resource.identity, meta.resource.version); + if (!current()) return; + message.success(t("settings.deleteSkillSuccess", { name: meta.name })); + await inspect(true); + } catch (e) { + if (current()) message.error(`${t("settings.deleteSkillFailed")}\n${String(e).replace(/^Error[:\s]*/i, "")}`); + } finally { + if (mounted.current && owner.current === scope && deleteSequence.current === operation) setDeleting(null); + } + } + + const visible = inspectionScope === sessionId ? inspection : { entries: [], issues: [] }; + const visibleError = inspectionScope === sessionId ? error : null; + const loading = busy || inspectionScope !== sessionId; + return void inspect(true, true)}>{t("settings.reloadSkills")} + }> +
+

{t("settings.skillsHint")}

+ {visibleError && } + {visible.issues.map((issue, index) => )} + {!loading && !visibleError && visible.entries.length === 0 && } + {visible.entries.map((entry) => { + const selected = entry.candidates.find((candidate) => candidate.selected)?.meta; + const shadows = entry.candidates.filter((candidate) => !candidate.selected); + const isDisabled = disabled.includes(entry.name); + const saved = savedDisabled.includes(entry.name); + return
+
+
+ {entry.name} + {selected && {selected.origin === "" ? t("common.builtin") : originLabel(selected.origin)}} + {t(entry.disabled ? "settings.skillRunningDisabled" : "settings.skillRunningEnabled")} + {saved !== isDisabled && {t(isDisabled ? "settings.skillDraftDisabled" : "settings.skillDraftEnabled")}} +
+ {selected?.description &&
{selected.description}
} + {selected?.whenToUse &&
{t("settings.skillWhen")}{selected.whenToUse}
} + {selected?.resource && } + {!selected && {t("settings.skillMissing")}} + {shadows.length > 0 && {shadows.map(({ meta }, index) =>
+ {meta.description || meta.name} + {meta.resource ? : {meta.origin}} +
)}, + }]} />} +
+
+ onToggle(entry.name, !value)} /> + {selected?.deletable && {t("settings.deleteSkillDirectory")}{selected.origin}} + disabled={!selected.resource || busy || !!deleting} + okButtonProps={{ danger: true, loading: deleting === entry.name }} + onConfirm={() => remove(selected)} + >} +
+
; + })} +
+
; +} diff --git a/ui/src/features/panels/SubagentModelsPanel.tsx b/ui/src/features/panels/SubagentModelsPanel.tsx index 24b76ac0..14563806 100644 --- a/ui/src/features/panels/SubagentModelsPanel.tsx +++ b/ui/src/features/panels/SubagentModelsPanel.tsx @@ -11,12 +11,13 @@ // - 「继承父会话」= UI 哨兵 `INHERIT_SENTINEL`(绝不可能撞 kebab-case wire id); // 选中后内部翻译回 `null` 写 store(与后端 Option 同形)。 import { useEffect, useMemo, useState } from "react"; -import { App, Button, Flex, Select, Tag } from "antd"; +import { Alert, App, Button, Flex, Select, Tag } from "antd"; import { useTranslation } from "react-i18next"; import { ipc } from "../../ipc/client"; import { useSettings } from "../../stores/settings"; import { flattenModels, findModel } from "../../utils/models"; import type { AgentMeta } from "../../ipc/types"; +import { ResourceDetails } from "./SkillsPanel"; /** * 只读角色集合:与后端 `agents::DELEGABLE_ROLES` 顺序对齐,剔除内部 title。 @@ -44,14 +45,15 @@ export default function SubagentModelsPanel() { const retry = useSettings((s) => s.roleRetry); // 后端 list_agents 已经按 DELEGABLE_ROLES 顺序返回;title 角色在 backend 那一侧剔除(agents::delegable)。 const [agents, setAgents] = useState([]); + const [loadError, setLoadError] = useState(null); // 拉一次:组件挂载时拉,子代理注册表变更需要重启应用生效(与 settings 全量刷新同语义)。 - // 失败时静默回 []:每个角色下拉降级为「只显示首项继承父会话」,留待用户回到列表可见时排查。 + // 注册表失败可见,避免空白列表被误认为没有内置角色。 useEffect(() => { let alive = true; void ipc.listAgents().then((list) => { if (alive) setAgents(list); - }).catch(() => { - if (alive) setAgents([]); + }).catch((e) => { + if (alive) setLoadError(String(e)); }); return () => { alive = false; }; }, []); @@ -103,14 +105,18 @@ export default function SubagentModelsPanel() { 控件右对齐 w-wide 档)——与同页 .settings-collection-row / .settings-provider-model 同规格。 勿再在本组件里加内联宽度或 Space 间距,否则会退回「行糊成一片 + 控件贴左」的旧观感。 */}
{t("settings.subagentModelsHint")}
+ {loadError && } {agents.map((agent) => { const value = current(agent.name); const isReadonly = READONLY_ROLES.has(agent.name); return (
- - {agent.name} - {isReadonly && {t("settings.subagentReadonly")}} + + + {agent.name} + {isReadonly && {t("settings.subagentReadonly")}} + + {agent.resource && } ({ value: k, label: k }))}/> setReason(e.target.value)}/>}; +} diff --git a/ui/src/features/dashboard/RuntimeDashboard.tsx b/ui/src/features/dashboard/RuntimeDashboard.tsx index 5aedb2a0..6c32561d 100644 --- a/ui/src/features/dashboard/RuntimeDashboard.tsx +++ b/ui/src/features/dashboard/RuntimeDashboard.tsx @@ -139,6 +139,8 @@ export default function RuntimeDashboard() { {projectName(session.project_id)} · {elapsed(session.started_at_ms)} · {t("dashboard.subsCount", { n: session.subs.length })} {session.activity || t("dashboard.noActivity")} + {session.workflow?.nodes && {session.workflow.nodes.map(node => node.id + ": " + node.state + " #" + node.attempt).join(" · ")}} + {session.workflow?.blocked && {session.workflow.blocked}} {session.waiting.length > 0 && {session.waiting.map((item) => {t(item.kind === "approval" ? "dashboard.approval" : "dashboard.answer")} diff --git a/ui/src/features/panels/SettingsPage.tsx b/ui/src/features/panels/SettingsPage.tsx index 3609328d..4910ae2e 100644 --- a/ui/src/features/panels/SettingsPage.tsx +++ b/ui/src/features/panels/SettingsPage.tsx @@ -58,6 +58,7 @@ import { AppearanceSettings } from "./FontSettings"; import ProvidersPanel, { validateProvider } from "./ProvidersPanel"; import SubagentModelsPanel from "./SubagentModelsPanel"; import SkillsPanel from "./SkillsPanel"; +import WorkflowSettings from "./WorkflowSettings"; import InstructionResourcesPanel from "./InstructionResourcesPanel"; import { INSTANT_APPLY_FIELD_IDS, @@ -2052,6 +2053,7 @@ function SettingsPageController() { labelKey: PAGE_LABEL_KEY.agent, body: draft && ( +
{/* 「Shell 与提示词」组:shell 选择器 + 自定义提示词合成一张 section 卡片 ([docs/settings-guide](../../../../docs/settings-guide.md)) */} diff --git a/ui/src/features/panels/WorkflowSettings.tsx b/ui/src/features/panels/WorkflowSettings.tsx new file mode 100644 index 00000000..d3feb8a4 --- /dev/null +++ b/ui/src/features/panels/WorkflowSettings.tsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Button, Input, Modal, Space, Tag, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import { ipc } from "../../ipc/client"; +import type { WorkflowConfigView, WorkflowIssue } from "../../ipc/types"; +const starter = JSON.stringify({ schema: 1, id: "project-default", nodes: [{ id: "build", kind: "implement", tool_allowlist: ["read", "edit", "create", "command"] }, { id: "review", kind: "review", role_id: "code-reviewer", tool_allowlist: ["read", "delivery_contract"], required_checks: ["host_independent_review"] }, { id: "finish", kind: "complete", tool_allowlist: [] }], edges: [{ from: "build", to: "review" }, { from: "review", to: "finish" }], repair_rules: [], role_policies: {}, model_policies: [], checks: [] }, null, 2); +export default function WorkflowSettings({ sessionId }: { + sessionId: string | null; +}) { + const { t } = useTranslation(); + const [view, setView] = useState(null); + const [draft, setDraft] = useState(""); + const [issues, setIssues] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [review, setReview] = useState(null); + const identity = useRef(sessionId); + identity.current = sessionId; + const generation = useRef(0); + const pending = useRef(false); + const invalidate = useCallback(() => { generation.current += 1; pending.current = false; }, []); + const load = useCallback(async () => { if (!sessionId || pending.current) + return; const id = ++generation.current; pending.current = true; setBusy(true); try { + const v = await ipc.readProjectWorkflow(sessionId); + if (id !== generation.current || identity.current !== sessionId) + return; + setView(v); + setDraft(v.text || starter); + setIssues(v.issues); + setError(""); + } + catch (e) { + if (id === generation.current) + setError(String(e)); + } + finally { + if (id === generation.current) { + pending.current = false; + setBusy(false); + } + } }, [sessionId]); + useEffect(() => { invalidate(); setView(null); setDraft(""); setIssues([]); setError(""); setReview(null); void load(); return invalidate; }, [sessionId, load, invalidate]); + const validate = async () => { if (!sessionId || pending.current) + return; const id = generation.current; pending.current = true; setBusy(true); try { + const found = await ipc.validateProjectWorkflow(sessionId, draft); + if (id === generation.current) { + setIssues(found); + setError(""); + } + } + catch (e) { + if (id === generation.current) + setError(String(e)); + } + finally { + if (id === generation.current) { + pending.current = false; + setBusy(false); + } + } }; + const save = async () => { if (!sessionId || !view || review === null || pending.current) + return; const id = generation.current; const text = review; pending.current = true; setBusy(true); try { + const found = await ipc.validateProjectWorkflow(sessionId, text); + if (id !== generation.current) + return; + setIssues(found); + if (found.length) + return; + const v = await ipc.saveProjectWorkflow(sessionId, text, view.revision, view.digest, crypto.randomUUID()); + if (id !== generation.current) + return; + setView(v); + setDraft(v.text); + setReview(null); + setError(""); + } + catch (e) { + if (id === generation.current) + setError(String(e)); + } + finally { + if (id === generation.current) { + pending.current = false; + setBusy(false); + } + } }; + if (!sessionId) + return {t("workflow.noSession")}; + return {t("workflow.edit")}{t("workflow.hint")}{error && }{view && {t(view.approved ? "workflow.approved" : "workflow.pending")}} setDraft(e.target.value)} rows={16} spellCheck={false} disabled={busy}/>{issues.map((issue, i) => )} { if (!busy) + setReview(null); }} onOk={() => void save()} confirmLoading={busy} width={760}>{t("workflow.hint")}
{review}
; +} diff --git a/ui/src/features/panels/settingsRegistry.ts b/ui/src/features/panels/settingsRegistry.ts index 954982e0..80904e18 100644 --- a/ui/src/features/panels/settingsRegistry.ts +++ b/ui/src/features/panels/settingsRegistry.ts @@ -112,6 +112,7 @@ export type SettingWidth = "narrow" | "mid" | "wide"; /** 全部设置项(登记制:改动设置项归属时必须同步本表与 PAGE_FIELDS) */ export const SETTINGS_ITEMS: SettingItem[] = [ + { id: "app.project_workflow", labelKey: "workflow.edit", page: "agent", keywords: ["workflow", "工作流"] }, // ---------- 界面 ---------- { id: "ui.theme", labelKey: "settings.theme", page: "appearance", width: "narrow", keywords: ["theme", "dark", "light", "主题", "暗色", "亮色"] }, { id: "ui.output_display", labelKey: "settings.outputDisplay", page: "appearance", width: "narrow", keywords: ["output", "display", "compact", "detailed", "输出", "展示", "简洁", "详细"] }, @@ -208,6 +209,7 @@ export const WIDTH_TIERS: SettingWidth[] = ["narrow", "mid", "wide"]; * 分类:整行 / 多行文本、整行开关、Slider、行内网格控件、整行列表与复合容器 / 动作项。 */ export const WIDTH_EXEMPT_ITEM_IDS: string[] = [ + "app.project_workflow", // Dedicated full-width project JSON editor // —— 整行 / 多行文本:内容长度不可预知,占满整行(宽度由容器决定) —— "ui.font_sans", // 界面字体名(可含多个 family,逗号分隔)+ 行内「恢复默认」按钮 "ui.font_content", // 内容字体名,同上 diff --git a/ui/src/features/shell/RightBarInfo.tsx b/ui/src/features/shell/RightBarInfo.tsx index f98cd0a3..1ddd1a4d 100644 --- a/ui/src/features/shell/RightBarInfo.tsx +++ b/ui/src/features/shell/RightBarInfo.tsx @@ -11,6 +11,7 @@ import { useUi } from "../../stores/ui"; import { originLabel } from "../../utils/skills"; import { readCollapsedSections, writeCollapsedSections, type CollapsibleSection } from "../../utils/rightbarPrefs"; import QuotaSection from "../quota/QuotaSection"; +import WorkflowPanel from "../chat/WorkflowPanel"; import AcceptancePanel from "../chat/AcceptancePanel"; import type { WorkspaceChanges } from "../workspace/useWorkspaceChanges"; import OpenInEditorSelect from "./OpenInEditorSelect"; @@ -154,7 +155,7 @@ export default function RightBarInfo({ visible, files, changes, onView }: { // 「能不能算交付通过」,与「运行是否结束」是两件事。 ...(tab ? [{ key: "acceptance", label: t("acceptance.title"), forceRender: true, - children: , + children: <>, }] : []), { key: "quota", label: t("rightbar.quota"), extra: quotaActions, forceRender: true, children: quotaContent }, { key: "skills", label: t("rightbar.skills"), extra: {t("rightbar.skillCount", { n: skills.length })}, children: <> diff --git a/ui/src/i18n/en-US.ts b/ui/src/i18n/en-US.ts index 00002210..a6dd98aa 100644 --- a/ui/src/i18n/en-US.ts +++ b/ui/src/i18n/en-US.ts @@ -602,6 +602,7 @@ export default { // Delivery acceptance (#16 completion gate): a finished run is not an accepted // delivery; this read-only projection exists to remove exactly that misreading. + workflow: {title:"Workflow",edit:"Project workflow configuration",hint:"Review the complete JSON before saving. Saving does not authorize execution. Active tasks keep their frozen definition.",load:"Reload",validate:"Validate",save:"Review and save",confirm:"Adopt this workflow definition?",empty:"No frozen workflow",version:"Adopted revision {{revision}}",candidate:"Current project revision {{revision}}",changed:"Project configuration differs; only later tasks adopt it",resume:"Continue as a new attempt",resumeConfirm:"Create a new attempt and confirm current permissions? Historical tools are not replayed. Permissions return to Plan.",route:"Route",reason:"Reason",owner:"Owner",dependencies:"Dependencies",scope:"Write scope",checks:"Acceptance checks",attempt:"Attempt",leases:"Active leases",override:"Correct classification",overrideReason:"Explain the classification correction",noSession:"Select a project session",approved:"Reviewed",pending:"Pending review",draft:"Workflow JSON",failed:"Workflow operation failed: {{error}}",loading:"Loading workflow…"}, acceptance: { title: "Acceptance", loading: "Loading acceptance…", failed: "Failed to load acceptance: {{error}}", unverified: "Unverified: this session has no acceptance record", diff --git a/ui/src/i18n/zh-CN.ts b/ui/src/i18n/zh-CN.ts index 5424c0c7..85a82b3c 100644 --- a/ui/src/i18n/zh-CN.ts +++ b/ui/src/i18n/zh-CN.ts @@ -594,6 +594,7 @@ export default { }, // 交付验收(#16 完成门):运行终态 ≠ 验收结论,这块只读投影专门消除这个误解。 + workflow: {title:"工作流",edit:"项目工作流配置",hint:"保存前请审核完整 JSON。保存不授予执行权限,运行中的任务继续使用冻结版本。",load:"重新读取",validate:"校验",save:"审核并保存",confirm:"采用以下工作流定义?",empty:"没有已冻结工作流",version:"采用版本 {{revision}}",candidate:"当前项目版本 {{revision}}",changed:"项目配置与本次运行不同,仅后续任务采用",resume:"以新尝试继续",resumeConfirm:"创建新尝试并重新确认权限?旧工具不会自动重放,权限回到计划档。",route:"路由",reason:"原因",owner:"所有者",dependencies:"依赖",scope:"写范围",checks:"验收检查",attempt:"尝试",leases:"在途租约",override:"纠正分类",overrideReason:"请说明纠正分类的依据",noSession:"请选择项目会话",approved:"已审核",pending:"待审核",draft:"工作流 JSON",failed:"工作流操作失败:{{error}}",loading:"正在读取工作流…"}, acceptance: { title: "交付验收", loading: "读取验收状态…", failed: "读取验收状态失败:{{error}}", unverified: "未验证:本会话没有可用的验收记录", diff --git a/ui/src/ipc/client.ts b/ui/src/ipc/client.ts index ca500754..0db4d2d0 100644 --- a/ui/src/ipc/client.ts +++ b/ui/src/ipc/client.ts @@ -4,6 +4,7 @@ * 入参对象键名即 Tauri invoke 参数名;返回类型与 ipc/types.ts 的 serde 结构一一对应。 * 组件不得直接 import @tauri-apps/api,新增命令一律收敛在此处。 */ +import type { WorkflowConfigView, WorkflowIssue, WorkflowProjection } from "./types"; import { invoke } from "@tauri-apps/api/core"; import type { ActivitySnapshot, AskOpenedEvent, DeliveryProjection, SessionRunSnapshot } from "./types"; import type { Channel } from "@tauri-apps/api/core"; @@ -24,6 +25,13 @@ export const ipc = { getDeliveryProjection: (sessionId: string) => invoke("get_delivery_projection", { sessionId }), + readProjectWorkflow: (sessionId: string) => invoke("read_project_workflow", {sessionId}), + validateProjectWorkflow: (sessionId: string, text: string) => invoke("validate_project_workflow", {sessionId, text}), + saveProjectWorkflow: (sessionId: string, text: string, expectedRevision: number, expectedDigest: string, operationId: string) => invoke("save_project_workflow", {sessionId, text, expectedRevision, expectedDigest, operationId}), + getWorkflowProjection: (sessionId: string) => invoke("get_workflow_projection", {sessionId}), + continueWorkflow: (sessionId: string, taskId: string, expectedRevision: number) => invoke("continue_workflow", {sessionId, taskId, expectedRevision}), + overrideWorkflowRoute: (sessionId: string, taskId: string, expectedRevision: number, kind: string, reason: string) => invoke("override_workflow_route", {sessionId,taskId,expectedRevision,kind,reason}), + listProjects: () => invoke("list_projects"), saveProject: (entry: ProjectEntry) => invoke("save_project", { entry }), deleteProject: (projectId: string) => diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index 54f037b4..b013ab42 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -532,6 +532,7 @@ export interface ActivitySub { report: string | null; } export interface ActivitySession { + workflow?: {task_id?:string;revision?:number;nodes?:{id:string;state:string;attempt:number;reason?:string}[];blocked?:string}; session_id: string; run_id: string; title: string; @@ -956,3 +957,16 @@ export interface DeliveryProjection { repair_rounds_used: number; repair_rounds_max: number; } + +export interface WorkflowIssue { pointer: string; code: string; message: string } +export interface WorkflowConfigView { text: string; revision: number; digest: string; approved: boolean; issues: WorkflowIssue[] } +export interface WorkflowProjection { + task_id: string; contract_revision: number; root_run_id: string; + route: { kind: string; reasons: string[] }; + units: { id: string; role_id: string | null; model_id: string | null; depends_on: string[]; write_scopes: {root_id:string;relative_path:string}[] }[]; + nodes: {unit_id:string;state:string;attempt:number;reason:string|null}[]; + checks: {id:string;required:boolean;platforms:string[];prerequisites:string[];cwd?:{root_id:string;relative_path:string};command?:unknown}[]; + leases: {unit_id:string;held_paths:string[];exclusive:boolean}[]; + frozen_revision:number; frozen_digest:string; source_digest?:string; candidate:WorkflowConfigView|null; + resume_allowed:boolean;recovery_error:string|null;design:{required:boolean;reasons:string[]}; +} From 3d20c6953808516e8341ee634605fce0073c25ed Mon Sep 17 00:00:00 2001 From: Yangshifu Date: Sat, 17 Oct 2026 14:24:59 +0800 Subject: [PATCH 9/9] fix(ci): restore file sections and cross-platform resource tests Restore file-summary collapse state and persistence, remove lowercase agents.md discovery, deduplicate rejected instruction aliases, and exercise native Windows lease aliases and path comparisons. --- docs/mcp-and-skills.md | 2 +- src-tauri/src/core/instruction_resources.rs | 21 ++-- .../src/core/instruction_resources/tests.rs | 114 ++++++++++++++++++ src-tauri/src/core/workflow/dispatch_tests.rs | 48 +++++++- src-tauri/src/skills/catalog_tests.rs | 4 +- ui/src/__tests__/rightbar.info.test.tsx | 4 +- ui/src/features/shell/RightBarInfo.tsx | 2 +- ui/src/utils/rightbarPrefs.ts | 4 +- 8 files changed, 181 insertions(+), 18 deletions(-) diff --git a/docs/mcp-and-skills.md b/docs/mcp-and-skills.md index bfea82e2..d27b7b44 100644 --- a/docs/mcp-and-skills.md +++ b/docs/mcp-and-skills.md @@ -123,7 +123,7 @@ SkillIndex 的 get/list/audit 缓存 TTL 为 10 秒;结构化 key 包含 works ### 指令资源准入 -[instruction_resources.rs](../src-tauri/src/core/instruction_resources.rs) 将项目五种指令文件、CODEGRAPH、实际选中的 lessons、记忆索引和技能统一为 collect → authorize → render。来源快照同时保存 raw SHA-256 和实际投影;[prompt.rs](../src-tauri/src/core/prompt.rs) 只渲染调用方已准入的文本,不重读文件。技能确认展示同次解析的名称/description/whenToUse 索引和正文,记忆的版本与描述来自同一份原始字节。内置资源静态信任;项目/全局/兼容目录位置、文件写入成功、FullAccess、模型 ask 或 auto_confirm 都不授权外部内容。受保护设置中的 custom_prompt 继续按配置提交器管理。 +[instruction_resources.rs](../src-tauri/src/core/instruction_resources.rs) 将项目四种指令文件(CODEWAVE.md、AGENTS.md、CLAUDE.md、claude.md)、CODEGRAPH、实际选中的 lessons、记忆索引和技能统一为 collect → authorize → render。指令入口只登记大写 AGENTS.md,不单独兼容 agents.md;大小写不敏感文件系统仍按原生路径规则解析,不额外强制区分大小写。来源快照同时保存 raw SHA-256 和实际投影;[prompt.rs](../src-tauri/src/core/prompt.rs) 只渲染调用方已准入的文本,不重读文件。技能确认展示同次解析的名称/description/whenToUse 索引和正文,记忆的版本与描述来自同一份原始字节。内置资源静态信任;项目/全局/兼容目录位置、文件写入成功、FullAccess、模型 ask 或 auto_confirm 都不授权外部内容。受保护设置中的 custom_prompt 继续按配置提交器管理。 设置「技能」页中的「指令资源许可」展示来源、版本、当前许可和活动运行已使用版本。选择「本次会话允许」只在本进程当前根会话及其子代理生效,不串其他会话或计划任务,不新增/撤销持久记录;没有当前会话时不能设置临时许可。「记住此版本」绑定项目/工作区、来源、用途、内容版本和全局 profile,经独立权威存储 CAS;跨项目不继承。撤销阻止后续准入,旧未消费或待提交批准票据不能复活。确认只提交宿主五分钟单用票据,不提交任意 path/body/trusted 作为权威;变更内容、切会话、过期及重复确认拒绝。未知结果检查原操作,不重新写入批准;已确认撤销只清理一次本地许可。 diff --git a/src-tauri/src/core/instruction_resources.rs b/src-tauri/src/core/instruction_resources.rs index 3c7fa536..6476a2e7 100644 --- a/src-tauri/src/core/instruction_resources.rs +++ b/src-tauri/src/core/instruction_resources.rs @@ -149,6 +149,9 @@ pub fn collect_project(scope: &ResourceScope) -> ResourceSnapshot { let mut add_file = |path: PathBuf, label: &str, kind: InstructionKind, limit: usize| match read_file(&path) { Ok((canonical, bytes)) => { + if !physical.insert(canonical.clone()) { + return; + } if !canonical.starts_with(crate::tools::pathutil::canonical_best_effort( &scope.workspace, )) && !scope.project_dir.as_ref().is_some_and(|dir| { @@ -160,9 +163,6 @@ pub fn collect_project(scope: &ResourceScope) -> ResourceSnapshot { )); return; } - if !physical.insert(canonical.clone()) { - return; - } let text: String = String::from_utf8_lossy(&bytes) .chars() .take(limit) @@ -188,17 +188,18 @@ pub fn collect_project(scope: &ResourceScope) -> ResourceSnapshot { )); } Err(error) if path.exists() || std::fs::symlink_metadata(&path).is_ok() => { + // Rejected sources still have a physical identity. Case-insensitive + // filesystems and links can expose the same oversized/non-regular + // source under multiple instruction names; report it only once. + let identity = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !physical.insert(identity) { + return; + } snapshot.issues.push(format!("{}: {error}", path.display())) } Err(_) => {} }; - for name in [ - "CODEWAVE.md", - "AGENTS.md", - "agents.md", - "CLAUDE.md", - "claude.md", - ] { + for name in ["CODEWAVE.md", "AGENTS.md", "CLAUDE.md", "claude.md"] { add_file( scope.workspace.join(name), name, diff --git a/src-tauri/src/core/instruction_resources/tests.rs b/src-tauri/src/core/instruction_resources/tests.rs index f981ef78..7d67f1dc 100644 --- a/src-tauri/src/core/instruction_resources/tests.rs +++ b/src-tauri/src/core/instruction_resources/tests.rs @@ -435,6 +435,36 @@ async fn unknown_native_commit_is_inspected_without_rewriting_and_unavailable_ne assert_eq!(records[0]["revision"], 1); } +#[test] +fn agents_discovery_uses_only_the_uppercase_candidate() { + let ws = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("agents.md"), "lowercase instructions").unwrap(); + let uppercase = ws.path().join("AGENTS.md"); + let scope = ResourceScope { + workspace: ws.path().into(), + data_dir: ws.path().into(), + project_dir: None, + project_id: None, + owner: "scope".into(), + }; + let snapshot = collect_project(&scope); + assert!(snapshot.issues.is_empty()); + // Native case-insensitive lookup may resolve AGENTS.md to the lowercase + // file. On case-sensitive filesystems it must not be discovered separately. + if uppercase.exists() { + assert_eq!(snapshot.resources.len(), 1); + assert_eq!(snapshot.resources[0].name, "AGENTS.md"); + } else { + assert!(snapshot.resources.is_empty()); + } + std::fs::write(&uppercase, "uppercase instructions").unwrap(); + let snapshot = collect_project(&scope); + assert!(snapshot.issues.is_empty()); + assert_eq!(snapshot.resources.len(), 1); + assert_eq!(snapshot.resources[0].name, "AGENTS.md"); + assert_eq!(snapshot.resources[0].text, "uppercase instructions"); +} + #[test] fn project_projection_has_character_limits_and_rejects_oversized_sources_without_a_fake_hash() { let ws = tempfile::tempdir().unwrap(); @@ -458,6 +488,90 @@ fn project_projection_has_character_limits_and_rejects_oversized_sources_without assert_eq!(snapshot.issues.len(), 1); } +#[cfg(unix)] +#[test] +fn instruction_aliases_share_resources_and_rejection_diagnostics() { + let ws = tempfile::tempdir().unwrap(); + let source = ws.path().join("source.md"); + std::fs::write(&source, "shared instruction").unwrap(); + for name in ["AGENTS.md", "CLAUDE.md"] { + std::os::unix::fs::symlink(&source, ws.path().join(name)).unwrap(); + } + let scope = ResourceScope { + workspace: ws.path().into(), + data_dir: ws.path().into(), + project_dir: None, + project_id: None, + owner: "scope".into(), + }; + let snapshot = collect_project(&scope); + assert_eq!(snapshot.resources.len(), 1); + assert!(snapshot.issues.is_empty()); + + std::fs::write(&source, vec![b'x'; 1024 * 1024 + 1]).unwrap(); + let snapshot = collect_project(&scope); + assert!(snapshot.resources.is_empty()); + assert_eq!(snapshot.issues.len(), 1); + assert!(snapshot.issues[0].contains("exceeds 1 MiB")); + + std::fs::remove_file(&source).unwrap(); + std::fs::create_dir(&source).unwrap(); + let snapshot = collect_project(&scope); + assert!(snapshot.resources.is_empty()); + assert_eq!(snapshot.issues.len(), 1); + assert!(snapshot.issues[0].contains("not a regular file")); +} + +#[cfg(unix)] +#[test] +fn escaping_instruction_aliases_share_one_rejection() { + let ws = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let source = outside.path().join("source.md"); + std::fs::write(&source, "outside instruction").unwrap(); + for name in ["AGENTS.md", "CLAUDE.md"] { + std::os::unix::fs::symlink(&source, ws.path().join(name)).unwrap(); + } + let scope = ResourceScope { + workspace: ws.path().into(), + data_dir: ws.path().into(), + project_dir: None, + project_id: None, + owner: "scope".into(), + }; + let snapshot = collect_project(&scope); + assert!(snapshot.resources.is_empty()); + assert_eq!(snapshot.issues.len(), 1); + assert!(snapshot.issues[0].contains("outside its project roots")); +} + +#[test] +fn distinct_rejected_instruction_sources_each_remain_diagnosed() { + let ws = tempfile::tempdir().unwrap(); + for name in ["AGENTS.md", "CLAUDE.md"] { + std::fs::write(ws.path().join(name), vec![b'x'; 1024 * 1024 + 1]).unwrap(); + } + let scope = ResourceScope { + workspace: ws.path().into(), + data_dir: ws.path().into(), + project_dir: None, + project_id: None, + owner: "scope".into(), + }; + let snapshot = collect_project(&scope); + assert!(snapshot.resources.is_empty()); + assert_eq!(snapshot.issues.len(), 2); + assert!( + snapshot + .issues + .iter() + .all(|issue| issue.contains("exceeds 1 MiB")) + ); + for name in ["AGENTS.md", "CLAUDE.md"] { + assert!(snapshot.issues.iter().any(|issue| issue.contains(name))); + } +} + #[cfg(unix)] #[test] fn non_regular_instruction_sources_are_rejected_before_blocking_open() { diff --git a/src-tauri/src/core/workflow/dispatch_tests.rs b/src-tauri/src/core/workflow/dispatch_tests.rs index 5b42f9da..b3b34e81 100644 --- a/src-tauri/src/core/workflow/dispatch_tests.rs +++ b/src-tauri/src/core/workflow/dispatch_tests.rs @@ -139,6 +139,7 @@ fn an_ancestor_path_conflicts() { assert!(err.iter().any(|d| d.code == "path_claimed")); } +#[cfg(unix)] #[test] fn a_symlink_alias_resolves_to_the_same_lease() { // 符号链接别名必须落到同一条目,否则两个名字就能绕过认领各写一次。 @@ -146,7 +147,6 @@ fn a_symlink_alias_resolves_to_the_same_lease() { let real = f.roots["ws"].join("real.txt"); std::fs::write(&real, b"x").unwrap(); let alias = f.roots["ws"].join("alias.txt"); - #[cfg(unix)] std::os::unix::fs::symlink(&real, &alias).unwrap(); admit( @@ -171,6 +171,52 @@ fn a_symlink_alias_resolves_to_the_same_lease() { ); } +#[cfg(windows)] +#[test] +fn a_junction_alias_resolves_to_the_same_lease() { + let f = fx(); + let real = f.roots["ws"].join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::write(real.join("file.txt"), b"x").unwrap(); + let alias = f.roots["ws"].join("alias"); + // Directory junctions exercise real Windows alias resolution without + // requiring the symlink privilege or Developer Mode on the test machine. + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&alias) + .arg(&real) + .output() + .expect("mklink /J"); + assert!( + output.status.success(), + "junction creation failed: {output:?}" + ); + assert_eq!( + alias.join("file.txt").canonicalize().unwrap(), + real.join("file.txt").canonicalize().unwrap() + ); + admit( + &f, + "r_a_junction_alias_resolves_to_the_same_lease", + vec![unit("a", &["real/file.txt"])], + BTreeMap::new(), + BTreeMap::new(), + ) + .unwrap(); + let err = admit( + &f, + "r2_a_junction_alias_resolves_to_the_same_lease", + vec![unit("b", &["alias/file.txt"])], + BTreeMap::new(), + BTreeMap::new(), + ) + .unwrap_err(); + assert!( + err.iter().any(|d| d.code == "path_claimed"), + "junction aliases must share the same lease" + ); +} + #[test] fn a_lock_file_write_is_a_single_owner_resource() { let f = fx(); diff --git a/src-tauri/src/skills/catalog_tests.rs b/src-tauri/src/skills/catalog_tests.rs index 2bab1b66..67fee245 100644 --- a/src-tauri/src/skills/catalog_tests.rs +++ b/src-tauri/src/skills/catalog_tests.rs @@ -342,7 +342,9 @@ fn equal_precedence_conflicts_are_stable_and_invalid_candidates_are_diagnosed() .audit .issues .iter() - .any(|issue| issue.path == path.display().to_string() + // read_dir uses native separators; fixture paths may contain + // '/' on Windows. Compare paths rather than their spelling. + .any(|issue| Path::new(&issue.path) == path.as_path() && !issue.message.is_empty()), "missing diagnostic for {}", path.display() diff --git a/ui/src/__tests__/rightbar.info.test.tsx b/ui/src/__tests__/rightbar.info.test.tsx index 2d42f70d..f19f0491 100644 --- a/ui/src/__tests__/rightbar.info.test.tsx +++ b/ui/src/__tests__/rightbar.info.test.tsx @@ -165,8 +165,8 @@ describe("右栏偏好读盘容错", () => { localStorage.setItem(COLLAPSED_SECTIONS_KEY, "not-json"); expect([...readCollapsedSections()]).toEqual([]); // 合法元素保留、非法元素剔除 - localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify(["skills", 42, null, "plan", "files"])); - expect([...readCollapsedSections()].sort()).toEqual(["files", "plan", "skills"]); + localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify(["skills", 42, null, "plan", "files", "acceptance", "unknown"])); + expect([...readCollapsedSections()].sort()).toEqual(["acceptance", "files", "plan", "skills"]); // 非数组 / 非字符串都不得让额度家展开态崩掉 localStorage.setItem(EXPANDED_QUOTA_KEY, JSON.stringify("nope")); expect([...readExpandedQuotaProviders()]).toEqual([]); diff --git a/ui/src/features/shell/RightBarInfo.tsx b/ui/src/features/shell/RightBarInfo.tsx index 5d6b7198..1cac1978 100644 --- a/ui/src/features/shell/RightBarInfo.tsx +++ b/ui/src/features/shell/RightBarInfo.tsx @@ -60,7 +60,7 @@ export default function RightBarInfo({ visible, files, changes, onView }: { const project = tab?.projectId ? projects.find(p => p.id === tab.projectId) : null; const hasProject = !!tab?.projectId; const directory = hasProject ? project?.directory ?? tab?.workspace ?? "" : ""; - const sections: CollapsibleSection[] = [...(todos.length ? ["plan" as const] : []), ...(tab ? ["acceptance" as const] : []), "quota", "skills"]; + const sections: CollapsibleSection[] = ["files", ...(todos.length ? ["plan" as const] : []), ...(tab ? ["acceptance" as const] : []), "quota", "skills"]; const openSections = sections.filter(id => !collapsed.has(id)); const statuses = { running: 0, done: 0, attention: 0, error: 0 }; for (const sub of subs) statuses[subStatus(sub)]++; diff --git a/ui/src/utils/rightbarPrefs.ts b/ui/src/utils/rightbarPrefs.ts index 25583cfd..d99f6afd 100644 --- a/ui/src/utils/rightbarPrefs.ts +++ b/ui/src/utils/rightbarPrefs.ts @@ -5,7 +5,7 @@ // 后端候选表第一个检测到的(本模块只记用户手动改选过的那一个)。 /** 折叠的段落 id(值为「已折叠」集合;缺省 = 全展开) */ -export type CollapsibleSection = "skills" | "plan" | "quota" | "acceptance"; +export type CollapsibleSection = "skills" | "plan" | "quota" | "acceptance" | "files"; export const COLLAPSED_SECTIONS_KEY = "ws_rb_sections_collapsed"; export const EXPANDED_QUOTA_KEY = "ws_rb_quota_expanded"; @@ -33,7 +33,7 @@ function writeIdSet(key: string, ids: Set): void { export function readCollapsedSections(): Set { const all = readIdSet(COLLAPSED_SECTIONS_KEY); const out = new Set(); - for (const id of ["skills", "plan", "quota", "acceptance"] as const) { + for (const id of ["skills", "plan", "quota", "acceptance", "files"] as const) { if (all.has(id)) out.add(id); } return out;