From 09bae996b083e01c86befd6315e1cf631856dd85 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 20 Oct 2026 23:18:15 +0800 Subject: [PATCH 01/16] feat(agent): add native global and project skill management --- ...026-09-25-shared-acp-agent-conversation.md | 2 +- .../2026-10-20-agent-skill-management.md | 86 +++ .../2026-09-30-macos-shared-dropdown-style.md | 10 + macos/Resources/en.lproj/Localizable.strings | 93 +++ .../zh-Hans.lproj/Localizable.strings | 92 +++ .../Features/AgentSkillsFeatureModel.swift | 165 +++++ .../Rust/RustAgentManagementService.swift | 35 ++ macos/Sources/Lithe/LitheApp.swift | 3 +- .../Lithe/Models/AppModel/AppModel.swift | 1 + .../AppModel/AppModelSupportTypes.swift | 4 +- .../Views/Agent/AgentPanelSettingsView.swift | 2 + .../Lithe/Views/Agent/AgentSkillSheets.swift | 112 ++++ .../Views/Agent/AgentSkillsSettingsView.swift | 182 ++++++ .../Views/App/SettingsSearchVocabulary.swift | 2 + .../Lithe/Views/App/SettingsView.swift | 11 + .../Views/Components/LitheContextMenu.swift | 42 +- .../Agent/AgentManagementPorts.swift | 15 + .../Agent/AgentSkills.swift | 65 ++ .../AgentSkillsFeatureModelTests.swift | 436 ++++++++++++++ .../LitheActionMenuFocusTests.swift | 90 +++ .../SettingsSearchVocabularyTests.swift | 8 + .../SettingsSidebarLayoutTests.swift | 2 +- rust/Cargo.lock | 2 + rust/lithe-agent-host/Cargo.toml | 2 + rust/lithe-agent-host/src/codex_history.rs | 116 +++- rust/lithe-agent-host/src/lib.rs | 1 + rust/lithe-agent-host/src/skills.rs | 281 +++++++++ rust/lithe-agent-host/src/skills/files.rs | 506 ++++++++++++++++ rust/lithe-agent-host/src/skills/settings.rs | 298 +++++++++ rust/lithe-agent-host/src/skills/tests.rs | 568 ++++++++++++++++++ rust/lithe-core/src/agent/mod.rs | 16 + rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/runtime/dispatcher.rs | 1 + shared/contracts/application-boundary.md | 9 + shared/contracts/rust-core-api.md | 52 ++ .../fixtures/agent/skills-management-v1.json | 144 +++++ .../features/agent-skill-management.json | 33 + .../macos-shared-product-dropdowns.json | 7 +- 38 files changed, 3477 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-20-agent-skill-management.md create mode 100644 macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift create mode 100644 macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift create mode 100644 macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift create mode 100644 macos/Tests/LitheTests/LitheActionMenuFocusTests.swift create mode 100644 rust/lithe-agent-host/src/skills.rs create mode 100644 rust/lithe-agent-host/src/skills/files.rs create mode 100644 rust/lithe-agent-host/src/skills/settings.rs create mode 100644 rust/lithe-agent-host/src/skills/tests.rs create mode 100644 shared/fixtures/agent/skills-management-v1.json create mode 100644 shared/platform-feature-matrix/features/agent-skill-management.json diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index deae8df9b8..17d43ac7af 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -173,7 +173,7 @@ macOS 的审批模式、模型和思考强度在等待、推理、输出、工 - **思考(`agent_thought_chunk`)**:存为独立的 `thought` 角色消息,不并进 Agent 回复。流式缓冲按角色记录,角色切换先刷新上一段,所以“思考 → 回复 → 工具 → 思考”保持原顺序。只有正在流式输出的最后一段思考默认展开,后续回复或工具到达后自动收起,用户手动展开的状态保留;搜索时临时展开匹配的思考,优先于已有的手动折叠选择;搜索期间不允许再次收起命中文字,清空搜索后恢复手动选择,不把搜索结果写回折叠偏好。手动偏好由 `AgentTranscriptView` 在视图局部内存中按会话 ID 与消息 ID 保存,再以绑定传给思考行;搜索先过滤消息,会移除并重建行,因此不能让行内状态成为偏好的所有者。切换会话保留各自选择,关闭会话或移除实际消息时释放对应偏好,销毁对话视图时一并释放;不写入功能模型、共享 Host、历史文件或安装目录,不影响代码签名和 Sparkle delta。`AgentThoughtTranscriptTests` 直接挂载实际对话容器,通过原生鼠标事件和完整渲染快照覆盖搜索移除/重建、命中时临时展开和会话切换。导出 Markdown 用引用块写出思考,便于和回复区分;历史消息数仍只统计用户与 Agent 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 - **计划(`plan`)**:ACP 规定每次都发完整计划,所以整体替换,不做合并;空列表清除计划,格式错误的更新忽略,最多保留 100 条。计划条状态只认 `pending`、`in_progress`、`completed`,未知状态的条目丢弃而不是猜测。点击底部“任务”打开计划与进度;没有计划时显示“暂无任务”,不把工具调用充当计划。`session/load` 回放会自然恢复计划。 -- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。Lithe 不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留默认两行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。对话补全不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。独立技能设置页可以展示原生发现结果或明确标注的目录清单,但不得据此伪造会话补全和启用状态;管理边界见 [技能管理](../feature/2026-10-20-agent-skill-management.md)。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留默认两行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 - **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 - **即时通知(`notice`)**:共享 Host 启用固定 SDK 的 `unstable_session_notices` 能力,并声明 `clientCapabilities.session.notices: {}`,让适配器通过独立事件发送警告。macOS 每个会话只保存最新一条通知,用现有 `LitheBanner` 展示和关闭;通知不计入回复、历史导出或轮次统计,下一条 prompt 成功提交及断连时清除。上游通知字段限制为标题 256 字符、详情 4096 字符,未知严重程度和空标题忽略。 diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md new file mode 100644 index 0000000000..91b6335c64 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -0,0 +1,86 @@ +# Agent 笔记:技能文件管理与原生 Agent 加载边界 + +状态:已实现 + +## 先说结论 + +技能设置按“全局技能 / 项目技能”组织,Codex 和 Claude 作为列表中的来源标签和筛选项。用户可以查看正文、打开文件、定位目录、新建、导入及启停技能;这些操作使用各 Agent 的原生目录和配置,不维护 Lithe 自己的技能副本或启用状态。技能文件出现在列表中,并不代表当前会话已经加载或执行了它。 + +## 问题 + +输入框的 `$` 和 `/` 补全只能显示当前会话上报的命令,不能回答技能保存在哪里、属于哪个项目、如何添加配套脚本。直接把文件清单当成会话能力,会误导用户;若另存一份技能或增加只影响 UI 的开关,还会出现页面“已关闭”但原生 Agent 仍然读取文件的情况。 + +## 决策 + +### 范围和页面 + +页面放在设置的 AI 分组,Agents 页提供管理入口。主要分类只分全局和项目;项目默认取打开设置的工作区,可选择其他已打开项目,选择动作不切换工作台或会话。新建和导入默认使用正在查看的分类,并明确选择适用 Agent 和显示实际目标目录。 + +信息结构参考 PI-Desktop `de57f05aa65b05c93a4113bf5d4db9723884b080` 的 [AgentSkillsPage](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/AgentSkillsPage.tsx) 和 [SkillEditorSheet](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/SkillEditorSheet.tsx)。只参考分类、搜索、紧凑列表与独立编辑面板的交互组织,不复制其代码、样式或状态存储。控件继续使用 Lithe 的设置分段、选择器、搜索框、按钮和菜单。主工作台、标题栏和项目标签不参与此变更。 + +### 发现事实仍由 Agent 拥有 + +Codex 复用已安装 CLI 的官方 `skills/list`,以所选工作区发出强制刷新,保留上游范围、启用状态和插件身份。本机 Codex 0.160.0 与固定适配器依赖的上游 rust-v0.156.1 公开协议均提供这些字段;不需要升级适配器,也不创建会话或发送提示词。生命周期复用既有 App Server 短进程及其有界读取、超时和进程树回收。 + +Claude 固定适配器把 SDK 的 `supportedCommands()` 转成 ACP 命令列表,但缺少可独立查询的完整技能文件路径和范围目录。因此设置页只列出所选项目 `.claude/skills` 和用户配置目录 `skills` 下的文件,并明确显示“目录清单”。用户配置目录服从 `CLAUDE_CONFIG_DIR`,未设置时使用 `~/.claude`。不仿制 Claude 的祖先目录、插件、策略与延迟加载规则;列表 enabled 只表示对应范围的本地配置可见性,不等于原生会话最终可用性。输入框继续仅使用会话实际上报的命令。 + +原生目录依据 [Codex 技能文档](https://developers.openai.com/codex/skills/) 与 [Claude 技能目录说明](https://code.claude.com/docs/en/skills#choose-where-skills-load)。正确示例:在项目分类为 Codex 创建技能,写到该项目 `.agents/skills//SKILL.md`。不要把 Claude 项目目录里的文件改名或移动到全局目录来模拟启停。 + +### 原生启停与配置作用范围 + +纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行用既有原生小开关表示已读取的配置状态,写入完成前保留旧值、显示进度并禁止重复操作;失败保留原状态并明确要求刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 + +Codex 使用官方 [skills/config/write](https://developers.openai.com/codex/app-server) 按 SKILL.md 路径设置 enabled。一次有界 App Server 交换先用所选工作区发现技能,拒绝未知路径,再写配置并强制读取列表验证最终状态。Codex 的此接口写入用户配置,因此项目技能的开关也影响所有使用相同路径的工作区;页面提示这一点,不将项目分类误称为独立项目开关。不直接重写 Codex 的 TOML 或插件文件。 + +Claude 使用官方 [skillOverrides](https://code.claude.com/docs/en/skills#override-skill-visibility-from-settings) 的 on/off;固定适配器对应的最低 CLI 2.1.280 已包含该能力,上游 CHANGELOG 从 2.1.129 明确支持。全局开关写 CLAUDE_CONFIG_DIR/settings.json,项目开关写所选项目 .claude/settings.local.json,不修改团队共享 settings.json 或 SKILL.md。该原生配置按名称生效,同名技能共享配置,项目及受管配置可以覆盖全局;界面帮助必须解释这些限制。全局行读取用户配置,项目行合并用户、该项目 settings.json、settings.local.json;这只是所列本地配置的视图,不声称复刻受管、祖先或已连接会话状态。name-only 和 user-invocable-only 仍显示开启,用户关闭再开启会明确改为 on;插件技能需使用 Claude 的插件管理,不通过改名搬目录实现开关。 + +Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有权限;无效 JSON、未知枚举、非普通文件、符号链接、超出 1 MiB 的配置均拒绝写入。配置目录内的持久锁文件用非阻塞文件锁串行化 Lithe 实例,写前重新比较原始字节以识别外部修改;临时文件同目录落盘并同步后原子替换,取消或失败回收临时文件。外部编辑器并不共享该锁,字节比较和替换间仍有极短的竞争窗口,不承诺跨所有编辑器的事务。锁与配置由用户目录/所选项目持有,不是可跨 worktree 复用的构建资源,不能写入 app bundle。 + +开关反馈只说明原生配置已保存。已经加载进会话的内容无法撤回,现有对话应重新连接以刷新命令和技能清单;不自动重连正在执行任务的 Agent。 + +### 文件写入和异步生命周期 + +共享 Core 只负责 `agent.skills` 的类型化分派、取消和错误信封;原生发现与文件操作在 `lithe-agent-host`。Swift 的服务桥离开主线程执行,功能模型保存列表和操作状态,视图只消费模型及显式的原生对话框/打开/定位动作。 + +导入保留完整目录中的脚本、模板和文件权限,源目录只读。名称、YAML 文件头、UTF-8、目录深度、文件数和大小都有边界;拒绝导入中的符号链接和特殊文件,以及通过链接的目标容器。先写随机隐藏暂存目录,再以不覆盖模式发布;已有空目录也算冲突。失败或取消回收暂存目录,不改已有技能。查看正文仅返回有界文本,不执行文件中的命令或指令。 + +刷新独立查询两个 Agent,单方不可用不遮住另一方结果;同一项目刷新失败保留旧列表,切项目先清除旧数据。请求与写入捕获项目,迟到结果须核对版本;页面离开时取消自有任务。原生探测的取消必须进入已有交换清理路径,初始化卡住时也先终止并回收子进程,再销毁短生命周期的 Tokio 运行时;不能只丢弃读取 future,把子进程回收留给已经退出的事件循环。保存后刷新列表,是否被运行中的会话发现继续由原生 Agent 决定,不停止会话,也不承诺一次导入会让所有 Agent 都可用。 + +这些文件是用户明确创建或导入的工作资料,不是构建产物、下载缓存或可跨工作树复用资源。它们的生命周期由用户和原生 Agent 管理,写入位置仅为原生用户或所选项目目录;不写应用包、已安装适配器、签名资源或发行目录,不改变代码签名和 Sparkle 增量更新基线。没有新增可复用的构建资源,因此工作树资源清单不登记用户技能。 + +## 考虑过的备选方案 + +- **统一扫描并重建完整 Agent 技能目录**:Codex 已有官方接口,另做一套发现会遗漏插件和策略;Claude 的清单不够完整时应明确说明,而不是复制它的整个解析器。 +- **沿用 PI-Desktop 的独立启用状态和运行时合并**:PI 自己掌握执行器,Lithe 使用外部原生 Agent,自己的布尔值无法证明对方已停用。启停改为写原生配置,不保留独立开关状态;市场、删除或跨范围移动仍不提供。 +- **把所有技能存入 Lithe 数据目录**:会增加原生 CLI 看不到的第二份事实,也让其他工具与 IDE 的结果不一致,因此使用原生技能包目录。 + +## 后果 + +用户能够直接分清全局和项目技能,并通过来源标签理解适用 Agent;管理文件不干扰对话。代价是 Claude 设置清单明确不完整,原生 CLI 不可用时 Codex 的发现会显示错误,技能生效时间仍取决于原生会话。 + +Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windows 文件系统、进程清理尚未验收;功能矩阵保持 partial/pending。组件和受控协议测试不代替真实会话验收。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentSkillsFeatureModelTests|AgentSkillsRustBridgeTests|AgentSkillsAppearanceTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests'` +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/agent-skills-rust.json` +- `./scripts/verify-shared-contracts.sh` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` + +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证失败不改值、写入串行和项目切换拒绝旧结果。 + +## 适用范围 + +- `rust/lithe-agent-host/src/skills.rs` +- `rust/lithe-agent-host/src/skills/files.rs` +- `rust/lithe-agent-host/src/skills/settings.rs` +- `rust/lithe-core/src/agent/mod.rs` +- `macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift` +- `macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift` +- `shared/fixtures/agent/skills-management-v1.json` diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 529de06c04..b5165e5044 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -25,6 +25,16 @@ Project/Dependencies 标题与 Terminal shell/动作入口同样使用 `LitheMen - 产品下拉框直接显示透明面板,`animationBehavior = .none`,不使用 SwiftUI `Menu`、菜单式 `Picker` 或系统 `NSPopover` 的弹跳展开,也不添加缩放或弹簧过渡。新增或修改产品下拉框时必须走共享组件;AI 的强制入口规则位于 `develop-lithe` Skill。 - 调用方只提交条目、动作、可用条件和定位信息。例如 Git Log 设置调用 `show(items:at:appearance:locale:)`;不要重新增加样式布尔值或局部绘制另一套菜单。 +### 操作菜单保留所属窗口的激活外观 + +技能开关暴露了共享动作菜单的焦点问题:打开三个点时菜单成为 key window(接收键盘事件的窗口),设置窗口失去该身份,AppKit 把仍开启的 NSSwitch 画灰。不能用修改技能 enabled、另画开关或强制整页活动状态修饰这个显示问题。 + +对照 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade` 的 `platform/platform-impl/src/com/intellij/ui/popup/AbstractPopup.java`:它区分弹窗是否能够聚焦与是否主动请求焦点(myFocusable/myRequestFocus)。Lithe 的纯操作菜单没有文本编辑器,在所属窗口已经接收键盘事件时保留其 key 身份,自己以不可成为 key 的子面板显示;由共享事件监听把该窗口的方向键、Return 和 Esc 交给菜单。无法确认有效所属窗口时保留旧的聚焦回退。可搜索的自定义下拉仍自己接收焦点,输入法和搜索字段路径不改。 + +若操作菜单来自另一个可搜索面板,父监听必须先让子菜单处理按键,避免一次方向键被父搜索列表抢走。未处理的输入或快捷键先关闭动作菜单再传回父窗口。所属窗口失焦或应用切走时关闭动作菜单;关闭、锚点拆除及失败清理时移除所有事件和通知监听。原生开关仍由真实 isEnabled 决定忙碌禁用,菜单不触发任何技能配置写入。 + +`AgentSkillsAppearanceTests` 检查保留父窗口焦点、原生开关值/可用性与绘制不变,以及真正保存期间仍被禁用。`LitheActionMenuFocusTests` 检查键盘和原响应者、嵌套面板分派、Esc、失焦和应用切换清理;仍需真实应用打开菜单核对颜色,后台测试进程的灰色基线不能单独证明实际视觉已修复。 + Monaco 的原生菜单选中经过异步 WebKit 消息回调,已经失去浏览器的用户手势授权。 复制、剪切、粘贴因此由 WKWebView 原生 responder 命令执行,继续触发 Monaco 自己的 DOM 剪贴板监听和撤销逻辑;返回 `handled` 后前端不能再次执行动作。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 1b5f75edb7..6515164f80 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2055,3 +2055,96 @@ "Session" = "Session"; "Minimum level" = "Minimum level"; "Open Log Directory" = "Open Log Directory"; +/* Agent skill management */ +"Skills" = "Skills"; +"Agent skills" = "Agent skills"; +"Global skills" = "Global skills"; +"Project skills" = "Project skills"; +"Manage Skills…" = "Manage Skills…"; +"Search skills" = "Search skills"; +"Skill files" = "Skill files"; +"All sources" = "All sources"; +"Skill source" = "Skill source"; +"Skill project" = "Skill project"; +"Skill actions" = "Skill actions"; +"Refresh skills" = "Refresh skills"; +"New skill" = "New skill"; +"Import skill" = "Import skill"; +"Import SKILL.md" = "Import SKILL.md"; +"Import skill directory" = "Import skill directory"; +"View skill" = "View skill"; +"Skill target Agent" = "Skill target Agent"; +"Copy path" = "Copy path"; +"Open SKILL.md" = "Open SKILL.md"; +"Reveal in Finder" = "Reveal in Finder"; +"No skills in this scope." = "No skills in this scope."; +"No matching skills." = "No matching skills."; +"Open a project to manage its skills." = "Open a project to manage its skills."; +"Enabled in Agent" = "Enabled in Agent"; +"Disabled in Agent" = "Disabled in Agent"; +"Built-in" = "Built-in"; +"Managed" = "Managed"; +"Plugin" = "Plugin"; +"Import source" = "Import source"; +"Create skill" = "Create skill"; +"Skill instructions" = "Skill instructions"; +"Skill name (lowercase-with-hyphens)" = "Skill name (lowercase-with-hyphens)"; +"When should this skill be used?" = "When should this skill be used?"; +"Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation." = "Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation."; +"Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions." = "Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions."; +"File inventory only. Claude determines whether this skill is available in a session." = "File inventory only. Claude determines whether this skill is available in a session."; +"Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten."; +"Install Codex CLI to discover its skills." = "Install Codex CLI to discover its skills."; +"Codex skill discovery failed. Check the CLI and retry." = "Codex skill discovery failed. Check the CLI and retry."; +"The skill file could not be opened." = "The skill file could not be opened."; +"The skill file could not be read." = "The skill file could not be read."; +"The skill document is unavailable. Refresh and retry." = "The skill document is unavailable. Refresh and retry."; +"SKILL.md must use UTF-8 text." = "SKILL.md must use UTF-8 text."; +"SKILL.md needs YAML frontmatter." = "SKILL.md needs YAML frontmatter."; +"SKILL.md has invalid YAML frontmatter." = "SKILL.md has invalid YAML frontmatter."; +"SKILL.md needs a name and description." = "SKILL.md needs a name and description."; +"SKILL.md frontmatter is not closed." = "SKILL.md frontmatter is not closed."; +"Enter a description and instructions." = "Enter a description and instructions."; +"Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "Use up to 64 lowercase letters, digits and single hyphens for the skill name."; +"Select SKILL.md or its containing directory." = "Select SKILL.md or its containing directory."; +"A skill with this directory name already exists. Existing skills are never overwritten." = "A skill with this directory name already exists. Existing skills are never overwritten."; +"Skill imports cannot contain symbolic links or special files." = "Skill imports cannot contain symbolic links or special files."; +"Import a regular file or directory instead of a symbolic link." = "Import a regular file or directory instead of a symbolic link."; +"The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory." = "The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory."; +"The skill could not be published. Check permissions and existing skill names." = "The skill could not be published. Check permissions and existing skill names."; +"Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." = "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh."; +"A skill was omitted because its native metadata is incomplete." = "A skill was omitted because its native metadata is incomplete."; +"A skill has an unknown scope and cannot be classified." = "A skill has an unknown scope and cannot be classified."; +"That skill name is reserved by Claude. Choose a different name." = "That skill name is reserved by Claude. Choose a different name."; +"SKILL.md exceeds the 128 KiB limit." = "SKILL.md exceeds the 128 KiB limit."; +"SKILL.md needs instructions after its frontmatter." = "SKILL.md needs instructions after its frontmatter."; + +"Enable skill" = "Enable skill"; + +"State unknown" = "State unknown"; + +"Switches save native Agent settings. Reconnect existing conversations to refresh available skills." = "Switches save native Agent settings. Reconnect existing conversations to refresh available skills."; + +"Skill setting saved." = "Skill setting saved."; + +"Codex saves this setting by path for all projects using this skill." = "Codex saves this setting by path for all projects using this skill."; + +"Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting." = "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting."; + +"Skill settings could not be updated. Check the settings file and refresh." = "Skill settings could not be updated. Check the settings file and refresh."; + +"Claude skill settings could not be read. Fix invalid settings and refresh before toggling." = "Claude skill settings could not be read. Fix invalid settings and refresh before toggling."; + +"Choose whether to enable the skill." = "Choose whether to enable the skill."; + +"Codex skill settings could not be confirmed. Refresh to check the current state." = "Codex skill settings could not be confirmed. Refresh to check the current state."; + +"The Agent did not apply the skill setting. Refresh and check project or managed configuration." = "The Agent did not apply the skill setting. Refresh and check project or managed configuration."; + +"The skill is no longer available in this scope. Refresh and retry." = "The skill is no longer available in this scope. Refresh and retry."; + +"Claude plugin skills must be managed through Claude's plugin settings." = "Claude plugin skills must be managed through Claude's plugin settings."; + +"Skill settings are busy. Retry after the other change finishes." = "Skill settings are busy. Retry after the other change finishes."; + +"Skill settings changed elsewhere. Refresh and retry." = "Skill settings changed elsewhere. Refresh and retry."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 25e5b45ff3..ed427766ed 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3522,3 +3522,95 @@ "Module: %@ · Session: %@ · Minimum level: %@" = "模块:%@ · 会话:%@ · 最低级别:%@"; "Minimum level" = "最低级别"; "Open Log Directory" = "打开日志目录"; +/* Agent skill management */ +"Skills" = "技能"; +"Agent skills" = "Agent 技能"; +"Global skills" = "全局技能"; +"Project skills" = "项目技能"; +"Manage Skills…" = "管理技能…"; +"Search skills" = "搜索技能"; +"Skill files" = "技能文件"; +"All sources" = "全部来源"; +"Skill source" = "技能来源"; +"Skill project" = "当前项目"; +"Skill actions" = "技能操作"; +"Refresh skills" = "刷新技能"; +"New skill" = "新建技能"; +"Import skill" = "导入技能"; +"Import SKILL.md" = "导入 SKILL.md"; +"Import skill directory" = "导入技能目录"; +"View skill" = "查看"; +"Skill target Agent" = "适用 Agent"; +"Copy path" = "复制路径"; +"Open SKILL.md" = "打开 SKILL.md"; +"No skills in this scope." = "当前分类暂无技能。"; +"No matching skills." = "没有匹配的技能。"; +"Open a project to manage its skills." = "请先打开项目,再管理项目技能。"; +"Enabled in Agent" = "Agent 已启用"; +"Disabled in Agent" = "Agent 已禁用"; +"Built-in" = "内置"; +"Managed" = "托管"; +"Plugin" = "插件"; +"Import source" = "导入来源"; +"Create skill" = "创建技能"; +"Skill instructions" = "技能正文"; +"Skill name (lowercase-with-hyphens)" = "技能名称(小写字母与连字符)"; +"When should this skill be used?" = "什么情况下应使用这个技能?"; +"Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation." = "技能已保存。如果对话中未显示,请刷新或重新连接 Agent。"; +"Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions." = "Codex 显示官方发现的技能;Claude 显示所选项目和用户目录中的技能文件。Claude 会话还可能包含继承目录和插件提供的技能。"; +"File inventory only. Claude determines whether this skill is available in a session." = "此处仅列出技能文件。技能是否在会话中可用,由 Claude 决定。"; +"Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "导入目录会保留脚本、模板等配套文件,不会覆盖已有技能。"; +"Install Codex CLI to discover its skills." = "请先安装 Codex CLI,再查看它发现的技能。"; +"Codex skill discovery failed. Check the CLI and retry." = "Codex 技能发现失败,请检查 CLI 后重试。"; +"The skill file could not be opened." = "无法打开技能文件。"; +"The skill file could not be read." = "无法读取技能文件。"; +"The skill document is unavailable. Refresh and retry." = "技能文件当前不可用,请刷新后重试。"; +"SKILL.md must use UTF-8 text." = "SKILL.md 必须使用 UTF-8 编码。"; +"SKILL.md needs YAML frontmatter." = "SKILL.md 需要 YAML 格式的文件头。"; +"SKILL.md has invalid YAML frontmatter." = "SKILL.md 的 YAML 文件头格式不正确。"; +"SKILL.md needs a name and description." = "SKILL.md 需要技能名称和说明。"; +"SKILL.md frontmatter is not closed." = "SKILL.md 的文件头缺少结束标记。"; +"Enter a description and instructions." = "请填写技能说明和正文。"; +"Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "技能名称最多 64 个字符,请使用小写字母、数字和单个连字符。"; +"Select SKILL.md or its containing directory." = "请选择 SKILL.md 文件或包含该文件的目录。"; +"A skill with this directory name already exists. Existing skills are never overwritten." = "同名技能目录已存在,不会覆盖已有技能。"; +"Skill imports cannot contain symbolic links or special files." = "导入的技能不能包含符号链接或特殊文件。"; +"Import a regular file or directory instead of a symbolic link." = "请导入普通文件或目录,而不是符号链接。"; +"The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory." = "技能目标位置包含符号链接或非目录项目,请使用普通技能目录。"; +"The skill could not be published. Check permissions and existing skill names." = "无法保存技能,请检查目录权限以及是否存在同名技能。"; +"Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." = "Codex 报告了技能发现错误,请检查 SKILL.md 文件并刷新。"; +"A skill was omitted because its native metadata is incomplete." = "有技能的原生信息不完整,未在列表中显示。"; +"A skill has an unknown scope and cannot be classified." = "有技能的作用范围未知,暂时无法分类。"; +"That skill name is reserved by Claude. Choose a different name." = "该技能名称为 Claude 保留,请使用其他名称。"; +"SKILL.md exceeds the 128 KiB limit." = "SKILL.md 超过了 128 KiB 的大小限制。"; +"SKILL.md needs instructions after its frontmatter." = "请在 SKILL.md 的文件头后添加技能正文。"; + +"Enable skill" = "启用技能"; + +"State unknown" = "状态未知"; + +"Switches save native Agent settings. Reconnect existing conversations to refresh available skills." = "开关保存到 Agent 原生配置。已有对话需重新连接以刷新可用技能。"; + +"Skill setting saved." = "技能设置已保存。"; + +"Codex saves this setting by path for all projects using this skill." = "Codex 按路径保存此设置,影响使用该技能路径的所有项目。"; + +"Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting." = "Claude 按名称保存可见性:全局技能写入用户配置,项目技能写入项目本地配置。项目及受管配置可能覆盖全局设置;同名技能共用此设置。"; + +"Skill settings could not be updated. Check the settings file and refresh." = "无法更新技能设置,请检查配置文件后刷新。"; + +"Claude skill settings could not be read. Fix invalid settings and refresh before toggling." = "无法读取 Claude 技能配置,请修复无效配置后刷新再操作开关。"; + +"Choose whether to enable the skill." = "请选择启用或停用技能。"; + +"Codex skill settings could not be confirmed. Refresh to check the current state." = "无法确认 Codex 技能设置,请刷新查看当前状态。"; + +"The Agent did not apply the skill setting. Refresh and check project or managed configuration." = "Agent 未应用此技能设置,请刷新并检查项目或受管配置。"; + +"The skill is no longer available in this scope. Refresh and retry." = "此技能已不在当前范围内,请刷新后重试。"; + +"Claude plugin skills must be managed through Claude's plugin settings." = "Claude 插件技能需通过 Claude 的插件设置管理。"; + +"Skill settings are busy. Retry after the other change finishes." = "技能配置正在被修改,请等待其他修改完成后重试。"; + +"Skill settings changed elsewhere. Refresh and retry." = "技能配置已在其他位置更改,请刷新后重试。"; diff --git a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift new file mode 100644 index 0000000000..257058229b --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift @@ -0,0 +1,165 @@ +import Foundation +import LitheCoreContracts + +/// Settings-only state. Discovery and file management never start a conversation. +@MainActor +final class AgentSkillsFeatureModel: ObservableObject { + struct Context: Equatable { let workspace: URL? } + struct Item: Identifiable, Equatable { + let agentID: String + let source: String + let skill: AgentSkill + var id: String { agentID + ":" + skill.id } + var agentName: String { agentID == "codex-acp" ? "Codex" : "Claude" } + } + enum Phase: Equatable { case idle, loading, ready, failed } + static let agents = ["codex-acp", "claude-acp"] + @Published private(set) var phase: Phase = .idle + @Published private(set) var listings: [String: AgentSkillListing] = [:] + @Published private(set) var directories: [String: [String: String]] = [:] + @Published private(set) var discoveryErrors: [String: String] = [:] + @Published private(set) var error: String? + @Published private(set) var savedURL: URL? + @Published private(set) var isWriting = false + @Published private(set) var togglingID: String? + @Published private(set) var didChangeEnablement = false + @Published var query = "" + @Published var scope = "user" + @Published var agentFilter = "all" + private let service: any AgentManagementService + private var context: Context? + private var revision = UUID() + private var writeTask: Task? + private var refreshTask: Task? + + init(service: any AgentManagementService) { self.service = service } + + private var matchingItems: [Item] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.agents.filter { agentFilter == "all" || agentFilter == $0 }.flatMap { agentID -> [Item] in + guard let listing = listings[agentID] else { return [Item]() } + return listing.skills.map { Item(agentID: agentID, source: listing.source, skill: $0) } + }.filter { item in + query.isEmpty || [item.skill.name, item.skill.description, item.skill.path] + .contains { $0.localizedCaseInsensitiveContains(query) } + }.sorted { + ($0.skill.name.lowercased(), $0.agentID, $0.skill.path) < ($1.skill.name.lowercased(), $1.agentID, $1.skill.path) + } + } + + func count(in scope: String) -> Int { matchingItems.filter { belongs($0, to: scope) }.count } + var visibleItems: [Item] { matchingItems.filter { belongs($0, to: scope) } } + private func belongs(_ item: Item, to scope: String) -> Bool { + scope == "repo" ? (context?.workspace != nil && item.skill.scope == "repo") : ["user", "system", "admin"].contains(item.skill.scope) + } + + func directory(agentID: String, scope: String) -> String? { directories[agentID]?[scope] } + + func read(_ item: Item) async throws -> String { + try await service.readSkill(at: URL(fileURLWithPath: item.skill.path), agentID: item.agentID) + } + + func reload(_ next: Context) { + guard !isWriting else { return } + refreshTask?.cancel() + refreshTask = Task { [weak self] in await self?.refresh(next) } + } + + func refresh(_ next: Context) async { + // A manual refresh must not invalidate a write in the same context. + guard !isWriting || context != next else { return } + if context != next { + writeTask?.cancel(); writeTask = nil; isWriting = false + togglingID = nil; didChangeEnablement = false + listings = [:]; directories = [:]; discoveryErrors = [:]; savedURL = nil + } + context = next + let token = UUID(); revision = token + error = nil; phase = .loading + let service = service + // A missing Codex CLI must not suppress the independently available Claude inventory. + async let codex = Self.load(service, agentID: "codex-acp", workspace: next.workspace) + async let claude = Self.load(service, agentID: "claude-acp", workspace: next.workspace) + async let codexDirectories = Self.loadDirectories(service, agentID: "codex-acp", workspace: next.workspace) + async let claudeDirectories = Self.loadDirectories(service, agentID: "claude-acp", workspace: next.workspace) + let results = await [codex, claude] + let paths = await [codexDirectories, claudeDirectories] + guard !Task.isCancelled, revision == token else { return } + for (index, agentID) in Self.agents.enumerated() { + let result = results[index] + switch result { + case .success(let listing): listings[agentID] = listing; discoveryErrors[agentID] = nil + case .failure(let reason): discoveryErrors[agentID] = reason.message + } + switch paths[index] { + case .success(let paths): directories[agentID] = paths + case .failure(let reason): + directories[agentID] = listings[agentID]?.directories ?? [:] + discoveryErrors[agentID] = [discoveryErrors[agentID], reason.message].compactMap { $0 }.joined(separator: "\n") + } + } + phase = discoveryErrors.count == Self.agents.count ? .failed : .ready + } + + private nonisolated static func loadDirectories(_ service: any AgentManagementService, agentID: String, workspace: URL?) async -> Result<[String: String], Failure> { + // Destination lookup creates no files and needs no CLI. + do { return .success(try await service.skillDirectories(agentID: agentID, workspace: workspace)) } + catch { return .failure(Failure(message: error.localizedDescription)) } + } + + private struct Failure: Error { let message: String } + private nonisolated static func load(_ service: any AgentManagementService, agentID: String, workspace: URL?) async -> Result { + do { return .success(try await service.listSkills(agentID: agentID, workspace: workspace)) } + catch { return .failure(Failure(message: error.localizedDescription)) } + } + + /// Capture the project before awaiting; switching projects invalidates delayed results. + func setEnabled(_ enabled: Bool, for item: Item, context expected: Context) { + guard !isWriting, phase != .loading, context == expected, + item.skill.enabled != nil, item.skill.enabled != enabled, + listings[item.agentID]?.skills.contains(item.skill) == true else { return } + isWriting = true; togglingID = item.id; error = nil; didChangeEnablement = false + let token = UUID(); revision = token + let service = service + let request = AgentSkillEnablement(agentID: item.agentID, workspace: expected.workspace, skill: item.skill, enabled: enabled) + writeTask = Task { [weak self] in + do { + let listing = try await service.setSkillEnabled(request) + guard let self, !Task.isCancelled, self.revision == token else { return } + self.listings[item.agentID] = listing + self.discoveryErrors[item.agentID] = nil + self.isWriting = false; self.togglingID = nil; self.didChangeEnablement = true; self.writeTask = nil + } catch { + guard let self, !Task.isCancelled, self.revision == token else { return } + self.error = error.localizedDescription + self.isWriting = false; self.togglingID = nil; self.writeTask = nil + } + } + } + + func write(_ request: AgentSkillWrite, context expected: Context) { + guard !isWriting, context == expected, phase != .loading else { return } + isWriting = true; error = nil; savedURL = nil + let token = UUID(); revision = token + let service = service + writeTask = Task { [weak self] in + do { + let url = try await service.writeSkill(request) + guard let self, !Task.isCancelled, self.revision == token else { return } + self.savedURL = url; self.isWriting = false + await self.refresh(expected) + if self.context == expected { self.writeTask = nil } + } catch { + guard let self, !Task.isCancelled, self.revision == token else { return } + self.error = error.localizedDescription; self.isWriting = false; self.writeTask = nil + } + } + } + + func dismissResult() { savedURL = nil; error = nil; didChangeEnablement = false } + func cancel() { + revision = UUID(); writeTask?.cancel(); writeTask = nil; refreshTask?.cancel(); refreshTask = nil; isWriting = false + togglingID = nil; didChangeEnablement = false + if phase == .loading { phase = .idle } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift index f4ec18b900..20e05cb1e3 100644 --- a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift +++ b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift @@ -7,6 +7,41 @@ import LitheRustCore struct RustAgentManagementService: AgentManagementService { let core: RustCoreBridge + private struct SkillsPayload: Encodable, Sendable { + var action = "list" + let agentId: String + let workspace: String? + } + private struct SkillDirectoriesResult: Decodable, Sendable { let directories: [String: String] } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { + let result: SkillDirectoriesResult = try await run("agent.skills", SkillsPayload(action: "directories", agentId: agentID, workspace: workspace?.path)) + return result.directories + } + private struct SkillReadPayload: Encodable, Sendable { + let action = "read" + let agentId: String + let source: String + } + private struct SkillReadResult: Decodable, Sendable { let content: String } + func readSkill(at url: URL, agentID: String) async throws -> String { + let result: SkillReadResult = try await run("agent.skills", SkillReadPayload(agentId: agentID, source: url.path)) + return result.content + } + private struct SkillWriteResult: Decodable, Sendable { let path: String } + + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + try await run("agent.skills", SkillsPayload(agentId: agentID, workspace: workspace?.path)) + } + + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + let result: SkillWriteResult = try await run("agent.skills", request) + return URL(fileURLWithPath: result.path) + } + + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { + try await run("agent.skills", request) + } + private struct StatusPayload: Encodable { let dataDirectory: String } private struct AgentPayload: Encodable { let dataDirectory: String; let agentId: String } private struct InstallResult: Decodable { let installedVersion: String } diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 6587caf909..6d52c631d2 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -931,7 +931,7 @@ private struct SettingsWindowHost: View { private struct SettingsWindow: View { @ObservedObject var model: AppModel @ObservedObject var settings: AppSettings - let projectSessions: ProjectSessionManager + @ObservedObject var projectSessions: ProjectSessionManager let bindingID: UUID @StateObject private var windowReference = SettingsWindowReference() @StateObject private var viewState: SettingsViewState @@ -961,6 +961,7 @@ private struct SettingsWindow: View { viewState: viewState, initialCategory: model.requestedSettingsCategory, categoryRequest: model.settingsCategoryRequest, + skillProjects: projectSessions.openProjects.compactMap(\.workspaceURL), onDismiss: close ) .environmentObject(model) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 01525a491e..7f6e4ab071 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -98,6 +98,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { service: services.agentManagement, dataDirectory: agentDataDirectory ) + lazy var agentSkillsFeature = AgentSkillsFeatureModel(service: services.agentManagement) /// Settings › Plugins marketplace and plugin repositories, created when /// the page is first shown. lazy var pluginMarketplaceFeature = PluginMarketplaceFeatureModel( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 4c456f932f..2b66eef229 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -12,6 +12,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case lsp = "LSP" case ai = "AI & Commit" case agents = "Agents" + case skills = "Skills" case providers = "AI Providers" case git = "Git" case updates = "Updates" @@ -45,6 +46,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .lsp: "server.rack" case .ai: "wand.and.stars" case .agents: "sparkles" + case .skills: "book" case .providers: "key" case .git: "arrow.triangle.branch" case .updates: "arrow.down.circle" @@ -78,7 +80,7 @@ enum SettingsGroup: String, CaseIterable, Identifiable { case .build: [.project, .run] case .languages: [.lsp] case .tools: [.terminal, .diagnostics] - case .ai: [.mcp, .ai, .agents, .providers] + case .ai: [.mcp, .ai, .agents, .skills, .providers] } } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift index 0303933abb..0f2b7fdd10 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift @@ -53,6 +53,8 @@ private struct AgentsManagementPage: View { statisticsRow } agentPicker + Button("Manage Skills…") { model.showSettings(category: .skills) } + .buttonStyle(LitheSecondaryButtonStyle()) checkFeedback switch selectedEntry { case .agent(let id): diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift new file mode 100644 index 0000000000..38dee0e21d --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift @@ -0,0 +1,112 @@ +import SwiftUI +import LitheCoreContracts + +struct AgentSkillDetailSheet: View { + let item: AgentSkillsFeatureModel.Item + @ObservedObject var feature: AgentSkillsFeatureModel + let onOpen: () -> Void + let onReveal: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var content: String? + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(verbatim: item.skill.name).font(LitheTheme.uiFont(size: 16, weight: .semibold)) + if let plugin = item.skill.pluginId { Text(verbatim: plugin).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + Text(verbatim: item.agentName).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Text(verbatim: item.skill.description).font(LitheTheme.settingsFont).textSelection(.enabled) + Text(verbatim: item.skill.path).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).textSelection(.enabled) + if item.source == "directory" { + Text("File inventory only. Claude determines whether this skill is available in a session.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } else if let enabled = item.skill.enabled { + Text(enabled ? "Enabled in Agent" : "Disabled in Agent").font(LitheTheme.smallFont) + } + if let error { AgentSettingsIssue(error) } + if let content { + ScrollView { + Text(verbatim: content).font(LitheTheme.codeFont).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding(8) + }.litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) + } else if error == nil { ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) } + HStack(spacing: 8) { + Button("Open SKILL.md", action: onOpen).buttonStyle(LitheSecondaryButtonStyle()) + Button("Reveal in Finder", action: onReveal).buttonStyle(LitheSecondaryButtonStyle()) + Spacer() + Button("Close") { dismiss() }.buttonStyle(LitheSecondaryButtonStyle()).keyboardShortcut(.cancelAction) + } + } + .padding(20).frame(width: 560, height: 570).background(LitheTheme.settingsSurface) + .task(id: item.id) { + do { + let result = try await feature.read(item) + if !Task.isCancelled { content = result } + } catch { if !Task.isCancelled { self.error = error.localizedDescription } } + } + } +} + +struct AgentSkillCreateSheet: View { + @ObservedObject var feature: AgentSkillsFeatureModel + let context: AgentSkillsFeatureModel.Context + let scope: String + let initialAgentID: String + let source: URL? + @Environment(\.dismiss) private var dismiss + @State private var agentID = "codex-acp" + @State private var name = "" + @State private var description = "" + @State private var instructions = "" + + private var valid: Bool { + source != nil || (!name.isEmpty && !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(source == nil ? "New skill" : "Import skill").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + HStack(spacing: 8) { + Text("Agent") + LitheSettingsSelect(selection: $agentID, options: AgentSkillsFeatureModel.agents, width: 180, + accessibilityLabel: "Skill target Agent", title: { $0 == "codex-acp" ? "Codex" : "Claude" }, localizesTitles: false) + Text(scope == "repo" ? "Project skills" : "Global skills").foregroundStyle(LitheTheme.secondaryText) + } + if let directory = feature.directory(agentID: agentID, scope: scope) { + Text(verbatim: directory).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).textSelection(.enabled) + } + if let source { + Text("Import source").font(LitheTheme.smallFont) + Text(verbatim: source.path).font(LitheTheme.codeFont).textSelection(.enabled) + Text("Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } else { + TextField("Skill name (lowercase-with-hyphens)", text: $name).litheSettingsTextField() + TextField("When should this skill be used?", text: $description).litheSettingsTextField() + Text("Skill instructions").font(LitheTheme.smallFont) + TextEditor(text: $instructions).font(LitheTheme.codeFont).litheSettingsTextEditor(height: 230) + } + if let error = feature.error { AgentSettingsIssue(error) } + HStack(spacing: 8) { + if feature.isWriting { ProgressView().controlSize(.small) } + Spacer() + Button("Cancel") { dismiss() }.buttonStyle(LitheSecondaryButtonStyle()).keyboardShortcut(.cancelAction) + Button(source == nil ? "Create skill" : "Import") { + let request: AgentSkillWrite + if let source { request = .init(agentID: agentID, workspace: context.workspace, scope: scope, source: source) } + else { request = .init(agentID: agentID, workspace: context.workspace, scope: scope, name: name, + description: description, instructions: instructions) } + feature.write(request, context: context) + }.buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) + .disabled(!valid || feature.phase == .loading) + } + } + .padding(20).frame(width: 540, height: source == nil ? 490 : 300) + .background(LitheTheme.settingsSurface).disabled(feature.isWriting) + .interactiveDismissDisabled(feature.isWriting) + .onAppear { agentID = initialAgentID } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift new file mode 100644 index 0000000000..0ac86443fb --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import LitheCoreContracts + +/// Scope is the main navigation; Agent provenance is secondary filtering. +struct AgentSkillsSettingsView: View { + @ObservedObject var feature: AgentSkillsFeatureModel + let workspace: URL? + var openProjects: [URL] = [] + var preferredAgentID: String? + let onOpen: (URL) -> Void + let onReveal: (URL) -> Void + let onCopy: (String) -> Void + let onChooseImport: (Bool, String, String) -> URL? + @Environment(\.locale) private var locale + @State private var selectedProjectPath = "" + @State private var sheet: AgentSkillSheet? + + private var projectPaths: [String] { + var paths = openProjects.map(\.path) + if let path = workspace?.path { paths.insert(path, at: 0) } + return paths.reduce(into: []) { if !$0.contains($1) { $0.append($1) } } + } + private var context: AgentSkillsFeatureModel.Context { + let path = projectPaths.contains(selectedProjectPath) ? selectedProjectPath : projectPaths.first + return .init(workspace: path.map { URL(fileURLWithPath: $0) }) + } + private var busy: Bool { feature.isWriting || feature.phase == .loading } + private var canAdd: Bool { !busy && (feature.scope != "repo" || context.workspace != nil) } + private func text(_ key: String) -> String { AgentLocalization.message(key, locale: locale) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Text("Skills").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheInfoTip("Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions.") + Spacer(minLength: 0) + if !projectPaths.isEmpty { + LitheSettingsSelect(selection: Binding(get: { context.workspace?.path ?? "" }, set: { selectedProjectPath = $0 }), + options: projectPaths, width: 190, accessibilityLabel: "Skill project", title: { URL(fileURLWithPath: $0).lastPathComponent }, localizesTitles: false) + .disabled(feature.isWriting) + } + } + LitheSettingsSegmentedControl(selection: $feature.scope, options: ["user", "repo"], width: 300, + title: { text($0 == "user" ? "Global skills" : "Project skills") + " \(feature.count(in: $0))" }) + .disabled(feature.isWriting) + LitheSearchTextField("Search skills", text: $feature.query).litheSearchField() + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { toolbar } + VStack(alignment: .leading, spacing: 8) { toolbar } + } + feedback + if feature.scope == "repo" && context.workspace == nil { + Text("Open a project to manage its skills.").foregroundStyle(LitheTheme.secondaryText).padding(.vertical, 16) + } else if feature.phase != .loading && feature.visibleItems.isEmpty { + Text(feature.query.isEmpty ? "No skills in this scope." : "No matching skills.") + .foregroundStyle(LitheTheme.secondaryText).padding(.vertical, 16) + } + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(feature.visibleItems) { item in row(item) } + } + } + .font(LitheTheme.settingsFont) + .frame(maxWidth: 760, alignment: .leading) + .task(id: context) { await feature.refresh(context) } + .onChange(of: context) { _ in sheet = nil } + .onChange(of: workspace) { _ in selectedProjectPath = workspace?.path ?? "" } + .onChange(of: feature.savedURL) { url in if url != nil { sheet = nil } } + .onDisappear { feature.cancel() } + .sheet(item: $sheet) { sheet in + switch sheet { + case .details(let item): + AgentSkillDetailSheet(item: item, feature: feature, + onOpen: { open(item.skill.path) }, onReveal: { reveal(item.skill.path) }) + case .add(let source): + AgentSkillCreateSheet(feature: feature, context: context, scope: feature.scope, + initialAgentID: initialAgentID, source: source) + } + } + } + + private var initialAgentID: String { + if AgentSkillsFeatureModel.agents.contains(feature.agentFilter) { return feature.agentFilter } + return AgentSkillsFeatureModel.agents.contains(preferredAgentID ?? "") ? preferredAgentID! : "codex-acp" + } + + @ViewBuilder private var toolbar: some View { + LitheSettingsSelect(selection: $feature.agentFilter, options: ["all"] + AgentSkillsFeatureModel.agents, + width: 140, accessibilityLabel: "Skill source", title: { $0 == "all" ? text("All sources") : ($0 == "codex-acp" ? "Codex" : "Claude") }, localizesTitles: false) + HStack(spacing: 8) { + Button { feature.reload(context) } label: { + LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: LitheTheme.Metrics.toolbarIconSize, + fallbackSystemImage: "arrow.clockwise", preservesOriginalColors: true) + }.litheToolbarIconButton().help("Refresh skills").disabled(busy) + LitheMenu { + LitheContextMenuItem.action("Import SKILL.md") { chooseImport(directory: false) } + LitheContextMenuItem.action("Import skill directory") { chooseImport(directory: true) } + } label: { Text("Import") }.buttonStyle(LitheSecondaryButtonStyle()).disabled(!canAdd) + Button("New skill") { feature.dismissResult(); sheet = .add(nil) } + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)).disabled(!canAdd) + } + } + + @ViewBuilder private var feedback: some View { + if feature.phase == .loading { ProgressView().controlSize(.small) } + Text("Switches save native Agent settings. Reconnect existing conversations to refresh available skills.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + if feature.didChangeEnablement { + Text("Skill setting saved.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if feature.savedURL != nil { + Text("Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if let error = feature.error { AgentSettingsIssue(text(error)) } + ForEach(AgentSkillsFeatureModel.agents, id: \.self) { agentID in + if feature.agentFilter == "all" || feature.agentFilter == agentID { + if let error = feature.discoveryErrors[agentID] { + Text(verbatim: (agentID == "codex-acp" ? "Codex: " : "Claude: ") + text(error)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).textSelection(.enabled) + } + ForEach(Array((feature.listings[agentID]?.issues ?? []).enumerated()), id: \.offset) { _, issue in + Text(verbatim: text(issue)).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).textSelection(.enabled) + } + } + } + } + + private func row(_ item: AgentSkillsFeatureModel.Item) -> some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text(verbatim: item.skill.name).font(LitheTheme.uiFont(size: 13, weight: .medium)).lineLimit(1) + AgentBadge(text: LocalizedStringKey(item.agentName), color: LitheTheme.secondaryText) + if item.skill.pluginId != nil { Text("Plugin").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if item.skill.scope == "system" { Text("Built-in").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if item.skill.scope == "admin" { Text("Managed").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + } + Text(verbatim: item.skill.description).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(2) + }.frame(maxWidth: .infinity, alignment: .leading) + if let enabled = item.skill.enabled { + Toggle("Enable skill", isOn: Binding(get: { enabled }, set: { feature.setEnabled($0, for: item, context: context) })) + .toggleStyle(.switch).controlSize(.small).labelsHidden() + .accessibilityLabel(Text(verbatim: text("Enable skill") + ": " + item.skill.name)) + .help(item.agentID == "codex-acp" + ? "Codex saves this setting by path for all projects using this skill." + : "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.") + .disabled(busy) + } else { + Text("State unknown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if feature.togglingID == item.id { ProgressView().controlSize(.small) } + Button("View skill") { sheet = .details(item) }.buttonStyle(LitheSecondaryButtonStyle()) + LitheMenu { + LitheContextMenuItem.action("Open SKILL.md") { open(item.skill.path) } + LitheContextMenuItem.action("Reveal in Finder") { reveal(item.skill.path) } + LitheContextMenuItem.action("Copy path") { onCopy(item.skill.path) } + } label: { + LitheIDEAIcon(resourcePath: "expui/general/more.svg", size: LitheTheme.Metrics.toolbarIconSize, + fallbackSystemImage: "ellipsis", preservesOriginalColors: true) + }.litheToolbarIconButton().accessibilityLabel("Skill actions") + } + .padding(10) + .background(LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) + } + + private func chooseImport(directory: Bool) { + let title = text(directory ? "Import skill directory" : "Import skill") + let source = onChooseImport(directory, title, text("Import")) + if let source { feature.dismissResult(); sheet = .add(source) } + } + private func open(_ path: String) { onOpen(URL(fileURLWithPath: path)) } + private func reveal(_ path: String) { onReveal(URL(fileURLWithPath: path)) } +} + +private enum AgentSkillSheet: Identifiable { + case details(AgentSkillsFeatureModel.Item) + case add(URL?) + var id: String { + switch self { case .details(let item): "details:" + item.id; case .add(let source): "add:" + (source?.path ?? "new") } + } +} diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index 5c53c91a8c..8903891ec0 100644 --- a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift +++ b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift @@ -33,6 +33,8 @@ enum SettingsSearchVocabulary { ["AI & Commit", "Commit message", "Pull request"] case .agents: ["Agents", "Agent conversation", "Codex", "Claude", "ACP adapter", "Preflight", "Providers"] + case .skills: + ["Skills", "Agent skills", "SKILL.md", "Global skills", "Project skills", "Import skill", "New skill", "Codex", "Claude"] case .providers: ["AI Providers", "AI provider", "Model", "API key", "Endpoint", "Responses", "Anthropic"] case .git: diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index ce40e60a16..1f993a8c38 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -63,6 +63,7 @@ struct SettingsView: View { let initialCategory: SettingsCategory /// Changes with every category request; see `WorkbenchFeatureModel.settingsCategoryRequest`. let categoryRequest: Int + private let skillProjects: [URL] private let onDismiss: (() -> Void)? static let categoryMinimumWidth: CGFloat = 234 static let contentMinimumWidth: CGFloat = 500 @@ -73,12 +74,14 @@ struct SettingsView: View { viewState: SettingsViewState, initialCategory: SettingsCategory = .general, categoryRequest: Int = 0, + skillProjects: [URL] = [], onDismiss: (() -> Void)? = nil ) { self.settings = settings self.viewState = viewState self.initialCategory = initialCategory self.categoryRequest = categoryRequest + self.skillProjects = skillProjects self.onDismiss = onDismiss } @@ -362,6 +365,14 @@ struct SettingsView: View { case .agents: AgentPanelSettingsView(model: model, settings: settings, feature: model.agentManagementFeature) .id(model.settingsCategoryRequest) + case .skills: + AgentSkillsSettingsView(feature: model.agentSkillsFeature, workspace: model.workspaceURL, + openProjects: skillProjects, preferredAgentID: model.selectedAgentConversationID, + onOpen: { model.platformUI.open($0) }, onReveal: { model.platformUI.revealInFileBrowser($0) }, + onCopy: { model.platformUI.copyToClipboard($0) }, onChooseImport: { directory, title, prompt in + directory ? model.platformUI.chooseDirectory(title: title, prompt: prompt) + : model.platformUI.chooseFile(title: title, prompt: prompt) + }) case .providers: providersSettings case .git: VStack(alignment: .leading, spacing: 14) { diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index bfb11f67e3..c8b56a8bdf 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -512,11 +512,12 @@ final class LitheDropdownResizeHandle: ProjectReplaceCornerHandleView { @MainActor private final class LitheContextMenuPanel: NSPanel { + var retainsParentKey = false var handleKey: ((NSEvent) -> Bool)? var handleContentKey: ((NSEvent) -> Bool)? var handleMouseMoved: ((NSEvent) -> Void)? var handleResizeCursor: ((NSEvent) -> Void)? - override var canBecomeKey: Bool { true } + override var canBecomeKey: Bool { !retainsParentKey } override func sendEvent(_ event: NSEvent) { if event.type == .keyDown, handleKey?(event) == true { return } if event.type == .mouseMoved { handleMouseMoved?(event) } @@ -585,6 +586,8 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { private var panel: LitheContextMenuPanel? private var localEventMonitor: Any? private var globalEventMonitor: Any? + private var parentKeyObserver: NSObjectProtocol? + private var applicationObserver: NSObjectProtocol? private var visibleFrame: NSRect = .zero private weak var customContentController: NSViewController? private var contentDismissed: (() -> Void)? @@ -676,6 +679,11 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { .environment(\.locale, locale) let panel = makePanel(contentController: NSHostingController(rootView: content), appearance: appearance) + // Action menus need keyboard navigation, but no text editor. Keep the + // originating window key so its native switches retain their on tint. + // Searchable/custom dropdowns still take key focus in the other show path. + let owner = parentWindow ?? NSApp.currentEvent?.window ?? NSApp.keyWindow + panel.retainsParentKey = owner?.isKeyWindow == true panel.handleKey = { selection.handle($0) } panel.acceptsMouseMovedEvents = true panel.handleMouseMoved = { [weak panel] event in @@ -690,11 +698,22 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { self.panel = panel triggerView = trigger - parentWindow?.addChildWindow(panel, ordered: .above) + owner?.addChildWindow(panel, ordered: .above) contentDismissed = onDismiss installEventMonitors() panel.orderFrontRegardless() - panel.makeKey() + if panel.retainsParentKey, let owner { + parentKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, + object: owner, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dismiss() } + } + applicationObserver = NotificationCenter.default.addObserver(forName: NSApplication.didResignActiveNotification, + object: NSApp, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dismiss() } + } + } else { + panel.makeKey() + } } private func makePanel(contentController: NSViewController, appearance: NSAppearance?) -> LitheContextMenuPanel { @@ -1058,8 +1077,19 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { matching: [.leftMouseDown, .rightMouseDown, .keyDown] ) { [weak self] event in guard let self else { return event } - if event.type == .keyDown, event.window === self.panel, self.panel?.handleKey?(event) == true { - return nil + if event.type == .keyDown, + self.panel?.childWindows?.contains(where: { ($0 as? LitheContextMenuPanel)?.retainsParentKey == true && $0.isVisible }) == true { + // A child action menu routes the key from this window itself. + return event + } + if event.type == .keyDown, + event.window === self.panel || (self.panel?.retainsParentKey == true && event.window === self.panel?.parent) { + if self.panel?.handleKey?(event) == true { return nil } + if self.panel?.retainsParentKey == true { + // Unhandled typing/shortcuts belong to the parent only after dismissal. + self.dismiss() + return event + } } var eventWindow = event.window while let window = eventWindow { @@ -1084,6 +1114,8 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } private func removeEventMonitors() { + if let parentKeyObserver { NotificationCenter.default.removeObserver(parentKeyObserver); self.parentKeyObserver = nil } + if let applicationObserver { NotificationCenter.default.removeObserver(applicationObserver); self.applicationObserver = nil } if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) self.localEventMonitor = nil diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift index e57de9c358..e53fd5f464 100644 --- a/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift +++ b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift @@ -157,6 +157,11 @@ public struct AgentCliUpdateResult: Decodable, Equatable, Sendable { /// Detects the user's runtime and installs ACP adapters with the user's npm. /// Node.js remains user-managed; CLI updates follow their verified installation owners. public protocol AgentManagementService: Sendable { + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] + func readSkill(at url: URL, agentID: String) async throws -> String + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing + func writeSkill(_ request: AgentSkillWrite) async throws -> URL + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing func status(dataDirectory: URL) async throws -> AgentManagementStatus /// Installs the pinned adapter version; returns the installed version. func install(agentID: String, dataDirectory: URL) async throws -> String @@ -171,6 +176,16 @@ public protocol AgentManagementService: Sendable { } public extension AgentManagementService { + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { throw CocoaError(.featureUnsupported) } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { [:] } + func readSkill(at url: URL, agentID: String) async throws -> String { throw CocoaError(.featureUnsupported) } + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + throw CocoaError(.featureUnsupported) + } + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + throw CocoaError(.featureUnsupported) + } + func install(agentID: String, dataDirectory: URL, onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String { try await install(agentID: agentID, dataDirectory: dataDirectory) diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift b/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift new file mode 100644 index 0000000000..7743c815de --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Native discovery metadata. Claude enablement is scoped configured visibility, +/// not a claim about managed policy or an already connected session. +public struct AgentSkill: Decodable, Equatable, Identifiable, Sendable { + public var id: String { path } + public let name: String + public let description: String + public let path: String + public let scope: String + public let enabled: Bool? + public let pluginId: String? + + public init(name: String, description: String, path: String, scope: String, enabled: Bool? = nil, pluginId: String? = nil) { + self.name = name; self.description = description; self.path = path; self.scope = scope; self.enabled = enabled; self.pluginId = pluginId + } +} + +/// A deliberate write to native settings, addressed by discovered skill path. +public struct AgentSkillEnablement: Encodable, Sendable { + public let action = "setEnabled" + public let agentId: String + public let workspace: String? + public let scope: String + public let source: String + public let enabled: Bool + + public init(agentID: String, workspace: URL?, skill: AgentSkill, enabled: Bool) { + agentId = agentID; self.workspace = workspace?.path; scope = skill.scope; source = skill.path; self.enabled = enabled + } +} + +public struct AgentSkillListing: Decodable, Equatable, Sendable { + public let skills: [AgentSkill] + /// "agent" for native discovery, "directory" for a partial file inventory. + public let source: String + public let issues: [String] + public let directories: [String: String] + + public init(skills: [AgentSkill], source: String, issues: [String] = [], directories: [String: String] = [:]) { + self.skills = skills; self.source = source; self.issues = issues; self.directories = directories + } +} + +/// Explicit write to the selected Agent's native user/project skill directory. +public struct AgentSkillWrite: Encodable, Sendable { + public let action: String + public let agentId: String + public let workspace: String? + public let scope: String + public let name: String? + public let description: String? + public let instructions: String? + public let source: String? + + public init(agentID: String, workspace: URL?, scope: String, name: String, description: String, instructions: String) { + action = "create"; agentId = agentID; self.workspace = workspace?.path; self.scope = scope + self.name = name; self.description = description; self.instructions = instructions; source = nil + } + + public init(agentID: String, workspace: URL?, scope: String, source: URL) { + action = "import"; agentId = agentID; self.workspace = workspace?.path; self.scope = scope + name = nil; description = nil; instructions = nil; self.source = source.path + } +} diff --git a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift new file mode 100644 index 0000000000..e221082236 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift @@ -0,0 +1,436 @@ +import AppKit +import SwiftUI +import Combine +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@MainActor +struct AgentSkillsFeatureModelTests { + @Test nonisolated func sharedFixtureDecodesNativeAndConfiguredVisibility() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/skills-management-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decode: (String) throws -> AgentSkillListing = { key in + try JSONDecoder().decode(AgentSkillListing.self, from: JSONSerialization.data(withJSONObject: fixture[key] as Any)) + } + let native = try decode("normalizedCodex"), inventory = try decode("claudeInventory") + #expect(native.skills.map(\.scope) == ["user", "repo", "user"]) + #expect(native.skills[1].enabled == false) + #expect(native.skills.first(where: { $0.pluginId != nil })?.pluginId == "helper@example") + #expect(inventory.skills.first?.enabled == false) + #expect(inventory.source == "directory") + } + + @Test func scopeIsPrimaryAndSourceFilteringPreservesNativeProvenance() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + #expect(feature.phase == .ready) + #expect(feature.count(in: "user") == 2) + #expect(feature.count(in: "repo") == 2) + #expect(feature.visibleItems.map(\.agentName) == ["Claude", "Codex"]) + feature.scope = "repo" + #expect(feature.visibleItems.allSatisfy { $0.skill.scope == "repo" }) + feature.agentFilter = "claude-acp" + #expect(feature.visibleItems.count == 1) + #expect(feature.visibleItems.first?.skill.enabled == true) + feature.query = "no-match" + #expect(feature.visibleItems.isEmpty) + feature.query = "PROJECT" + #expect(feature.visibleItems.count == 1) + feature.cancel() + } + + @Test func noProjectNeverReclassifiesTheHomeDirectoryAsAProject() async { + let feature = AgentSkillsFeatureModel(service: SkillsTestService()) + await feature.refresh(.init(workspace: nil)) + #expect(feature.count(in: "user") == 2) + #expect(feature.count(in: "repo") == 0) + feature.scope = "repo" + #expect(feature.visibleItems.isEmpty) + feature.cancel() + } + + @Test func independentFailuresKeepPreviousRowsAndDoNotHideTheOtherAgent() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let previous = feature.listings["codex-acp"] + service.failCodex = true + await feature.refresh(context) + #expect(feature.phase == .ready) + #expect(feature.listings["codex-acp"] == previous) + #expect(feature.discoveryErrors["codex-acp"] != nil) + #expect(feature.discoveryErrors["claude-acp"] == nil) + #expect(feature.directory(agentID: "codex-acp", scope: "repo") != nil) + feature.cancel() + } + + @Test func oldProjectResultsCannotReplaceTheNewProjectsSkills() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + service.holdFirstProject = true + await withTaskGroup(of: Void.self) { group in + group.addTask { await feature.refresh(.init(workspace: service.firstProject)) } + #expect(await awaitChange(on: service) { service.held.count == 2 }, "both old requests reached the controlled gate") + await feature.refresh(.init(workspace: service.secondProject)) + let selected = feature.listings + service.releaseLists() + await group.waitForAll() + #expect(feature.listings == selected) + #expect(feature.visibleItems.allSatisfy { $0.skill.path.contains("second") }) + } + feature.cancel() + } + + @Test func writeRunsOnceWithCapturedScopeAndRefreshesOnlyAfterSuccess() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + service.holdWrite = true + let request = AgentSkillWrite(agentID: "claude-acp", workspace: context.workspace, scope: "repo", + name: "new-skill", description: "Use for testing", instructions: "Inspect the files") + feature.write(request, context: context) + feature.write(request, context: context) + #expect(await awaitChange(on: service) { service.writeRequests.count == 1 }) + #expect(feature.isWriting) + let payload = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(service.writeRequests[0])) as? [String: Any] + #expect(payload?["workspace"] as? String == service.firstProject.path) + #expect(payload?["scope"] as? String == "repo") + service.releaseWrite() + #expect(await awaitChange(on: feature) { !feature.isWriting && feature.savedURL != nil && feature.phase == .ready }) + #expect(service.listRequests == 4) + #expect(service.writeRequests.count == 1) + feature.cancel() + } + + @Test func leavingThePageRejectsLateWriteFeedback() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + service.holdWrite = true + feature.write(.init(agentID: "codex-acp", workspace: context.workspace, scope: "repo", name: "new-skill", + description: "Testing", instructions: "Inspect"), context: context) + #expect(await awaitChange(on: service) { service.writeRequests.count == 1 }) + feature.cancel() + service.releaseWrite() + #expect(await awaitChange(on: service) { service.finishedWrites == 1 }) + #expect(feature.savedURL == nil) + #expect(!feature.isWriting) + } + + @Test func toggleWaitsForNativeReadbackAndSerializesChanges() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.holdWrite = true + feature.setEnabled(false, for: item, context: context) + feature.setEnabled(false, for: item, context: context) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + #expect(feature.isWriting && feature.togglingID == item.id) + #expect(feature.visibleItems.first?.skill.enabled == true, "pending writes do not invent native state") + await feature.refresh(context) + #expect(feature.isWriting, "manual refresh cannot invalidate the write") + let request = try #require(service.toggleRequests.first) + #expect(request.workspace == context.workspace?.path && request.source == item.skill.path && !request.enabled) + service.releaseWrite() + #expect(await awaitChange(on: feature) { feature.didChangeEnablement && !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == false) + #expect(service.toggleRequests.count == 1) + } + + @Test func failedToggleKeepsPreviousStateAndUnknownStateCannotBeChanged() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.failToggle = true + feature.setEnabled(false, for: item, context: context) + #expect(await awaitChange(on: feature) { feature.error != nil && !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true) + #expect(!feature.didChangeEnablement) + service.unknownEnablement = true + await feature.refresh(context) + let unknown = try #require(feature.visibleItems.first) + feature.setEnabled(true, for: unknown, context: context) + #expect(!feature.isWriting && service.toggleRequests.count == 1) + } + + @Test func oldToggleCannotReplaceAnotherProjectsState() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.holdWrite = true + feature.setEnabled(false, for: item, context: context) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + await feature.refresh(.init(workspace: service.secondProject)) + let selected = feature.listings + service.releaseWrite() + #expect(await awaitChange(on: service) { service.finishedToggles == 1 }) + #expect(feature.listings == selected && !feature.didChangeEnablement) + #expect(feature.togglingID == nil && !feature.isWriting) + } +} + +@MainActor +private final class SkillsTestService: ObservableObject, AgentManagementService { + let firstProject = URL(fileURLWithPath: "/fixture/first") + let secondProject = URL(fileURLWithPath: "/fixture/second") + var failCodex = false + var holdFirstProject = false + var holdWrite = false + @Published var held: [String: CheckedContinuation] = [:] + @Published var writeRequests: [AgentSkillWrite] = [] + @Published var finishedWrites = 0 + @Published var readRequests = 0 + @Published var toggleRequests: [AgentSkillEnablement] = [] + @Published var finishedToggles = 0 + var failToggle = false + var unknownEnablement = false + private var enablements: [String: Bool] = [:] + var listRequests = 0 + private var writeGate: CheckedContinuation? + + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + listRequests += 1 + if holdFirstProject && workspace == firstProject { + await withCheckedContinuation { held[agentID] = $0 } + } + if failCodex && agentID == "codex-acp" { throw Failure() } + let root = workspace?.path ?? "/fixture/global" + return .init(skills: [ + .init(name: agentID + "-global", description: "Global test", path: root + "/user/" + agentID + "/SKILL.md", scope: "user", enabled: unknownEnablement ? nil : (enablements[root + "/user/" + agentID + "/SKILL.md"] ?? true)), + .init(name: agentID + "-project", description: "Project test", path: root + "/project/" + agentID + "/SKILL.md", scope: "repo", enabled: unknownEnablement ? nil : (enablements[root + "/project/" + agentID + "/SKILL.md"] ?? true)) + ], source: agentID == "codex-acp" ? "agent" : "directory") + } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { + ["user": "/fixture/user/skills", "repo": (workspace?.path ?? "") + "/skills"] + } + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + writeRequests.append(request) + if holdWrite { await withCheckedContinuation { writeGate = $0 } } + finishedWrites += 1 + return URL(fileURLWithPath: "/fixture/saved/SKILL.md") + } + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { + toggleRequests.append(request) + if holdWrite { await withCheckedContinuation { writeGate = $0 } } + defer { finishedToggles += 1 } + if failToggle { throw Failure() } + enablements[request.source] = request.enabled + return try await listSkills(agentID: request.agentId, workspace: request.workspace.map { URL(fileURLWithPath: $0) }) + } + func readSkill(at url: URL, agentID: String) async throws -> String { + readRequests += 1 + return "---\nname: example\ndescription: A skill for testing\n---\n\n# Workflow\n\n1. Inspect the project.\n2. Explain the result." + } + func releaseLists() { let gates = held.values; held = [:]; for gate in gates { gate.resume() } } + func releaseWrite() { holdWrite = false; writeGate?.resume(); writeGate = nil } + func status(dataDirectory: URL) async throws -> AgentManagementStatus { throw Failure() } + func install(agentID: String, dataDirectory: URL) async throws -> String { throw Failure() } + func uninstall(agentID: String, dataDirectory: URL) async throws { throw Failure() } + func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult { throw Failure() } + private struct Failure: LocalizedError { var errorDescription: String? { "Controlled skill failure" } } +} + +struct AgentSkillsRustBridgeTests { + @Test func nativeCreateReadAndConflictUseTheRealCoreBridgeInAnIsolatedProject() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-skills-bridge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = RustAgentManagementService(core: RustCoreBridge()) + let request = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "bridge-skill", + description: "Verify the native bridge", instructions: "Read the project files.") + let result = try await service.writeSkill(request) + #expect(result.path.hasSuffix(".claude/skills/bridge-skill/SKILL.md")) + let content = try await service.readSkill(at: result, agentID: "claude-acp") + #expect(content.contains("Read the project files.")) + let directories = try await service.skillDirectories(agentID: "claude-acp", workspace: root) + #expect(directories["repo"]?.hasSuffix(".claude/skills") == true) + do { _ = try await service.writeSkill(request); Issue.record("Existing skill must not be overwritten") } + catch { #expect(!error.localizedDescription.isEmpty) } + #expect(try await service.readSkill(at: result, agentID: "claude-acp") == content) + let skill = AgentSkill(name: "bridge-skill", description: "Verify the native bridge", path: result.path, scope: "repo", enabled: true) + let toggled = try await service.setSkillEnabled(.init(agentID: "claude-acp", workspace: root, skill: skill, enabled: false)) + #expect(toggled.skills.first(where: { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath() == result.resolvingSymlinksInPath() })?.enabled == false) + let nativeSettings = try Data(contentsOf: root.appendingPathComponent(".claude/settings.local.json")) + let json = try #require(JSONSerialization.jsonObject(with: nativeSettings) as? [String: Any]) + #expect((json["skillOverrides"] as? [String: String])?["bridge-skill"] == "off") + #expect(try await service.readSkill(at: result, agentID: "claude-acp") == content) + } +} + + +@MainActor +struct AgentSkillsAppearanceTests { + @Test func openingAnActionMenuPreservesTheNativeSwitchStateAndAppearance() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: .dark) { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + }) + let window = SkillMenuFocusWindow(contentRect: NSRect(x: 100, y: 100, width: 760, height: 500), + styleMask: [.titled, .nonactivatingPanel], backing: .buffered, defer: false) + let presenter = LitheContextMenuPresenter() + window.isReleasedWhenClosed = false; window.contentView = host; window.makeKeyAndOrderFront(nil) + defer { presenter.dismiss(); service.releaseWrite(); feature.cancel(); window.contentView = nil; window.close() } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + layout(host) + if dropdownAnchors(host).count >= 4 && feature.phase == .ready { break } + await Task.yield() + } + func switches(_ view: NSView) -> [NSSwitch] { + if let control = view as? NSSwitch { return [control] } + return view.subviews.flatMap(switches) + } + func pixels(_ control: NSView) throws -> Data { + let bitmap = try #require(control.bitmapImageRepForCachingDisplay(in: control.bounds)) + control.cacheDisplay(in: control.bounds, to: bitmap) + let bytes = try #require(bitmap.bitmapData) + return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } + let control = try #require(switches(host).first) + #expect(control.state == .on && control.isEnabled) + let originalPixels = try pixels(control) + try capture(host, name: "skills-menu-before") + let before = feature.listings + var copies = 0 + presenter.show(items: [.action("Copy path") { copies += 1 }], at: NSPoint(x: 800, y: 500), + appearance: NSAppearance(named: .darkAqua), locale: Locale(identifier: "en"), parentWindow: window) + layout(host) + #expect(window.isKeyWindow, "an action menu must retain the settings window's key appearance") + #expect(window.childWindows?.contains(where: { $0.isVisible && !$0.canBecomeKey }) == true) + #expect(feature.listings == before && !feature.isWriting) + #expect(service.toggleRequests.isEmpty, "menu presentation never writes native skill settings") + #expect(control.state == .on && control.isEnabled) + #expect(try pixels(control) == originalPixels, "key-window transfer must not gray an enabled skill") + try capture(host, name: "skills-menu-open") + for code in [UInt16(125), UInt16(36)] { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code))) + } + #expect(copies == 1, "arrow and Return navigation still execute the action once") + #expect(window.childWindows?.contains(where: { $0.isVisible }) != true) + service.holdWrite = true + let item = try #require(feature.visibleItems.first) + feature.setEnabled(false, for: item, context: .init(workspace: service.firstProject)) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + layout(host) + #expect(try #require(switches(host).first).isEnabled == false, "a real pending write must still disable the control") + } + + @Test func scopedListsAndNativeDropdownAnchorsFitNarrowAndWideSettings() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + for scheme in [ColorScheme.dark, .light] { + for width in [CGFloat(500), CGFloat(760)] { + feature.scope = "user" + let root = SettingsAppearanceContainer(themePreference: scheme == .dark ? .dark : .light) { + ScrollView { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + openProjects: [service.firstProject, service.secondProject], preferredAgentID: "codex-acp", + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + } + }.environment(\.locale, Locale(identifier: "zh-Hans")) + let host = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 640), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { feature.cancel(); window.contentView = nil; window.close() } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var anchors: [NSView] = [] + while clock.now < deadline { + layout(host) + anchors = dropdownAnchors(host) + if anchors.count >= 5 && feature.phase == .ready { break } + await Task.yield() + } + #expect(anchors.count >= 5, "project, source, import and each row keep native dropdown anchors") + for anchor in anchors { + let rect = anchor.convert(anchor.bounds, to: host) + #expect(rect.minX >= -1 && rect.maxX <= width + 1, "dropdown stays inside a narrow settings page") + } + try capture(host, name: "skills-\(scheme)-\(Int(width))") + feature.scope = "repo" + host.layoutSubtreeIfNeeded() + #expect(feature.visibleItems.count == 2) + } + } + } + + @Test func detailsAndCreationUseExistingSettingsChrome() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + for scheme in [ColorScheme.dark, .light] { + let views: [(String, AnyView)] = [ + ("detail", AnyView(AgentSkillDetailSheet(item: item, feature: feature, onOpen: {}, onReveal: {}))), + ("create", AnyView(AgentSkillCreateSheet(feature: feature, context: context, scope: "repo", initialAgentID: "codex-acp", source: nil))) + ] + for (name, view) in views { + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: scheme == .dark ? .dark : .light) { + view.environment(\.locale, Locale(identifier: "zh-Hans")) + }) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 560, height: 570), styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderFront(nil) + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + if name == "detail" { + #expect(await awaitChange(on: service) { service.readRequests > (scheme == .dark ? 0 : 1) }) + host.layoutSubtreeIfNeeded() + } + #expect(host.fittingSize.width <= 560) + try capture(host, name: "skill-\(name)-\(scheme)") + } + } + feature.cancel() + } + + private func layout(_ host: NSView) { + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) + host.layoutSubtreeIfNeeded() + } + private func dropdownAnchors(_ view: NSView) -> [NSView] { + // Selectors have their own shared native owner; menus use the general dropdown owner. + if view is LitheDropdownAnchorView || String(describing: type(of: view)) == "LitheSettingsSelectAnchorNSView" { return [view] } + return view.subviews.flatMap(dropdownAnchors) + } + private func capture(_ host: NSView, name: String) throws { + guard let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_SKILLS_SCREENSHOTS"] else { return } + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent(name + ".png")) + } +} + +@MainActor +private final class SkillMenuFocusWindow: NSPanel { + override var canBecomeKey: Bool { true } +} diff --git a/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift new file mode 100644 index 0000000000..62089808c8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift @@ -0,0 +1,90 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@Suite("Action menu focus", .serialized) +@MainActor +struct LitheActionMenuFocusTests { + @Test func actionKeysRemainScopedToTheOwnerAndEscapeRestoresItsResponder() throws { + let window = makeWindow() + let editor = NSTextView(frame: window.contentView!.bounds) + window.contentView = editor + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(editor) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + var calls = 0 + presenter.show(items: [.action("Copy") { calls += 1 }], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + #expect(window.isKeyWindow) + #expect(window.firstResponder === editor) + try sendKey(125, to: window) + try sendKey(53, to: window) + #expect(calls == 0 && !hasPopup(window)) + #expect(window.firstResponder === editor) + } + + @Test func childActionsReceiveKeysBeforeTheSearchableParentsHandler() throws { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let parent = LitheContextMenuPresenter(), child = LitheContextMenuPresenter() + defer { child.dismiss(); parent.dismiss(); window.contentView = nil; window.close() } + var parentKeys = 0, childCalls = 0 + let content = NSHostingController(rootView: Text("Parent") + .frame(width: 200, height: 120) + .litheDropdownKeyHandler { _ in parentKeys += 1; return true }) + parent.show(contentController: content, at: window.frame.origin, appearance: nil, parentWindow: window, onDismiss: {}) + let parentPanel = try #require(window.childWindows?.first(where: { $0.isVisible })) + #expect(parentPanel.isKeyWindow) + child.show(items: [.action("Copy") { childCalls += 1 }], at: parentPanel.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: parentPanel) + #expect(parentPanel.isKeyWindow) + try sendKey(125, to: parentPanel) + try sendKey(36, to: parentPanel) + #expect(childCalls == 1 && parentKeys == 0) + #expect(!hasPopup(parentPanel) && parentPanel.isVisible) + } + + @Test func losingOwnerFocusAndApplicationActivationDismissesTheMenu() { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + func show() { + presenter.show(items: [.action("Copy") {}], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + } + show() + #expect(hasPopup(window)) + window.resignKey() + #expect(!hasPopup(window)) + window.makeKey() + show() + #expect(hasPopup(window)) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: NSApp) + #expect(!hasPopup(window)) + } + + private func makeWindow() -> NSWindow { + let window = ActionMenuFocusWindow(contentRect: NSRect(x: 100, y: 100, width: 400, height: 240), + styleMask: [.titled, .nonactivatingPanel], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + return window + } + private func hasPopup(_ window: NSWindow) -> Bool { + window.childWindows?.contains(where: { $0.isVisible }) == true + } + private func sendKey(_ code: UInt16, to window: NSWindow) throws { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code))) + } +} + +// A nonactivating native host can become key in the isolated test process; +// it exercises real AppKit focus without activating a desktop application. +@MainActor +private final class ActionMenuFocusWindow: NSPanel { + override var canBecomeKey: Bool { true } +} diff --git a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift index e6eeda279c..71d3435245 100644 --- a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift +++ b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift @@ -43,6 +43,12 @@ struct SettingsSearchVocabularyTests { #expect(!SettingsSearchVocabulary.matches(query: "ACP adapter", category: .providers)) } + @Test func skillsPageMatchesSkillScopeAndImportQueries() { + for query in ["Skills", "SKILL.md", "Global skills", "Project skills", "Import skill"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .skills)) + } + } + @Test func editorPageMatchesTheNewFontQueries() { #expect(SettingsSearchVocabulary.matches(query: "font", category: .editor)) #expect(SettingsSearchVocabulary.matches(query: "fonts", category: .editor)) @@ -103,6 +109,8 @@ struct SettingsSearchVocabularyTests { let translations = try simplifiedChineseTranslations() let localize: (String) -> String = { translations[$0] ?? $0 } + #expect(SettingsSearchVocabulary.matches(query: "技能", category: .skills, localize: localize)) + #expect(SettingsSearchVocabulary.matches(query: "全局技能", category: .skills, localize: localize)) #expect(translations["Editor tabs"] == "编辑器标签栏") #expect(translations["Font"] == "字体") #expect(translations["Search fonts"] == "搜索字体") diff --git a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift index 88b3268839..97bdf9bd98 100644 --- a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift +++ b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift @@ -37,6 +37,6 @@ struct SettingsSidebarLayoutTests { #expect(SettingsGroup.build.categories == [.project, .run]) #expect(SettingsGroup.languages.categories == [.lsp]) #expect(SettingsGroup.tools.categories == [.terminal, .diagnostics]) - #expect(SettingsGroup.ai.categories == [.mcp, .ai, .agents, .providers]) + #expect(SettingsGroup.ai.categories == [.mcp, .ai, .agents, .skills, .providers]) } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 07cf58ec9e..26df940af8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1998,6 +1998,7 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", "http-body-util", "hyper", @@ -2008,6 +2009,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml_ng", "tokio", "tokio-util", "tungstenite", diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index e8dcf5b1c8..7873c543f0 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared ACP session and agent process host for Lithe" agent-client-protocol = { version = "=2.2.0", features = ["unstable_end_turn_token_usage", "unstable_session_notices"] } bytes = "1" futures = "0.3" +fs2 = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } @@ -17,6 +18,7 @@ kill_tree = { version = "0.2", features = ["tokio"] } serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde_json = "1" +serde_yaml_ng = "0.10.0" semver = "1" tokio = { version = "1", features = ["io-util", "net", "process", "rt", "sync", "time"] } tokio-util = { version = "0.7", features = ["compat"] } diff --git a/rust/lithe-agent-host/src/codex_history.rs b/rust/lithe-agent-host/src/codex_history.rs index 76dcde6d9a..9099ebb135 100644 --- a/rust/lithe-agent-host/src/codex_history.rs +++ b/rust/lithe-agent-host/src/codex_history.rs @@ -11,6 +11,7 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt, BufReader}; const PROBE_TIMEOUT: Duration = Duration::from_secs(30); +const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(50); const PAGE_SIZE: u32 = 50; /// Source kinds codex-acp lists, so archived and active history share a scope. @@ -37,6 +38,80 @@ pub(crate) struct CodexHistory { } impl CodexHistory { + /// Validate the selected path against native discovery before changing its + /// configuration, then return freshly reported state from the same server. + pub(crate) async fn set_skill_enabled( + executable: PathBuf, + cwd: PathBuf, + path: PathBuf, + enabled: bool, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + let params = json!({"cwds": [cwd], "forceReload": true}); + let before = subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + params.clone(), + ) + .await?; + let path = crate::skills::codex_skill_path(&before, &cwd, &path) + .ok_or("unknown skill")?; + if cancelled() { + return Err("cancelled"); + } + subscription::request( + &mut input, + &mut output, + 3, + "skills/config/write", + json!({"path":path,"enabled":enabled}), + ) + .await?; + subscription::request(&mut input, &mut output, 4, "skills/list", params).await + }, + cancelled, + ) + .await + } + + /// Reuse the bounded, credential-free App Server lifecycle for native skill discovery. + pub(crate) async fn skills( + executable: PathBuf, + cwd: PathBuf, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + json!({"cwds": [cwd], "forceReload": true}), + ) + .await + }, + cancelled, + ) + .await + } + /// Only Codex connections have archived history; other Agents return None. pub(crate) fn for_launch(launch: &ResolvedLaunch) -> Option { let (executable, model_provider) = match &launch.subscription_cli { @@ -105,6 +180,21 @@ impl CodexHistory { where F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, Fut: std::future::Future>, + { + self.exchange_cancellable(operation, || false).await + } + + /// Cancellation participates in the exchange so cleanup is awaited before + /// a short-lived caller drops its Tokio runtime, including during initialization. + async fn exchange_cancellable( + &self, + operation: F, + cancelled: C, + ) -> Result + where + F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, + Fut: std::future::Future>, + C: Fn() -> bool, { let mut process = std::process::Command::new(&self.executable); // History needs no credentials; never hand API overrides to this process. @@ -130,15 +220,25 @@ impl CodexHistory { } process.kill_on_drop(true); let mut child = subscription::ProbeProcess(process.spawn().map_err(|_| "unavailable")?); - let result = async { - let mut input = child.0.stdin.take().ok_or("unavailable")?; - let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); - initialize(&mut input, &mut output).await?; - operation(input, output).await + let result = { + let exchange = async { + let mut input = child.0.stdin.take().ok_or("unavailable")?; + let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); + initialize(&mut input, &mut output).await?; + operation(input, output).await + }; + let bounded = tokio::time::timeout(PROBE_TIMEOUT, exchange); + tokio::pin!(bounded); + loop { + if cancelled() { + break Err("cancelled"); + } + tokio::select! { + result = &mut bounded => break result.unwrap_or(Err("timeout")), + _ = tokio::time::sleep(CANCEL_POLL_INTERVAL) => {} + } + } }; - let result = tokio::time::timeout(PROBE_TIMEOUT, result) - .await - .unwrap_or(Err("timeout")); terminate_tree(&mut child.0).await; result } diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index a7cd0cf48c..b26c326bb1 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -20,6 +20,7 @@ mod prompt_retry; mod session_defaults; mod session_restore; mod session_routing; +pub mod skills; mod steering; mod subagents; mod subscription; diff --git a/rust/lithe-agent-host/src/skills.rs b/rust/lithe-agent-host/src/skills.rs new file mode 100644 index 0000000000..fbb87da580 --- /dev/null +++ b/rust/lithe-agent-host/src/skills.rs @@ -0,0 +1,281 @@ +//! Native Agent skill discovery and explicit user-owned skill installation. +//! +//! Codex owns discovery through skills/list. Claude's directory inventory is +//! deliberately not advertised as a complete or enabled session capability list. +//! Explicit toggles use native settings; discovery never changes configuration. + +mod files; +mod settings; +#[cfg(test)] +mod tests; + +use crate::{codex_history::CodexHistory, environment}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Explicit settings action. Reading a list never installs or enables a skill. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub action: Action, + pub agent_id: String, + /// Current opened project, or None for user-only settings. + pub workspace: Option, + pub scope: Option, + pub name: Option, + pub description: Option, + pub instructions: Option, + /// User-selected SKILL.md or a directory containing it; never an archive or URL. + pub source: Option, + /// Desired native visibility; required only for setEnabled. + pub enabled: Option, +} + +/// Mutations require an explicit settings action, never a discovery request. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Action { + List, + Directories, + Read, + Create, + Import, + SetEnabled, +} + +/// A native path is transient platform metadata, not a persistent shared identifier. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Skill { + name: String, + description: String, + path: PathBuf, + scope: String, + /// Native Codex state, or Claude's configured visibility in this scope. + /// None means configuration could not be read, never an inferred default. + enabled: Option, + plugin_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Listing { + skills: Vec, + /// "agent" means native discovery; "directory" means an explicitly partial inventory. + source: &'static str, + issues: Vec, + directories: std::collections::BTreeMap, +} + +/// Failures retain a stable category without returning raw subprocess output. +#[derive(Debug)] +pub enum Error { + Invalid(String), + Failed(String), + MissingCLI, + Cancelled, +} + +impl Error { + pub fn message(&self) -> &str { + match self { + Self::Invalid(message) | Self::Failed(message) => message, + Self::MissingCLI => "Install Codex CLI to discover its skills.", + Self::Cancelled => "Skill operation was cancelled.", + } + } +} + +/// Run on a platform worker. The caller owns cancellation and the current workspace. +pub fn execute(request: Request, cancelled: &dyn Fn() -> bool) -> Result { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Failed("The user home directory is unavailable.".into()))?; + let claude = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")); + execute_at(request, &home, &claude, cancelled) +} + +fn execute_at( + request: Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + check(cancelled)?; + if !matches!(request.agent_id.as_str(), "codex-acp" | "claude-acp") { + return Err(Error::Invalid( + "Skill management is available for Codex and Claude.".into(), + )); + } + if request + .workspace + .as_ref() + .is_some_and(|p| !p.is_absolute() || !p.is_dir()) + || !claude.is_absolute() + { + return Err(Error::Invalid( + "The skill workspace and configuration directory must be absolute directories.".into(), + )); + } + if request.action == Action::Directories { + return Ok(json!({"directories": files::directories(&request, home, claude)})); + } + if request.action == Action::Read { + let path = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + return files::read_document(path).map(|content| json!({"content": content})); + } + if request.action == Action::SetEnabled { + return settings::set_enabled(&request, home, claude, cancelled); + } + if request.action != Action::List { + return files::install(&request, home, claude, cancelled).map(|path| json!({"path": path})); + } + let mut listing = if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Failed("Skill discovery could not start.".into()))?; + let result = runtime.block_on(CodexHistory::skills(cli.path, cwd.to_path_buf(), cancelled)); + check(cancelled)?; + let value = result.map_err(|_| { + Error::Failed("Codex skill discovery failed. Check the CLI and retry.".into()) + })?; + parse_codex(&value, cwd)? + } else { + files::claude_inventory(request.workspace.as_deref(), claude, cancelled)? + }; + if request.agent_id == "claude-acp" { + settings::annotate(&mut listing, request.workspace.as_deref(), claude); + } + check(cancelled)?; + listing_value(listing, &request, home, claude) +} + +fn listing_value( + mut listing: Listing, + request: &Request, + home: &Path, + claude: &Path, +) -> Result { + listing.directories = files::directories(request, home, claude); + listing.skills.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + serde_json::to_value(listing) + .map_err(|_| Error::Failed("Skill metadata could not be returned.".into())) +} + +fn check(cancelled: &dyn Fn() -> bool) -> Result<(), Error> { + if cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } +} + +fn parse_codex(value: &Value, cwd: &Path) -> Result { + let entries = value["data"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + let entry = entries + .iter() + .find(|entry| { + entry["cwd"].as_str().is_some_and(|path| { + Path::new(path) == cwd + || std::fs::canonicalize(path) + .ok() + .zip(std::fs::canonicalize(cwd).ok()) + .is_some_and(|(a, b)| a == b) + }) + }) + .ok_or_else(|| { + Error::Failed("Codex did not report skills for the selected project.".into()) + })?; + let rows = entry["skills"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + if rows.len() > files::MAX_ENTRIES { + return Err(Error::Failed("Too many skills to display.".into())); + } + let mut listing = Listing { + skills: vec![], + source: "agent", + issues: vec![], + directories: Default::default(), + }; + for row in rows { + let path = row["path"].as_str().map(PathBuf::from); + match (row["name"].as_str(), row["description"].as_str(), path) { + (Some(name), Some(description), Some(path)) + if path.is_absolute() && !name.is_empty() => + { + if !matches!( + row["scope"].as_str(), + Some("user" | "repo" | "system" | "admin") + ) { + listing + .issues + .push("A skill has an unknown scope and cannot be classified.".into()); + continue; + } + if listing.skills.iter().any(|skill| skill.path == path) { + continue; + } + listing.skills.push(Skill { + name: name.into(), + description: description.into(), + path, + scope: row["scope"].as_str().unwrap_or("unknown").into(), + enabled: row["enabled"].as_bool(), + plugin_id: row["pluginId"].as_str().map(str::to_owned), + }); + } + _ => listing + .issues + .push("A skill was omitted because its native metadata is incomplete.".into()), + } + } + if entry["errors"] + .as_array() + .is_some_and(|errors| !errors.is_empty()) + { + listing.issues.push( + "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." + .into(), + ); + } + Ok(listing) +} + +/// Restrict config writes to a path that this workspace's native discovery reports. +pub(crate) fn codex_skill_path(value: &Value, cwd: &Path, path: &Path) -> Option { + parse_codex(value, cwd).ok().and_then(|listing| { + listing + .skills + .into_iter() + .find(|skill| same_path(&skill.path, path) && skill.enabled.is_some()) + .map(|skill| skill.path) + }) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left == right + || std::fs::canonicalize(left) + .ok() + .zip(std::fs::canonicalize(right).ok()) + .is_some_and(|(left, right)| left == right) +} diff --git a/rust/lithe-agent-host/src/skills/files.rs b/rust/lithe-agent-host/src/skills/files.rs new file mode 100644 index 0000000000..b4f56065c3 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/files.rs @@ -0,0 +1,506 @@ +//! Bounded directory inventory and non-overwriting publication in native user/project roots. + +use super::*; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; + +pub(super) const MAX_ENTRIES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 128 * 1024; +const MAX_IMPORT_BYTES: u64 = 32 * 1024 * 1024; +const MAX_DEPTH: usize = 20; + +fn failure(message: &str) -> Error { + Error::Failed(message.into()) +} + +fn read(path: &Path) -> Result, Error> { + read_limited(path, MAX_FILE_BYTES) +} + +fn read_limited(path: &Path, limit: u64) -> Result, Error> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options + .open(path) + .map_err(|_| failure("The skill file could not be opened."))?; + if !file + .metadata() + .map_err(|_| failure("The skill file could not be inspected."))? + .is_file() + { + return Err(failure("Skills must contain regular files.")); + } + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure("The skill file could not be read."))?; + if bytes.len() as u64 > limit { + return Err(failure(if limit == MAX_DOCUMENT_BYTES { + "SKILL.md exceeds the 128 KiB limit." + } else { + "A skill file exceeds the 8 MiB limit." + })); + } + Ok(bytes) +} + +pub(super) fn metadata(bytes: &[u8], fallback: &str) -> Result<(String, String), Error> { + let text = std::str::from_utf8(bytes).map_err(|_| failure("SKILL.md must use UTF-8 text."))?; + let mut lines = text.trim_start_matches('\u{feff}').lines(); + if lines.next().map(str::trim) != Some("---") { + return Err(failure("SKILL.md needs YAML frontmatter.")); + } + let mut yaml = String::new(); + let mut closed = false; + for line in lines.by_ref() { + if line.trim() == "---" { + closed = true; + break; + } + yaml.push_str(line); + yaml.push('\n'); + } + if !closed { + return Err(failure("SKILL.md frontmatter is not closed.")); + } + if !lines.any(|line| !line.trim().is_empty()) { + return Err(failure( + "SKILL.md needs instructions after its frontmatter.", + )); + } + let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml) + .map_err(|_| failure("SKILL.md has invalid YAML frontmatter."))?; + let name = value["name"].as_str().unwrap_or(fallback).trim(); + let description = value["description"].as_str().unwrap_or("").trim(); + if name.is_empty() || description.is_empty() { + return Err(failure("SKILL.md needs a name and description.")); + } + Ok((name.into(), description.into())) +} + +/// Inventory only these two explicit locations. Do not emulate Claude's policy, +/// plugin resolution, ancestor discovery or lazy nested skill loading. +pub(super) fn claude_inventory( + workspace: Option<&Path>, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let mut result = Listing { + source: "directory", + skills: vec![], + issues: vec![], + directories: Default::default(), + }; + let mut roots = vec![(claude.join("skills"), "user")]; + if let Some(workspace) = workspace { + roots.push((workspace.join(".claude/skills"), "repo")); + } + let mut inspected = 0usize; + 'roots: for (root, scope) in roots { + check(cancelled)?; + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => { + result + .issues + .push(format!("Cannot read {}", root.display())); + continue; + } + }; + for entry in entries { + check(cancelled)?; + inspected += 1; + if inspected > MAX_ENTRIES { + result + .issues + .push("Only the first 2000 directory entries were inspected.".into()); + break 'roots; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + result + .issues + .push("A skill directory could not be read.".into()); + continue; + } + }; + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + let path = entry.path().join("SKILL.md"); + if !path.exists() { + continue; + } + // A native skill directory may be a link. Resolve only for reading; + // importing links or writing through linked destinations is forbidden. + let parsed = fs::canonicalize(&path) + .map_err(|_| failure("A skill path could not be resolved.")) + .and_then(|canonical| read_limited(&canonical, MAX_DOCUMENT_BYTES)) + .and_then(|bytes| metadata(&bytes, &entry.file_name().to_string_lossy())); + match parsed { + Ok((name, description)) => result.skills.push(Skill { + name, + description, + path, + scope: scope.into(), + enabled: None, + plugin_id: None, + }), + Err(error) => { + result + .issues + .push(format!("{}: {}", path.display(), error.message())) + } + } + } + } + Ok(result) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// Create native containers without following a linked intermediate directory. +fn destination(request: &Request, home: &Path, claude: &Path) -> Result { + let project = request.scope.as_deref() == Some("repo"); + if !matches!(request.scope.as_deref(), Some("user" | "repo")) { + return Err(Error::Invalid( + "Choose Global or Project for the skill.".into(), + )); + } + let (base, components) = if project { + ( + request.workspace.as_deref().ok_or_else(|| { + Error::Invalid("Open a project before creating project skills.".into()) + })?, + vec![ + if request.agent_id == "codex-acp" { + ".agents" + } else { + ".claude" + }, + "skills", + ], + ) + } else if request.agent_id == "codex-acp" { + (home, vec![".agents", "skills"]) + } else { + // CLAUDE_CONFIG_DIR is the user's native configuration override. + fs::create_dir_all(claude) + .map_err(|_| failure("The Claude configuration directory could not be created."))?; + (claude, vec!["skills"]) + }; + let mut path = + fs::canonicalize(base).map_err(|_| failure("The skill destination is unavailable."))?; + for component in components { + path.push(component); + match fs::create_dir(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(failure("The skill destination could not be created.")), + } + let metadata = fs::symlink_metadata(&path) + .map_err(|_| failure("The skill destination could not be inspected."))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(failure("The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.")); + } + } + Ok(path) +} + +struct Staging(PathBuf); +impl Drop for Staging { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn install( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let (name, content, source) = match request.action { + Action::Create => { + let name = request.name.as_deref().unwrap_or(""); + let description = request.description.as_deref().unwrap_or("").trim(); + let instructions = request.instructions.as_deref().unwrap_or("").trim(); + if description.is_empty() || instructions.is_empty() { + return Err(Error::Invalid( + "Enter a description and instructions.".into(), + )); + } + // JSON strings are valid YAML scalars, preserving quotes and newlines. + let text = format!( + "---\nname: {}\ndescription: {}\n---\n\n{}\n", + json!(name), + json!(description), + instructions + ); + if text.len() as u64 > MAX_DOCUMENT_BYTES { + return Err(Error::Invalid("The skill is too large.".into())); + } + (name.to_owned(), Some(text.into_bytes()), None) + } + Action::Import => { + let source = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| { + Error::Invalid("Choose a SKILL.md file or skill directory.".into()) + })?; + let info = fs::symlink_metadata(source) + .map_err(|_| failure("The import source is unavailable."))?; + if info.file_type().is_symlink() { + return Err(failure( + "Import a regular file or directory instead of a symbolic link.", + )); + } + let file = if info.is_dir() { + source.join("SKILL.md") + } else { + source.to_path_buf() + }; + if file.file_name().and_then(|v| v.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let bytes = read_limited(&file, MAX_DOCUMENT_BYTES)?; + let fallback = if info.is_dir() { + source.file_name().and_then(|v| v.to_str()).unwrap_or("") + } else { + "" + }; + let (name, _) = metadata(&bytes, fallback)?; + if request.agent_id == "codex-acp" { + // Codex requires an explicit frontmatter name, unlike Claude's directory fallback. + let (explicit, _) = metadata(&bytes, "")?; + if explicit.is_empty() { + return Err(failure("Codex requires a skill name.")); + } + } + ( + name, + Some(bytes), + info.is_dir().then(|| source.to_path_buf()), + ) + } + Action::List | Action::Read | Action::Directories | Action::SetEnabled => unreachable!(), + }; + if !valid_name(&name) { + return Err(Error::Invalid( + "Use up to 64 lowercase letters, digits and single hyphens for the skill name.".into(), + )); + } + if request.agent_id == "claude-acp" && matches!(name.as_str(), "synced" | "anthropic-skills") { + return Err(Error::Invalid( + "That skill name is reserved by Claude. Choose a different name.".into(), + )); + } + check(cancelled)?; + let root = destination(request, home, claude)?; + if source + .as_ref() + .and_then(|source| fs::canonicalize(source).ok()) + .is_some_and(|source| root.starts_with(source)) + { + return Err(Error::Invalid( + "The import source cannot contain the destination directory.".into(), + )); + } + let target = root.join(&name); + if target.symlink_metadata().is_ok() { + return Err(failure("A skill with this directory name already exists. Existing skills are never overwritten.")); + } + let staging = Staging(root.join(format!(".lithe-skill-{}", uuid::Uuid::new_v4()))); + fs::create_dir(&staging.0) + .map_err(|_| failure("The skill staging directory could not be created."))?; + if let Some(source) = source { + let mut budget = Budget { + entries: 0, + bytes: 0, + }; + copy_directory(&source, &staging.0, 0, &mut budget, cancelled)?; + // Validate the actual staged bytes, including concurrent source changes. + let (staged_name, _) = metadata( + &read_limited(&staging.0.join("SKILL.md"), MAX_DOCUMENT_BYTES)?, + &name, + )?; + if staged_name != name { + return Err(failure( + "SKILL.md changed while it was being imported. Retry the import.", + )); + } + } else if let Some(content) = content { + let mut file = File::create(staging.0.join("SKILL.md")) + .map_err(|_| failure("SKILL.md could not be created."))?; + file.write_all(&content) + .map_err(|_| failure("SKILL.md could not be saved."))?; + } + check(cancelled)?; + publish(&staging.0, &target)?; + Ok(target.join("SKILL.md")) +} + +struct Budget { + entries: usize, + bytes: u64, +} +fn copy_directory( + source: &Path, + destination: &Path, + depth: usize, + budget: &mut Budget, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(failure("The skill directory is too deeply nested.")); + } + let entries = + fs::read_dir(source).map_err(|_| failure("The skill directory could not be read."))?; + for entry in entries { + check(cancelled)?; + let entry = entry.map_err(|_| failure("A skill entry could not be read."))?; + budget.entries += 1; + if budget.entries > MAX_ENTRIES { + return Err(failure("The skill contains too many files.")); + } + let kind = entry + .file_type() + .map_err(|_| failure("A skill entry could not be inspected."))?; + let path = destination.join(entry.file_name()); + if kind.is_dir() { + fs::create_dir(&path).map_err(|_| failure("A skill directory could not be copied."))?; + copy_directory(&entry.path(), &path, depth + 1, budget, cancelled)?; + } else if kind.is_file() { + let bytes = read(&entry.path())?; + budget.bytes += bytes.len() as u64; + if budget.bytes > MAX_IMPORT_BYTES { + return Err(failure("The skill exceeds the 32 MiB import limit.")); + } + fs::write(&path, bytes).map_err(|_| failure("A skill file could not be copied."))?; + let permissions = entry + .metadata() + .map_err(|_| failure("Skill permissions could not be read."))? + .permissions(); + fs::set_permissions(&path, permissions) + .map_err(|_| failure("Skill permissions could not be preserved."))?; + } else { + return Err(failure( + "Skill imports cannot contain symbolic links or special files.", + )); + } + } + Ok(()) +} + +/// Atomic no-replace publication closes the existence-check race with another window. +pub(super) fn publish(source: &Path, target: &Path) -> Result<(), Error> { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let from = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + let to = CString::new(target.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + #[cfg(target_os = "macos")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result != 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + // Windows MoveFile semantics reject an existing destination directory. + fs::rename(source, target).map_err(|_| { + failure("The skill could not be published. Check permissions and existing skill names.") + }) + } +} + +/// Native destinations are returned as display metadata; discovery creates no directories. +pub(super) fn directories( + request: &Request, + home: &Path, + claude: &Path, +) -> std::collections::BTreeMap { + let mut directories = std::collections::BTreeMap::new(); + directories.insert( + "user".into(), + if request.agent_id == "codex-acp" { + home.join(".agents/skills") + } else { + claude.join("skills") + }, + ); + if let Some(workspace) = &request.workspace { + directories.insert( + "repo".into(), + workspace.join(if request.agent_id == "codex-acp" { + ".agents/skills" + } else { + ".claude/skills" + }), + ); + } + directories +} + +pub(super) fn read_document(path: &Path) -> Result { + if path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let canonical = fs::canonicalize(path) + .map_err(|_| failure("The skill document is unavailable. Refresh and retry."))?; + String::from_utf8(read_limited(&canonical, MAX_DOCUMENT_BYTES)?) + .map_err(|_| failure("SKILL.md must use UTF-8 text.")) +} diff --git a/rust/lithe-agent-host/src/skills/settings.rs b/rust/lithe-agent-host/src/skills/settings.rs new file mode 100644 index 0000000000..9273abd610 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/settings.rs @@ -0,0 +1,298 @@ +//! Native visibility settings. Never move skill packages or keep a Lithe enabled flag. + +use super::*; +use fs2::FileExt; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; + +const MAX_SETTINGS_BYTES: u64 = 1024 * 1024; + +fn failed() -> Error { + Error::Failed( + "Skill settings could not be updated. Check the settings file and refresh.".into(), + ) +} + +/// These are configured states, not a reconstruction of Claude's effective +/// managed/ancestor/session policy. Project-local overrides take precedence. +pub(super) fn annotate(listing: &mut Listing, workspace: Option<&Path>, claude: &Path) { + let user = load(&claude.join("settings.json")); + let project = workspace + .map(|root| { + Ok::<_, Error>(( + load(&root.join(".claude/settings.json"))?, + load(&root.join(".claude/settings.local.json"))?, + )) + }) + .transpose(); + for skill in &mut listing.skills { + skill.enabled = user.as_ref().ok().and_then(|user| { + let mut state = visibility(user, &skill.name)?; + if skill.scope == "repo" { + if let Some((shared, local)) = project.as_ref().ok()?.as_ref() { + state = override_visibility(shared, &skill.name)?.unwrap_or(state); + state = override_visibility(local, &skill.name)?.unwrap_or(state); + } + } + Some(state) + }); + } + if user.is_err() || project.is_err() { + listing.issues.push("Claude skill settings could not be read. Fix invalid settings and refresh before toggling.".into()); + } +} + +fn override_visibility(value: &Value, name: &str) -> Option> { + match value + .get("skillOverrides") + .and_then(|values| values.get(name)) + .and_then(Value::as_str) + { + None => Some(None), + Some("off") => Some(Some(false)), + Some("on" | "name-only" | "user-invocable-only") => Some(Some(true)), + Some(_) => None, + } +} + +fn visibility(value: &Value, name: &str) -> Option { + Some(override_visibility(value, name)?.unwrap_or(true)) +} + +pub(super) fn set_enabled( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let enabled = request + .enabled + .ok_or_else(|| Error::Invalid("Choose whether to enable the skill.".into()))?; + let path = request + .source + .as_deref() + .filter(|path| path.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| failed())?; + let value = runtime.block_on(CodexHistory::set_skill_enabled( + cli.path, + cwd.to_owned(), + path.to_owned(), + enabled, + cancelled, + )); + check(cancelled)?; + let listing = parse_codex(&value.map_err(|_| Error::Failed("Codex skill settings could not be confirmed. Refresh to check the current state.".into()))?, cwd)?; + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + return listing_value(listing, request, home, claude); + } + let mut listing = files::claude_inventory(request.workspace.as_deref(), claude, cancelled)?; + let discovery_issue_count = listing.issues.len(); + annotate(&mut listing, request.workspace.as_deref(), claude); + let skill = listing + .skills + .iter() + .find(|skill| { + same_path(&skill.path, path) && Some(skill.scope.as_str()) == request.scope.as_deref() + }) + .ok_or_else(|| { + Error::Invalid( + "The skill is no longer available in this scope. Refresh and retry.".into(), + ) + })?; + if skill.enabled.is_none() { + return Err(failed()); + } + // SkillOverrides keys are names. Duplicate names in the same scope share one setting. + if skill.name.contains(':') { + return Err(Error::Invalid( + "Claude plugin skills must be managed through Claude's plugin settings.".into(), + )); + } + let target = if skill.scope == "repo" { + request + .workspace + .as_ref() + .ok_or_else(failed)? + .join(".claude/settings.local.json") + } else { + claude.join("settings.json") + }; + write_override(&target, &skill.name, enabled, cancelled)?; + listing.issues.truncate(discovery_issue_count); + annotate(&mut listing, request.workspace.as_deref(), claude); + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + listing_value(listing, request, home, claude) +} + +fn options() -> OpenOptions { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .mode(0o600); + } + options +} + +fn snapshot(path: &Path) -> Result>, Error> { + let file = match options().read(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(failed()), + }; + if !file.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + let mut bytes = vec![]; + file.take(MAX_SETTINGS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| failed())?; + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + Ok(Some(bytes)) +} + +fn parse(bytes: Option<&[u8]>) -> Result { + let value: Value = match bytes { + Some(bytes) => serde_json::from_slice(bytes).map_err(|_| failed())?, + None => json!({}), + }; + if !value.is_object() { + return Err(failed()); + } + if let Some(overrides) = value.get("skillOverrides") { + if !overrides.is_object() + || !overrides.as_object().unwrap().values().all(|value| { + matches!( + value.as_str(), + Some("on" | "off" | "name-only" | "user-invocable-only") + ) + }) + { + return Err(failed()); + } + } + Ok(value) +} + +fn load(path: &Path) -> Result { + parse(snapshot(path)?.as_deref()) +} + +struct Temporary(PathBuf); +impl Drop for Temporary { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill settings staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn write_override( + path: &Path, + name: &str, + enabled: bool, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + let parent = path.parent().ok_or_else(failed)?; + // Reject linked parents before writing, including a linked .claude directory. + let mut ancestors: Vec<_> = parent.ancestors().collect(); + ancestors.reverse(); + for ancestor in ancestors { + if !ancestor.exists() { + fs::create_dir(ancestor).map_err(|_| failed())?; + } + let metadata = fs::symlink_metadata(ancestor).map_err(|_| failed())?; + // macOS /var is a system link; resolve existing ancestors, but the actual + // settings directory itself must remain a regular native directory. + if !ancestor.is_dir() || (ancestor == parent && metadata.file_type().is_symlink()) { + return Err(failed()); + } + } + let lock_path = parent.join(".lithe-skill-settings.lock"); + let lock = options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|_| failed())?; + if !lock.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(&lock_path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + // Nonblocking lock serializes Lithe windows; native/editor changes are + // checked against the exact input snapshot before atomic publication. + lock.try_lock_exclusive().map_err(|_| { + Error::Failed("Skill settings are busy. Retry after the other change finishes.".into()) + })?; + let before = snapshot(path)?; + let mut value = parse(before.as_deref())?; + value + .as_object_mut() + .ok_or_else(failed)? + .entry("skillOverrides") + .or_insert_with(|| json!({}))[name] = json!(if enabled { "on" } else { "off" }); + let mut bytes = serde_json::to_vec_pretty(&value).map_err(|_| failed())?; + bytes.push(b'\n'); + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + check(cancelled)?; + let staging = Temporary(parent.join(format!(".lithe-skill-settings-{}", uuid::Uuid::new_v4()))); + let mut file = options() + .write(true) + .create_new(true) + .open(&staging.0) + .map_err(|_| failed())?; + if let Ok(metadata) = fs::metadata(path) { + file.set_permissions(metadata.permissions()) + .map_err(|_| failed())?; + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| failed())?; + drop(file); + check(cancelled)?; + if snapshot(path)? != before { + return Err(Error::Failed( + "Skill settings changed elsewhere. Refresh and retry.".into(), + )); + } + fs::rename(&staging.0, path).map_err(|_| failed())?; + Ok(()) +} diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs new file mode 100644 index 0000000000..1aa76d6158 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -0,0 +1,568 @@ +use super::*; +use std::fs; +use std::time::Duration; + +struct Sandbox(PathBuf); +impl Sandbox { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe-skills-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("home")).unwrap(); + fs::create_dir_all(root.join("project")).unwrap(); + Self(fs::canonicalize(root).unwrap()) + } + fn run(&self, payload: Value) -> Result { + let mut payload = payload; + payload["workspace"] = json!(self.0.join("project")); + execute_at( + serde_json::from_value(payload).unwrap(), + &self.0.join("home"), + &self.0.join("home/.claude"), + &|| false, + ) + } +} +impl Drop for Sandbox { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn create(agent: &str, scope: &str, name: &str) -> Value { + json!({"action":"create", "agentId":agent, "scope":scope, "name":name, + "description":"Use for Unicode 中文, quotes \" and newlines\nonly", "instructions":"# Workflow\n\n1. Inspect\n2. Explain"}) +} + +#[test] +fn claude_toggles_preserve_documents_other_settings_and_scope() { + let fixture = Sandbox::new(); + let user = fixture + .run(create("claude-acp", "user", "same-name")) + .unwrap(); + let repo = fixture + .run(create("claude-acp", "repo", "same-name")) + .unwrap(); + let user_settings = fixture.0.join("home/.claude/settings.json"); + let local_settings = fixture.0.join("project/.claude/settings.local.json"); + fs::write( + &user_settings, + r#"{"env":{"EXAMPLE":"keep"},"skillOverrides":{"unrelated":"name-only"}}"#, + ) + .unwrap(); + let document = fs::read(user["path"].as_str().unwrap()).unwrap(); + let toggle = |scope: &str, source: &Value, enabled: bool| { + fixture.run(json!({"action":"setEnabled", "agentId":"claude-acp", "scope":scope,"source":source,"enabled":enabled})) + }; + let result = toggle("user", &user["path"], false).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == false)); + assert!( + !local_settings.exists(), + "a global toggle must not write project settings" + ); + let result = toggle("repo", &repo["path"], true).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == (skill["scope"] == "repo"))); + assert!(!fixture.0.join("project/.claude/settings.json").exists()); + toggle("user", &user["path"], true).unwrap(); + let config: Value = serde_json::from_slice(&fs::read(user_settings).unwrap()).unwrap(); + assert_eq!(config["env"]["EXAMPLE"], "keep"); + assert_eq!(config["skillOverrides"]["unrelated"], "name-only"); + assert_eq!(config["skillOverrides"]["same-name"], "on"); + assert_eq!(fs::read(user["path"].as_str().unwrap()).unwrap(), document); +} + +#[test] +fn claude_invalid_settings_are_never_replaced_and_hide_unknown_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + let target = fixture.0.join("project/.claude/settings.local.json"); + for invalid in [ + "invalid JSON", + "[]", + r#"{"skillOverrides":[]}"#, + r#"{"skillOverrides":{"other":"unexpected"}}"#, + ] { + fs::write(&target, invalid).unwrap(); + let listing = fixture + .run(json!({"action":"list","agentId":"claude-acp"})) + .unwrap(); + assert_eq!(listing["skills"][0]["enabled"], Value::Null); + assert!(!listing["issues"].as_array().unwrap().is_empty()); + assert!(fixture.run(json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"],"enabled":false})).is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), invalid); + } +} + +#[test] +fn claude_toggles_reject_missing_or_foreign_paths_and_missing_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + for payload in [ + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"]}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"user","source":result["path"],"enabled":false}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":fixture.0.join("foreign/SKILL.md"),"enabled":false}), + ] { + assert!(fixture.run(payload).is_err()); + } + assert!(!fixture + .0 + .join("project/.claude/settings.local.json") + .exists()); +} + +#[test] +fn claude_settings_cancel_and_busy_lock_never_change_existing_bytes() { + use fs2::FileExt; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "{\"other\":true}").unwrap(); + assert!(settings::write_override(&target, "test", false, &|| true).is_err()); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(target.parent().unwrap().join(".lithe-skill-settings.lock")) + .unwrap(); + lock.try_lock_exclusive().unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + drop(lock); + assert_eq!(fs::read_to_string(&target).unwrap(), "{\"other\":true}"); + assert_eq!( + fs::read_dir(target.parent().unwrap()).unwrap().count(), + 2, + "no temporary settings survive failure" + ); +} + +#[cfg(unix)] +#[test] +fn claude_settings_reject_symlinks_and_preserve_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + let outside = fixture.0.join("outside.json"); + fs::write(&outside, "{}").unwrap(); + symlink(&outside, &target).unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + assert_eq!(fs::read_to_string(&outside).unwrap(), "{}"); + fs::remove_file(&target).unwrap(); + fs::write(&target, "{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + settings::write_override(&target, "test", false, &|| false).unwrap(); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} +const DOCUMENT: &str = + "---\nname: sample-skill\ndescription: Test skill\n---\n\nRead the supporting files.\n"; + +#[test] +fn create_is_native_scoped_non_overwriting_and_round_trips_document() { + let fixture = Sandbox::new(); + for agent in ["codex-acp", "claude-acp"] { + for scope in ["user", "repo"] { + let result = fixture.run(create(agent, scope, "new-skill")).unwrap(); + let path = PathBuf::from(result["path"].as_str().unwrap()); + let parent = if scope == "user" { + fixture.0.join("home") + } else { + fixture.0.join("project") + }; + assert!(path.starts_with(parent)); + assert!(path.to_string_lossy().contains(if agent == "codex-acp" { + ".agents" + } else { + ".claude" + })); + let bytes = fs::read(&path).unwrap(); + let parsed = files::read_document(&path).unwrap(); + assert!(parsed.contains("中文")); + assert_eq!(files::metadata(&bytes, "").unwrap().0, "new-skill"); + assert!(fixture.run(create(agent, scope, "new-skill")).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + } +} + +#[test] +fn import_keeps_full_package_and_never_changes_the_source() { + let fixture = Sandbox::new(); + let source = fixture.0.join("external"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/helper.sh"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + source.join("scripts/helper.sh"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let result = fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .unwrap(); + let copied = PathBuf::from(result["path"].as_str().unwrap()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(copied.parent().unwrap().join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode(), + fs::metadata(source.join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode() + ); + } + assert_eq!(fs::read(&copied).unwrap(), DOCUMENT.as_bytes()); + assert_eq!( + fs::read(copied.parent().unwrap().join("scripts/helper.sh")).unwrap(), + fs::read(source.join("scripts/helper.sh")).unwrap() + ); + assert_eq!( + fs::read(source.join("SKILL.md")).unwrap(), + DOCUMENT.as_bytes() + ); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .is_err()); +} + +#[test] +fn single_file_import_and_claude_directory_fallback_are_supported() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + fs::write(&source, DOCUMENT).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":source})) + .is_ok()); + let directory = fixture.0.join("folder-skill"); + fs::create_dir(&directory).unwrap(); + fs::write( + directory.join("SKILL.md"), + "---\ndescription: Folder fallback\n---\n\nInstructions\n", + ) + .unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":directory})) + .is_err()); + let result = fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":directory})) + .unwrap(); + assert!(result["path"].as_str().unwrap().contains("folder-skill")); +} + +#[test] +fn directory_inventory_reports_configured_visibility_and_malformed_issues() { + let fixture = Sandbox::new(); + fixture + .run(create("claude-acp", "user", "user-skill")) + .unwrap(); + fixture + .run(create("claude-acp", "repo", "project-skill")) + .unwrap(); + let bad = fixture.0.join("project/.claude/skills/broken"); + fs::create_dir(&bad).unwrap(); + fs::write(bad.join("SKILL.md"), "---\nname: [invalid\n---\n").unwrap(); + let result = fixture + .run(json!({"action":"list", "agentId":"claude-acp"})) + .unwrap(); + assert_eq!(result["source"], "directory"); + assert_eq!(result["skills"].as_array().unwrap().len(), 2); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|row| row["enabled"] == true)); + assert_eq!(result["issues"].as_array().unwrap().len(), 1); + assert!(result["directories"]["repo"] + .as_str() + .unwrap() + .ends_with(".claude/skills")); +} + +#[test] +fn invalid_names_yaml_and_missing_workspace_do_not_publish_any_skill() { + let fixture = Sandbox::new(); + for name in ["../escape", "UPPER", "", "a--b", "/absolute", "a/b"] { + assert!(fixture.run(create("codex-acp", "repo", name)).is_err()); + } + let payload = create("codex-acp", "repo", "valid-name"); + assert!(execute_at( + serde_json::from_value(payload).unwrap(), + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &|| false + ) + .is_err()); + let source = fixture.0.join("SKILL.md"); + for text in [ + "No frontmatter", + "---\nname: no-close", + "---\n[]\n---\n", + "---\nname: test\n---\nBody", + ] { + fs::write(&source, text).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + } + assert!(!fixture.0.join("project/.agents").exists()); +} + +#[cfg(unix)] +#[test] +fn linked_destinations_and_import_resources_are_rejected_without_partial_publish() { + use std::os::unix::fs::symlink; + let fixture = Sandbox::new(); + let outside = fixture.0.join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, fixture.0.join("project/.agents")).unwrap(); + assert!(fixture + .run(create("codex-acp", "repo", "linked-skill")) + .is_err()); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + symlink(&outside, source.join("assets")).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!( + fs::read_dir(root).unwrap().count(), + 0, + "failed import cleans staging and publishes nothing" + ); +} + +#[test] +fn cancel_during_package_copy_cleans_staging_and_preserves_existing_skill() { + let fixture = Sandbox::new(); + fixture + .run(create("codex-acp", "user", "existing")) + .unwrap(); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("other.txt"), "resource").unwrap(); + let calls = std::cell::Cell::new(0); + let cancel = || { + calls.set(calls.get() + 1); + calls.get() >= 5 + }; + let request: Request = serde_json::from_value( + json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source}), + ) + .unwrap(); + assert!(matches!( + execute_at( + request, + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &cancel + ), + Err(Error::Cancelled) + )); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(root.join("existing/SKILL.md").is_file()); +} + +#[test] +fn publication_cannot_replace_an_existing_empty_directory() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::create_dir(&target).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + assert!(source.join("SKILL.md").is_file()); +} + +#[test] +fn native_response_keeps_disabled_scope_and_rejects_foreign_workspace() { + let fixture = Sandbox::new(); + let cwd = fixture.0.join("project"); + let value = json!({"data":[{"cwd":cwd, "skills":[ + {"name":"native-skill", "description":"Reported", "path":fixture.0.join("SKILL.md"), "scope":"system", "enabled":false}],"errors":[]}]}); + let listing = parse_codex(&value, &cwd).unwrap(); + assert_eq!(listing.skills[0].enabled, Some(false)); + assert_eq!(listing.skills[0].scope, "system"); + assert!(parse_codex(&value, &fixture.0.join("foreign")).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_discovery_only_initializes_and_lists_without_creating_or_prompting() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let response = json!({"data":[{"cwd":fixture.0.join("project"), "skills":[], "errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"method\":\"skills/list\"'*) printf '%s\\n' '{{\"id\":2,\"result\":{response}}}' ;;\n esac\ndone\n", fixture.0.display()); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), &|| false), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, response); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + ["initialize", "initialized", "skills/list"] + ); + assert_eq!(requests[2]["params"]["forceReload"], true); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_toggle_validates_path_writes_native_config_and_reads_back() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let path = fixture.0.join("project/.agents/skills/test/SKILL.md"); + let listing = |enabled| json!({"data":[{"cwd":fixture.0.join("project"),"skills":[{"name":"test","description":"Test","path":path,"scope":"repo","enabled":enabled}],"errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"id\":2,'*) printf '%s\\n' '{{\"id\":2,\"result\":{}}}' ;;\n *'\"id\":3,'*) printf '%s\\n' '{{\"id\":3,\"result\":{{}}}}' ;;\n *'\"id\":4,'*) printf '%s\\n' '{{\"id\":4,\"result\":{}}}' ;;\n esac\ndone\n", fixture.0.display(), listing(true), listing(false)); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable.clone(), + fixture.0.join("project"), + path.clone(), + false, + &|| false, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, listing(false)); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + [ + "initialize", + "initialized", + "skills/list", + "skills/config/write", + "skills/list" + ] + ); + assert_eq!(requests[3]["params"], json!({"path":path,"enabled":false})); + fs::remove_file(fixture.0.join("requests.log")).unwrap(); + let rejected = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable, + fixture.0.join("project"), + fixture.0.join("foreign/SKILL.md"), + false, + &|| false, + ), + ) + .await + .unwrap(); + assert_eq!(rejected, Err("unknown skill")); + assert!(!fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .contains("skills/config/write")); +} + +#[cfg(unix)] +#[test] +fn shared_skill_fixture_preserves_native_scopes_plugins_and_disabled_state() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/skills-management-v1.json" + )) + .unwrap(); + let listing = parse_codex(&fixture["nativeCodex"], Path::new("/fixture/workspace")).unwrap(); + assert_eq!( + serde_json::to_value(listing).unwrap(), + fixture["normalizedCodex"] + ); +} + +#[test] +fn oversized_import_is_bounded_and_never_published() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + let file = fs::File::create(&source).unwrap(); + file.set_len(8 * 1024 * 1024 + 1).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + assert!(!fixture.0.join("home/.agents").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn cancelled_discovery_reaps_the_probe_even_while_initialization_is_stalled() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("stalled-codex"); + let pid_file = fixture.0.join("probe.pid"); + fs::write( + &executable, + format!( + "#!/bin/sh\nprintf '%s' \"$$\" > '{}'\nwhile IFS= read -r line; do :; done\n", + pid_file.display() + ), + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + // The child announces startup before the cancellation check becomes true. + // No real-time sleep coordinates the test; the production poll remains bounded. + let cancelled = || pid_file.exists(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), &cancelled), + ) + .await + .unwrap(); + assert_eq!(result, Err("cancelled")); + let pid: i32 = fs::read_to_string(pid_file).unwrap().parse().unwrap(); + // A terminated but unreaped child would still answer kill(pid, 0). + assert_eq!( + unsafe { libc::kill(pid, 0) }, + -1, + "probe must be reaped before discovery returns" + ); +} diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs index 1ccba7d197..718a961f04 100644 --- a/rust/lithe-core/src/agent/mod.rs +++ b/rust/lithe-core/src/agent/mod.rs @@ -146,3 +146,19 @@ fn emit_progress(progress: install::InstallProgress) { }).to_string()); } } + +/// Execute explicit native skill discovery, installation or visibility configuration. +pub(crate) fn skills(request: lithe_agent_host::skills::Request) -> Result { + use lithe_agent_host::skills::Error; + let result = lithe_agent_host::skills::execute(request, &cancelled); + cancellation::check()?; + result.map_err(|error| { + let code = match &error { + Error::Invalid(_) => ErrorCode::InvalidRequest, + Error::MissingCLI => ErrorCode::RuntimeMissing, + Error::Cancelled => ErrorCode::Cancelled, + Error::Failed(_) => ErrorCode::ProcessFailed, + }; + CoreError::new(code, error.message()) + }) +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index be49d3c3ba..1a26ed7503 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -38,6 +38,8 @@ pub enum CoreCommand { Ping, /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). AgentStatus, + /// Lists native skills or creates/imports one explicitly (`agent.skills`). + AgentSkills, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). AgentInstall, /// Removes a Lithe-managed ACP adapter install (`agent.uninstall`). @@ -411,6 +413,7 @@ impl CoreCommand { "core.ping" => Some(Self::Ping), "ideHost.control" => Some(Self::IdeHostControl), "agent.status" => Some(Self::AgentStatus), + "agent.skills" => Some(Self::AgentSkills), "agent.install" => Some(Self::AgentInstall), "agent.uninstall" => Some(Self::AgentUninstall), "agent.installCli" => Some(Self::AgentInstallCli), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index be4312cba6..01cf5daa60 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -139,6 +139,7 @@ fn execute(request: &str) -> CoreResponse { }), ), CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), + CoreCommand::AgentSkills => agent_response(id, parsed.payload, crate::agent::skills), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { Ok(data) => CoreResponse::success(id, data), diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index ce57984a01..e0304e2cc1 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -754,3 +754,12 @@ JDT-owned workspace build paths and the existing platform-owned JDT state. - 固定 codex-acp 的 `session_info_update._meta.codex.error.willRetry == true` 和非空 `turnId` 是 macOS 重试显示的证据;该通知可能没有 `title`。共享样例 `codexRetry` 由官方 ACP SDK 往返验证,Host 继续透传元数据,不新增请求/事件种类或网络重试策略。 - 状态隔离到会话,新进度清除重试显示,完成/失败/断连清理本轮状态,正在停止时不因迟到进度回退;不得显示未经脱敏的原始错误或从文本猜测重试次数。请求仍等待上游完成/取消确认,既有十分钟 prompt 上限和十秒取消上限不变。 - 这次交付 macOS 展示;Windows 对话 UI 的消费与真实验收仍待完成。展示状态只在内存中,不写入 bundle、安装目录或上游历史。 + + +Agent skill management groups files by Global/Project and retains their Agent +provenance. Codex-reported capabilities and Claude's partial directory inventory +are distinct; neither file presence nor a management-list row proves that the +current conversation has used a skill. Explicit create/import operations write +only to native user/project directories and preserve existing skills. Explicit toggles write native settings: Codex by discovered path via its official App Server API, Claude by name via skillOverrides in user or project-local settings. Claude states are configured visibility, not effective managed/session policy. Never persist a separate Lithe enabled flag, move packages to simulate disablement, or claim that a change revokes already loaded session content. File +contents remain untrusted document data. Settings discovery and file management +must not create conversations, send prompts, or interrupt existing Agent work. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 0524836aaa..e401804d5a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3277,3 +3277,55 @@ Core resolves its current stash position under the existing writer lease and rej an identity that is no longer present. Legacy positional references remain accepted. See `shared/fixtures/git/saved-stash-v1.json`. Native Shelf persistence is platform-owned; raw patch bytes are kept separately for index and working-tree versions. + + +### Agent skill management + +`agent.skills` accepts explicit `action` values `list`, `directories`, `read`, +`create`, `import`, and `setEnabled`, with `agentId` (`codex-acp` or `claude-acp`) and optional +absolute `workspace`. These management operations never create an Agent session, +send a prompt or stop a running conversation. Only an explicit `setEnabled` action changes native visibility settings. +The shared fixture is `shared/fixtures/agent/skills-management-v1.json`. + +`list` returns `skills`, `source`, `issues`, and native `directories` (`user`, +plus `repo` when a workspace is present). Each row has `name`, `description`, +absolute `path`, native `scope` (`user`, `repo`, `system`, `admin`), optional +`enabled` and optional `pluginId`. Paths are transient platform file-operation +metadata, not portable persisted identifiers. Rows are sorted by lowercased name +and path. Native Codex discovery uses the installed CLI's official `skills/list` +with the selected cwd and `forceReload: true`, reusing the bounded App Server +probe and process-tree cleanup. Unknown enablement remains null. Missing or +malformed native results fail explicitly; refresh failure retains previous rows +only within the same workspace and Agent. + +Claude `source: directory` is an explicitly partial inventory of +`CLAUDE_CONFIG_DIR/skills` (default `~/.claude/skills`) and the selected workspace's +`.claude/skills`. It does not emulate inherited-directory, plugin, policy or +session discovery. Its `enabled` reports configured visibility: user rows read user settings; repo rows merge user, project shared and project-local settings. Absent overrides mean on, off means false, name-only/user-invocable-only mean true. Invalid or unreadable configuration leaves the affected state null. This is not effective managed policy or evidence of session availability. Codex `source: agent` is native discovery. Platform UI +uses Global/Project as the main categories and Agent provenance as a filter. + +`setEnabled` requires an absolute discovered `source` path and boolean `enabled`; Claude also requires matching `scope`. It returns a fresh listing using the same shape as `list`. Codex validates the path with `skills/list`, calls official `skills/config/write`, then forces native readback and verifies the desired state. Its setting is global by path even for a project skill. Claude merges only the named `skillOverrides` on/off value into user `settings.json` for global skills, or project `.claude/settings.local.json` for project skills; it preserves unrelated JSON values and permissions, refuses malformed, linked or over-1-MiB files, uses a nonblocking cross-process lock and same-directory atomic replacement with a pre-publish snapshot check. Claude settings are by name, so same-named skills share a setting; project/managed settings can override user settings. Plugin skills are not managed through this operation. Existing session contents cannot be withdrawn: clients advise reconnecting, and never automatically interrupt a running conversation. No skill file is renamed or edited by toggling. + +`directories` returns only the native destination map and needs no installed +CLI. `read` takes the selected absolute `source` path to a `SKILL.md`, reads at +most 128 KiB of regular UTF-8 text and returns `content`. It never interprets or +executes instructions, scripts or dynamic content in the document. + +`create` requires `scope` (`user` or `repo`), portable `name` (1–64 lowercase +letters/digits separated by single hyphens), `description`, and `instructions`. +`import` requires `scope` and an absolute, user-selected `source` (`SKILL.md` or +its package directory). Writes return the resulting `path`. Codex targets +`~/.agents/skills` or `/.agents/skills`; Claude targets its native +configuration directory or `/.claude/skills`. Project writes require +an opened workspace. Imports preserve package contents and file permissions, +reject special files and symbolic links, and enforce 2,000 entries, 20 nested +levels, 128 KiB per SKILL.md, 8 MiB per resource file and 32 MiB total. Hidden staging is removed on failure or +cancellation; publication atomically refuses an existing target, including an +empty directory. Existing skills and import sources are never overwritten. +These are explicit user-owned documents, not downloaded/build/cache resources; +no app bundle, installation resource or shared worktree cache is writable. + +The macOS settings surface offers search, refresh, file details, native open and +reveal, creation and import. It keeps native loading separate from management +and rejects stale results after project changes. Windows can use the same Core +operations; the settings UI and Windows runtime validation remain pending. diff --git a/shared/fixtures/agent/skills-management-v1.json b/shared/fixtures/agent/skills-management-v1.json new file mode 100644 index 0000000000..9e39f281e1 --- /dev/null +++ b/shared/fixtures/agent/skills-management-v1.json @@ -0,0 +1,144 @@ +{ + "requests": { + "list": { + "command": "agent.skills", + "payload": { + "action": "list", + "agentId": "codex-acp", + "workspace": "/fixture/workspace" + } + }, + "directories": { + "command": "agent.skills", + "payload": { + "action": "directories", + "agentId": "claude-acp", + "workspace": "/fixture/workspace" + } + }, + "create": { + "command": "agent.skills", + "payload": { + "action": "create", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "scope": "repo", + "name": "example-skill", + "description": "Use for a specific workflow", + "instructions": "Inspect the project and explain the result." + } + }, + "import": { + "command": "agent.skills", + "payload": { + "action": "import", + "agentId": "claude-acp", + "workspace": "/fixture/workspace", + "scope": "user", + "source": "/fixture/import/example-skill" + } + }, + "read": { + "command": "agent.skills", + "payload": { + "action": "read", + "agentId": "codex-acp", + "source": "/fixture/home/.agents/skills/shared-checks/SKILL.md" + } + }, + "setEnabled": { + "command": "agent.skills", + "payload": { + "action": "setEnabled", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "source": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "enabled": false, + "scope": "repo" + } + } + }, + "nativeCodex": { + "data": [ + { + "cwd": "/fixture/workspace", + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "errors": [] + } + ] + }, + "normalizedCodex": { + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "source": "agent", + "issues": [], + "directories": {} + }, + "claudeInventory": { + "skills": [ + { + "name": "local-review", + "description": "Review this project's changes", + "path": "/fixture/workspace/.claude/skills/local-review/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + } + ], + "source": "directory", + "issues": [], + "directories": { + "user": "/fixture/home/.claude/skills", + "repo": "/fixture/workspace/.claude/skills" + } + } +} diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json new file mode 100644 index 0000000000..3ff0f4fb99 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -0,0 +1,33 @@ +{ + "id": "agent-skill-management", + "area": "AI", + "group": "Agent 对话", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关;操作菜单保留所属窗口焦点和开启开关的颜色", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift", + "macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift", + "rust/lithe-agent-host/src/skills.rs", + "rust/lithe-agent-host/src/skills/files.rs", + "macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift", + "rust/lithe-agent-host/src/skills/tests.rs", + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/settings.rs", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/agent/mod.rs", + "rust/lithe-agent-host/src/skills.rs", + "shared/fixtures/agent/skills-management-v1.json" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停写入失败保留原值、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" +} diff --git a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json index 0267caa645..73233b9229 100644 --- a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json +++ b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json @@ -2,7 +2,7 @@ "id": "macos-shared-product-dropdowns", "area": "界面", "group": "全局共享控件", - "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求", + "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求;操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -34,7 +34,8 @@ "macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorInteractionTests.swift", - "macos/Tests/LitheTests/AgentActivityPresentationTests.swift" + "macos/Tests/LitheTests/AgentActivityPresentationTests.swift", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift" ] }, "windows": { @@ -45,6 +46,6 @@ ] }, "owner": "macOS UI", - "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。", + "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。", "notes": "顶部分支菜单的 Recent/Local/Remote/Tags 支持点击折叠,Recent 按命名空间分组;搜索覆盖全部引用,清空后保留各组折叠状态。分支动作在右侧按触发行对齐,屏幕边缘自动向左展开。 搜索框共用 Git Log 的控件尺寸、占位、IME 和焦点外框,按上游 SearchFieldWithExtension 显式使用弹窗背景;弹出时焦点留在面板,直接输入或 ⌘F 才进入编辑器。 共享弹窗显式观察应用 colorScheme,内容与外框继承同一环境,原生面板同步明暗外观;应用外观与系统外观不同时不再出现黑色搜索框和浅色文字。 见 #1138:已确认原生布局递归栈溢出,具体触发控件未确定;当前验证覆盖同步反馈风险的防护,不代表原始崩溃已经完整复现。" } From 3ac400d149e5c1b96ea743b14eb284a707a7d5bf Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Wed, 21 Oct 2026 11:05:36 +0800 Subject: [PATCH 02/16] fix(macos): make skill toggles respond immediately --- .../2026-10-20-agent-skill-management.md | 4 +-- .../Features/AgentSkillsFeatureModel.swift | 22 +++++++++++---- .../Views/Agent/AgentSkillsSettingsView.swift | 3 +- .../AgentSkillsFeatureModelTests.swift | 28 +++++++++++++++---- .../features/agent-skill-management.json | 4 +-- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md index 91b6335c64..5e45e316cf 100644 --- a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -28,7 +28,7 @@ Claude 固定适配器把 SDK 的 `supportedCommands()` 转成 ACP 命令列表 ### 原生启停与配置作用范围 -纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行用既有原生小开关表示已读取的配置状态,写入完成前保留旧值、显示进度并禁止重复操作;失败保留原状态并明确要求刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 +纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行使用既有原生小开关。点击后立即显示用户选择的状态,后台写入并回读原生配置,不在开关旁增加加载转圈;保存期间禁止重复操作。用户选择暂存在功能模型,不能提前替换已确认的原生清单或显示“已保存”。确认成功后采用回读结果;失败恢复已确认状态并显示错误,提示刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会清除临时选择、取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 Codex 使用官方 [skills/config/write](https://developers.openai.com/codex/app-server) 按 SKILL.md 路径设置 enabled。一次有界 App Server 交换先用所选工作区发现技能,拒绝未知路径,再写配置并强制读取列表验证最终状态。Codex 的此接口写入用户配置,因此项目技能的开关也影响所有使用相同路径的工作区;页面提示这一点,不将项目分类误称为独立项目开关。不直接重写 Codex 的 TOML 或插件文件。 @@ -71,7 +71,7 @@ Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windo - `./scripts/verify-platform-feature-matrix.sh` - `./scripts/verify-agent-notes.sh` -原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证失败不改值、写入串行和项目切换拒绝旧结果。 +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间开关立即关闭且不增加加载转圈。 ## 适用范围 diff --git a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift index 257058229b..51b5402a1e 100644 --- a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift @@ -13,6 +13,10 @@ final class AgentSkillsFeatureModel: ObservableObject { var agentName: String { agentID == "codex-acp" ? "Codex" : "Claude" } } enum Phase: Equatable { case idle, loading, ready, failed } + struct PendingEnablement { + let itemID: String + let enabled: Bool + } static let agents = ["codex-acp", "claude-acp"] @Published private(set) var phase: Phase = .idle @Published private(set) var listings: [String: AgentSkillListing] = [:] @@ -21,7 +25,7 @@ final class AgentSkillsFeatureModel: ObservableObject { @Published private(set) var error: String? @Published private(set) var savedURL: URL? @Published private(set) var isWriting = false - @Published private(set) var togglingID: String? + @Published private(set) var pendingEnablement: PendingEnablement? @Published private(set) var didChangeEnablement = false @Published var query = "" @Published var scope = "user" @@ -49,6 +53,11 @@ final class AgentSkillsFeatureModel: ObservableObject { func count(in scope: String) -> Int { matchingItems.filter { belongs($0, to: scope) }.count } var visibleItems: [Item] { matchingItems.filter { belongs($0, to: scope) } } + /// Display the user's choice immediately without replacing confirmed native state. + func displayedEnablement(for item: Item) -> Bool? { + if let pendingEnablement, pendingEnablement.itemID == item.id { return pendingEnablement.enabled } + return item.skill.enabled + } private func belongs(_ item: Item, to scope: String) -> Bool { scope == "repo" ? (context?.workspace != nil && item.skill.scope == "repo") : ["user", "system", "admin"].contains(item.skill.scope) } @@ -70,7 +79,7 @@ final class AgentSkillsFeatureModel: ObservableObject { guard !isWriting || context != next else { return } if context != next { writeTask?.cancel(); writeTask = nil; isWriting = false - togglingID = nil; didChangeEnablement = false + pendingEnablement = nil; didChangeEnablement = false listings = [:]; directories = [:]; discoveryErrors = [:]; savedURL = nil } context = next @@ -118,7 +127,8 @@ final class AgentSkillsFeatureModel: ObservableObject { guard !isWriting, phase != .loading, context == expected, item.skill.enabled != nil, item.skill.enabled != enabled, listings[item.agentID]?.skills.contains(item.skill) == true else { return } - isWriting = true; togglingID = item.id; error = nil; didChangeEnablement = false + pendingEnablement = .init(itemID: item.id, enabled: enabled) + isWriting = true; error = nil; didChangeEnablement = false let token = UUID(); revision = token let service = service let request = AgentSkillEnablement(agentID: item.agentID, workspace: expected.workspace, skill: item.skill, enabled: enabled) @@ -128,11 +138,11 @@ final class AgentSkillsFeatureModel: ObservableObject { guard let self, !Task.isCancelled, self.revision == token else { return } self.listings[item.agentID] = listing self.discoveryErrors[item.agentID] = nil - self.isWriting = false; self.togglingID = nil; self.didChangeEnablement = true; self.writeTask = nil + self.pendingEnablement = nil; self.isWriting = false; self.didChangeEnablement = true; self.writeTask = nil } catch { guard let self, !Task.isCancelled, self.revision == token else { return } self.error = error.localizedDescription - self.isWriting = false; self.togglingID = nil; self.writeTask = nil + self.pendingEnablement = nil; self.isWriting = false; self.writeTask = nil } } } @@ -159,7 +169,7 @@ final class AgentSkillsFeatureModel: ObservableObject { func dismissResult() { savedURL = nil; error = nil; didChangeEnablement = false } func cancel() { revision = UUID(); writeTask?.cancel(); writeTask = nil; refreshTask?.cancel(); refreshTask = nil; isWriting = false - togglingID = nil; didChangeEnablement = false + pendingEnablement = nil; didChangeEnablement = false if phase == .loading { phase = .idle } } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift index 0ac86443fb..cd8ef361c2 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift @@ -137,7 +137,7 @@ struct AgentSkillsSettingsView: View { } Text(verbatim: item.skill.description).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(2) }.frame(maxWidth: .infinity, alignment: .leading) - if let enabled = item.skill.enabled { + if let enabled = feature.displayedEnablement(for: item) { Toggle("Enable skill", isOn: Binding(get: { enabled }, set: { feature.setEnabled($0, for: item, context: context) })) .toggleStyle(.switch).controlSize(.small).labelsHidden() .accessibilityLabel(Text(verbatim: text("Enable skill") + ": " + item.skill.name)) @@ -148,7 +148,6 @@ struct AgentSkillsSettingsView: View { } else { Text("State unknown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } - if feature.togglingID == item.id { ProgressView().controlSize(.small) } Button("View skill") { sheet = .details(item) }.buttonStyle(LitheSecondaryButtonStyle()) LitheMenu { LitheContextMenuItem.action("Open SKILL.md") { open(item.skill.path) } diff --git a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift index e221082236..e70c80b4b3 100644 --- a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift @@ -125,7 +125,7 @@ struct AgentSkillsFeatureModelTests { #expect(!feature.isWriting) } - @Test func toggleWaitsForNativeReadbackAndSerializesChanges() async throws { + @Test func toggleImmediatelyShowsTheChoiceAndSerializesNativeConfirmation() async throws { let service = SkillsTestService() let feature = AgentSkillsFeatureModel(service: service) let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) @@ -134,9 +134,11 @@ struct AgentSkillsFeatureModelTests { let item = try #require(feature.visibleItems.first) service.holdWrite = true feature.setEnabled(false, for: item, context: context) + #expect(feature.displayedEnablement(for: item) == false) + #expect(!feature.didChangeEnablement, "the immediate display is not a native confirmation") feature.setEnabled(false, for: item, context: context) #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) - #expect(feature.isWriting && feature.togglingID == item.id) + #expect(feature.isWriting && feature.pendingEnablement?.itemID == item.id) #expect(feature.visibleItems.first?.skill.enabled == true, "pending writes do not invent native state") await feature.refresh(context) #expect(feature.isWriting, "manual refresh cannot invalidate the write") @@ -145,20 +147,26 @@ struct AgentSkillsFeatureModelTests { service.releaseWrite() #expect(await awaitChange(on: feature) { feature.didChangeEnablement && !feature.isWriting }) #expect(feature.visibleItems.first?.skill.enabled == false) + #expect(feature.pendingEnablement == nil) #expect(service.toggleRequests.count == 1) } - @Test func failedToggleKeepsPreviousStateAndUnknownStateCannotBeChanged() async throws { + @Test func failedToggleRestoresConfirmedStateAndUnknownStateCannotBeChanged() async throws { let service = SkillsTestService() let feature = AgentSkillsFeatureModel(service: service) let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) - defer { feature.cancel() } + defer { service.releaseWrite(); feature.cancel() } await feature.refresh(context) let item = try #require(feature.visibleItems.first) service.failToggle = true + service.holdWrite = true feature.setEnabled(false, for: item, context: context) + #expect(feature.displayedEnablement(for: item) == false) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + service.releaseWrite() #expect(await awaitChange(on: feature) { feature.error != nil && !feature.isWriting }) #expect(feature.visibleItems.first?.skill.enabled == true) + #expect(feature.displayedEnablement(for: item) == true && feature.pendingEnablement == nil) #expect(!feature.didChangeEnablement) service.unknownEnablement = true await feature.refresh(context) @@ -182,7 +190,7 @@ struct AgentSkillsFeatureModelTests { service.releaseWrite() #expect(await awaitChange(on: service) { service.finishedToggles == 1 }) #expect(feature.listings == selected && !feature.didChangeEnablement) - #expect(feature.togglingID == nil && !feature.isWriting) + #expect(feature.pendingEnablement == nil && !feature.isWriting) } } @@ -335,7 +343,15 @@ struct AgentSkillsAppearanceTests { feature.setEnabled(false, for: item, context: .init(workspace: service.firstProject)) #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) layout(host) - #expect(try #require(switches(host).first).isEnabled == false, "a real pending write must still disable the control") + let pendingControl = try #require(switches(host).first) + #expect(pendingControl.state == .off, "the native switch reflects the choice before saving finishes") + #expect(!pendingControl.isEnabled, "a real pending write must still disable the control") + func progressIndicators(_ view: NSView) -> [NSProgressIndicator] { + if let indicator = view as? NSProgressIndicator { return [indicator] } + return view.subviews.flatMap(progressIndicators) + } + #expect(progressIndicators(host).isEmpty, "a skill toggle does not add a loading spinner") + try capture(host, name: "skills-toggle-pending") } @Test func scopedListsAndNativeDropdownAnchorsFitNarrowAndWideSettings() async throws { diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json index 3ff0f4fb99..f54beb51a0 100644 --- a/shared/platform-feature-matrix/features/agent-skill-management.json +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -2,7 +2,7 @@ "id": "agent-skill-management", "area": "AI", "group": "Agent 对话", - "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关;操作菜单保留所属窗口焦点和开启开关的颜色", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈);操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", @@ -29,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停写入失败保留原值、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;写入失败恢复已确认值并显示错误、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" } From 9bc811a65cc05a2b0cddfbf05873c58b7057c1ec Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Wed, 21 Oct 2026 14:56:36 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(agent):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=85=A8=E5=B1=80=E6=8C=87=E4=BB=A4=E5=B9=B6?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=8A=80=E8=83=BD=E5=BC=80=E5=85=B3=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 AI 设置中保留独立的指令与技能入口,直接编辑 Codex/Claude 用户指令文件,提供草稿、冲突校验和原子保存。为技能成功提示保留布局空间,覆盖连续启停、深浅主题和窄宽页面的原生几何回归。 --- .../2026-10-15-macos-settings-hierarchy.md | 2 +- .../2026-10-20-agent-skill-management.md | 6 +- .../2026-10-21-agent-global-instructions.md | 55 ++++ macos/Resources/en.lproj/Localizable.strings | 24 ++ .../zh-Hans.lproj/Localizable.strings | 24 ++ .../Application/Composition/AppServices.swift | 3 + .../AgentInstructionsFeatureModel.swift | 65 ++++ .../Rust/RustAgentInstructionsService.swift | 32 ++ .../Lithe/Models/AppModel/AppModel.swift | 1 + .../AppModel/AppModelSupportTypes.swift | 4 +- .../Platform/MacOS/MacServiceContainer.swift | 1 + .../Agent/AgentInstructionsSettingsView.swift | 97 ++++++ .../Views/Agent/AgentSkillsSettingsView.swift | 6 +- .../Views/App/SettingsSearchVocabulary.swift | 2 + .../Lithe/Views/App/SettingsView.swift | 2 + .../Agent/AgentInstructions.swift | 36 +++ .../AgentInstructionsAppearanceTests.swift | 92 ++++++ .../AgentInstructionsFeatureModelTests.swift | 145 +++++++++ .../AgentSkillsFeatureModelTests.swift | 61 ++++ .../SettingsSearchVocabularyTests.swift | 6 + .../SettingsSidebarLayoutTests.swift | 2 +- rust/Cargo.lock | 1 + rust/lithe-agent-host/Cargo.toml | 1 + rust/lithe-agent-host/src/instructions.rs | 285 ++++++++++++++++++ .../src/instructions/tests.rs | 203 +++++++++++++ rust/lithe-agent-host/src/lib.rs | 1 + rust/lithe-core/src/agent/instructions.rs | 40 +++ rust/lithe-core/src/agent/mod.rs | 2 + rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 6 + rust/lithe-core/src/tests/agent.rs | 28 ++ shared/contracts/application-boundary.md | 14 + shared/contracts/rust-core-api.md | 18 ++ .../fixtures/agent/agent-instructions-v1.json | 40 +++ .../features/agent-global-instructions.json | 33 ++ .../features/agent-skill-management.json | 4 +- windows/tauri/src-tauri/Cargo.lock | 3 + 37 files changed, 1341 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md create mode 100644 macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift create mode 100644 macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift create mode 100644 rust/lithe-agent-host/src/instructions.rs create mode 100644 rust/lithe-agent-host/src/instructions/tests.rs create mode 100644 rust/lithe-core/src/agent/instructions.rs create mode 100644 shared/fixtures/agent/agent-instructions-v1.json create mode 100644 shared/platform-feature-matrix/features/agent-global-instructions.json diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 8f5906dc30..7086015ecb 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -22,7 +22,7 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 | 构建、执行、部署 | 构建工具、项目 JDK、运行配置、服务运行,以及框架和构建工具注入的配置 | | 语言与框架 | 语言服务(LSP)的开关,以及 Spring Boot 等框架的配置 | | 工具 | 终端、日志与诊断(查看、目录、清理和诊断包导出)等通用工具;插件带来的专用工具(例如 `.http` 请求)可以放进对应工具页 | -| AI | 凡是和 AI 有关的都放这里:MCP、Skill、AI 提交信息、Agents、AI 服务商 | +| AI | 凡是和 AI 有关的都放这里:MCP、Skill、全局指令、AI 提交信息、Agents、AI 服务商 | 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md index 5e45e316cf..081f5ce6aa 100644 --- a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -16,6 +16,8 @@ 页面放在设置的 AI 分组,Agents 页提供管理入口。主要分类只分全局和项目;项目默认取打开设置的工作区,可选择其他已打开项目,选择动作不切换工作台或会话。新建和导入默认使用正在查看的分类,并明确选择适用 Agent 和显示实际目标目录。 +「技能」和「指令」是同一 AI 分组中的两个独立页面,必须同时保留入口与各自的功能模型。新增全局指令编辑器不能替换技能设置;预览多个尚未合并的功能时,需要使用包含两者的工作区构建,不能把切到另一开发分支造成的入口缺失误判为原生技能未加载。 + 信息结构参考 PI-Desktop `de57f05aa65b05c93a4113bf5d4db9723884b080` 的 [AgentSkillsPage](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/AgentSkillsPage.tsx) 和 [SkillEditorSheet](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/SkillEditorSheet.tsx)。只参考分类、搜索、紧凑列表与独立编辑面板的交互组织,不复制其代码、样式或状态存储。控件继续使用 Lithe 的设置分段、选择器、搜索框、按钮和菜单。主工作台、标题栏和项目标签不参与此变更。 ### 发现事实仍由 Agent 拥有 @@ -36,7 +38,7 @@ Claude 使用官方 [skillOverrides](https://code.claude.com/docs/en/skills#over Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有权限;无效 JSON、未知枚举、非普通文件、符号链接、超出 1 MiB 的配置均拒绝写入。配置目录内的持久锁文件用非阻塞文件锁串行化 Lithe 实例,写前重新比较原始字节以识别外部修改;临时文件同目录落盘并同步后原子替换,取消或失败回收临时文件。外部编辑器并不共享该锁,字节比较和替换间仍有极短的竞争窗口,不承诺跨所有编辑器的事务。锁与配置由用户目录/所选项目持有,不是可跨 worktree 复用的构建资源,不能写入 app bundle。 -开关反馈只说明原生配置已保存。已经加载进会话的内容无法撤回,现有对话应重新连接以刷新命令和技能清单;不自动重连正在执行任务的 Agent。 +开关反馈只说明原生配置已保存。成功提示始终预留文字所需的布局空间,仅在原生确认后可见;开始下一次保存时隐藏文字但保留空间,同时从辅助功能树隐藏。不要按保存状态插入或删除整行提示,否则首次保存及连续切换都会让下方技能列表上下跳动。已经加载进会话的内容无法撤回,现有对话应重新连接以刷新命令和技能清单;不自动重连正在执行任务的 Agent。 ### 文件写入和异步生命周期 @@ -71,7 +73,7 @@ Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windo - `./scripts/verify-platform-feature-matrix.sh` - `./scripts/verify-agent-notes.sh` -原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间开关立即关闭且不增加加载转圈。 +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;开关测试分别阻塞两次原生写入,对比首次点击前、关闭保存中、关闭确认后、再次开启保存中和开启确认后的全部原生开关位置,确保提示变化不移动技能行。可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间开关立即关闭且不增加加载转圈。 ## 适用范围 diff --git a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md new file mode 100644 index 0000000000..79f5ad744a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md @@ -0,0 +1,55 @@ +# Agent 笔记:全局指令使用原生用户文件 + +状态:已实现 + +## 先说结论 + +macOS 设置的「AI → 指令」可以编辑 Codex、Claude Code 各自的全局指令。保存直接更新原生用户文件,新对话由上游 Agent 加载;Lithe 不额外拼接一份指令,也不改项目规则。页面显示实际路径,保存冲突或失败时保留草稿。 + +## 问题 + +Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口。只新增一个文本框并保存到 Lithe 偏好,会让界面显示与 Agent 实际收到的内容不一致;忽略 Codex override 文件,也会出现保存成功却不生效的问题。 + +## 决策 + +- 页面沿用设置分组、搜索、共享选择器和原生多行编辑框。Codex、Claude 分别保留内存草稿,切换页面或 Agent 不自动保存。明确选择重新加载并确认放弃草稿后,成功读取才替换草稿。 +- 全局指令和技能管理的入口、功能模型、Core 命令分别保留,在同一 AI 分组中并列可用;预览构建必须包含两个功能后再交给用户。全局指令编辑器不负责加载、启停或复制技能。 +- 原生文件路径由 Agent Host(负责原生文件和进程的 Rust 适配层)解析,使用 Agent 启动时继承的 `CODEX_HOME`、`CLAUDE_CONFIG_DIR` 或平台用户目录。Codex 选择首个非空的 `AGENTS.override.md` / `AGENTS.md`,Claude 选择 `CLAUDE.md`;不支持的自定义 Agent 不猜路径。 +- Core 只分派带类型的请求和稳定错误;Swift 应用模型管理加载、保存、失败和草稿,视图不直接访问文件。原生路径仅作显示和保存校验,不成为跨平台持久标识。 +- 保存比较原文件的路径与摘要(字节内容的 SHA-256 标识),防止覆盖读取后发生的外部修改。临时文件在目标目录内创建,写完同步后原子替换;保留已有权限,拒绝覆盖只读文件,Unix 新文件为 `0600`、新目录为 `0700`。同进程保存使用不等待的锁;其他程序不共享锁,检查与替换之间仍存在很小的外部竞争窗口,不声称提供系统级事务。 +- 清空 override 后重新按原生规则读取,页面可能回到基础 `AGENTS.md`。正确做法是展示这个实际生效的来源;不要保留一个已被原生跳过的空 override 并声称已清空全部指令。 +- 文件只接受至多 1 MiB 的 UTF-8 普通文件,符号链接拒绝编辑。限制用于保护设置页资源;Codex 自身的上下文截断预算仍由原生配置决定,Lithe 不修改它。 +- 仅用户点击保存时写用户配置目录;不写发行 bundle、安装目录、代码签名资源或 Sparkle 增量更新基线。不新增下载、缓存或可跨工作树复用的生成资源,因此不注册到工作树资源清单。临时文件由单次保存拥有,成功和失败都清理,异常退出遗留只是一份随机命名临时文件,不会被 Agent 加载。 + +交互结构参考 [PI-Desktop 的全局指令页](https://github.com/vastsa/PI-Desktop/blob/a6f4ba121e524a167c42466c7b4debbb3cfd0744/apps/desktop/src/features/settings/agent-sections.tsx):路径、文本编辑和显式保存。没有复制其源码或视觉样式。原生规则依据 [Codex AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 和 [Claude Code memory](https://code.claude.com/docs/en/memory)。 + +## 考虑过的备选方案 + +### 所有 Agent 共用一个 Lithe 指令副本 + +这需要维护额外的注入、去重和历史恢复语义,也会和用户 CLI 的全局文件产生两份来源。采用各自的原生文件,代价是用户希望同一规则用于两种 Agent 时需分别保存。 + +### 保存时停止并重连所有项目 + +会打断正在进行的任务,且不能撤回已进入模型上下文的旧内容。因此只提示新建对话,不自动停止或篡改现有会话。项目规则优先级继续由上游 Agent 决定,不在页面声称全局指令能覆盖所有规则。 + +## 后果 + +用户可以直接管理 CLI 也会读取的全局文件,冲突能保留草稿并显式恢复。代价是符号链接配置需先由用户自行处理,外部编辑不会实时推送到页面,更新也不会改写现有对话。Windows 已可通过共享 Core 调用文件能力,尚无对应设置页和 Windows 实机验证;矩阵保留部分实现、待验证状态。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentInstructionsFeatureModelTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests'`。 +- Agent Host 计时测试覆盖原生优先级、读取不创建文件、保存和清空、外部修改与路径变化、UTF-8/大小限制、符号链接拒绝、权限保留和临时文件清理。 +- Core 的 `global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access` 验证共享 fixture 与严格请求边界。 +- 隔离临时用户目录,通过同一 Core 命令保存,再捕获真实 Codex CLI 与 Claude CLI 发出的模型请求;Codex 的保存、更新、override 保存、清空 override、清空基础文件,以及 Claude 的保存、更新、清空共八项均确认新增指令出现、旧指令消失。Codex 五项同时确认项目规则继续存在。响应来自本地受控服务,不是在线模型推理;测试进程和临时目录均回收,不修改用户实际配置。 +- `./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-service-boundaries.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-agent-notes.sh`、`./scripts/verify-platform-feature-matrix.sh`。 + +## 适用范围 + +- `rust/lithe-agent-host/src/instructions.rs` +- `rust/lithe-core/src/agent/instructions.rs` +- `macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift` +- `shared/fixtures/agent/agent-instructions-v1.json` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 057cb30b53..1608a86a52 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2066,6 +2066,30 @@ "Session" = "Session"; "Minimum level" = "Minimum level"; "Open Log Directory" = "Open Log Directory"; + +"Instructions" = "Instructions"; +"Global instructions" = "Global instructions"; +"Instruction Agent" = "Instruction Agent"; +"Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file."; +"The file will be created when you save." = "The file will be created when you save."; +"Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions." = "Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions."; +"Save instructions" = "Save instructions"; +"Reload from disk" = "Reload from disk"; +"Instructions saved." = "Instructions saved."; +"Unsaved changes" = "Unsaved changes"; +"Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "Start a new conversation after saving to load the updated instructions. Project instructions still apply."; +"Discard draft and reload?" = "Discard draft and reload?"; +"Reloading replaces this draft with the current file contents." = "Reloading replaces this draft with the current file contents."; +"Global instructions must be no larger than 1 MiB." = "Global instructions must be no larger than 1 MiB."; +"Cannot access the global instruction file. Check its permissions and try again." = "Cannot access the global instruction file. Check its permissions and try again."; +"Global instructions are available for Codex and Claude Code." = "Global instructions are available for Codex and Claude Code."; +"Cannot locate the Agent user directory." = "Cannot locate the Agent user directory."; +"The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR." = "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR."; +"The instruction path must be a regular file, not a symbolic link or directory." = "The instruction path must be a regular file, not a symbolic link or directory."; +"The global instruction file must use UTF-8 encoding." = "The global instruction file must use UTF-8 encoding."; +"Another instruction save is running. Try again." = "Another instruction save is running. Try again."; +"The instruction directory changed. Reload before saving." = "The instruction directory changed. Reload before saving."; +"Global instructions changed outside this editor. Your draft is kept; reload before saving." = "Global instructions changed outside this editor. Your draft is kept; reload before saving."; /* Agent skill management */ "Skills" = "Skills"; "Agent skills" = "Agent skills"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9a51689b6c..1ab25632ef 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3533,6 +3533,30 @@ "Module: %@ · Session: %@ · Minimum level: %@" = "模块:%@ · 会话:%@ · 最低级别:%@"; "Minimum level" = "最低级别"; "Open Log Directory" = "打开日志目录"; + +"Instructions" = "指令"; +"Global instructions" = "全局指令"; +"Instruction Agent" = "指令所属 Agent"; +"Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "全局指令适用于使用此 Agent 的所有项目。每个 Agent 使用各自的原生文件。"; +"The file will be created when you save." = "保存时将创建此文件。"; +"Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions." = "Codex 正在使用 AGENTS.override.md。清空后将恢复基础 AGENTS.md 中的指令。"; +"Save instructions" = "保存指令"; +"Reload from disk" = "从磁盘重新加载"; +"Instructions saved." = "指令已保存。"; +"Unsaved changes" = "有未保存的修改"; +"Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "保存后请新建对话以加载更新后的指令。项目指令仍然有效。"; +"Discard draft and reload?" = "放弃草稿并重新加载?"; +"Reloading replaces this draft with the current file contents." = "重新加载将使用磁盘文件的当前内容替换此草稿。"; +"Global instructions must be no larger than 1 MiB." = "全局指令不能超过 1 MiB。"; +"Cannot access the global instruction file. Check its permissions and try again." = "无法访问全局指令文件,请检查文件权限后重试。"; +"Global instructions are available for Codex and Claude Code." = "Codex 和 Claude Code 支持全局指令。"; +"Cannot locate the Agent user directory." = "无法定位 Agent 的用户目录。"; +"The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR." = "Agent 用户目录必须为绝对路径,请检查 CODEX_HOME 或 CLAUDE_CONFIG_DIR。"; +"The instruction path must be a regular file, not a symbolic link or directory." = "指令路径必须为普通文件,不能为符号链接或目录。"; +"The global instruction file must use UTF-8 encoding." = "全局指令文件必须使用 UTF-8 编码。"; +"Another instruction save is running. Try again." = "另一项指令保存正在进行,请重试。"; +"The instruction directory changed. Reload before saving." = "指令目录已变更,请重新加载后再保存。"; +"Global instructions changed outside this editor. Your draft is kept; reload before saving." = "全局指令已被其他程序修改。草稿已保留,请重新加载后再保存。"; /* Agent skill management */ "Skills" = "技能"; "Agent skills" = "Agent 技能"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index b61a30abda..535a22a017 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -65,6 +65,7 @@ final class AppServices { let credentialResolver: any AIProviderCredentialResolver /// Detects Node.js and installs ACP adapters for the Agents settings page. let agentManagement: any AgentManagementService + let agentInstructions: any AgentInstructionsService let agentProviderConfigurationParser: any AgentProviderConfigurationParsing let aiConfigurationSources: [any AIConfigurationSource] let recentProjectsStore: RecentProjectsStore @@ -110,6 +111,7 @@ final class AppServices { diagnosticsExportService: DiagnosticsExportService, credentialResolver: any AIProviderCredentialResolver, agentManagement: any AgentManagementService = UnavailableAgentManagementService(), + agentInstructions: any AgentInstructionsService = UnavailableAgentInstructionsService(), agentProviderConfigurationParser: any AgentProviderConfigurationParsing = UnavailableAgentProviderConfigurationParser(), aiConfigurationSources: [any AIConfigurationSource], recentProjectsStore: RecentProjectsStore, @@ -167,6 +169,7 @@ final class AppServices { self.diagnosticsExportService = diagnosticsExportService self.credentialResolver = credentialResolver self.agentManagement = agentManagement + self.agentInstructions = agentInstructions self.agentProviderConfigurationParser = agentProviderConfigurationParser self.aiConfigurationSources = aiConfigurationSources self.recentProjectsStore = recentProjectsStore diff --git a/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift new file mode 100644 index 0000000000..910b130272 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift @@ -0,0 +1,65 @@ +import Foundation +import LitheCoreContracts + +/// Settings edits the upstream files directly; drafts survive navigation and failures. +@MainActor +final class AgentInstructionsFeatureModel: ObservableObject { + enum Phase: Equatable { case idle, loading, ready, saving, failed(String) } + + @Published var selectedAgent: AgentInstructionKind = .codex + @Published private(set) var files: [AgentInstructionKind: AgentInstructionFile] = [:] + @Published private(set) var drafts: [AgentInstructionKind: String] = [:] + @Published private(set) var phases: [AgentInstructionKind: Phase] = [:] + @Published private(set) var saved: Set = [] + private let service: any AgentInstructionsService + + init(service: any AgentInstructionsService) { self.service = service } + + var file: AgentInstructionFile? { files[selectedAgent] } + var draft: String { drafts[selectedAgent] ?? "" } + var phase: Phase { phases[selectedAgent] ?? .idle } + var isBusy: Bool { phase == .loading || phase == .saving } + var isDirty: Bool { file.map { draft != $0.content } ?? false } + var canSave: Bool { isDirty && !isBusy && draft.utf8.count <= 1024 * 1024 } + + func edit(_ text: String) { + guard !isBusy else { return } + drafts[selectedAgent] = text + saved.remove(selectedAgent) + } + + /// Initial loads retain cached drafts. Explicit reload replaces a draft only after success. + func load(reload: Bool = false) async { + let agent = selectedAgent + guard !isBusy, reload || files[agent] == nil else { return } + phases[agent] = .loading + do { + let file = try await service.read(agent: agent) + files[agent] = file + drafts[agent] = file.content + saved.remove(agent) + phases[agent] = .ready + } catch is CancellationError { + phases[agent] = files[agent] == nil ? .idle : .ready + } catch { + phases[agent] = .failed(error.localizedDescription) + } + } + + func save() async { + let agent = selectedAgent + guard canSave, let file = files[agent] else { return } + let content = drafts[agent] ?? "" + phases[agent] = .saving + do { + let updated = try await service.save(file: file, content: content) + files[agent] = updated + // Text controls are disabled during save; selection may still change. + drafts[agent] = updated.content + saved.insert(agent) + phases[agent] = .ready + } catch { + phases[agent] = .failed(error.localizedDescription) + } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift new file mode 100644 index 0000000000..fe566467ba --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift @@ -0,0 +1,32 @@ +import Foundation +import LitheCoreContracts + +struct RustAgentInstructionsService: AgentInstructionsService { + let core: RustCoreBridge + private struct Read: Encodable, Sendable { let agentId: String } + private struct Save: Encodable, Sendable { + let agentId: String + let content: String + let expectedPath: String + let expectedRevision: String + } + + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + try await run("agent.instructions.read", Read(agentId: agent.rawValue)) + } + + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { + try await run("agent.instructions.save", Save(agentId: file.agentId, content: content, + expectedPath: file.path, expectedRevision: file.revision)) + } + + private func run(_ command: String, _ payload: Payload) async throws -> AgentInstructionFile { + let core = core + let operationID = UUID().uuidString + return try await withTaskCancellationHandler { + try await Task.detached(priority: .userInitiated) { + try core.executeResult(command: command, payload: payload, operationID: operationID).get() + }.value + } onCancel: { core.cancel(operationID: operationID) } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index bf7b6fcf06..d1bc556fb1 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -98,6 +98,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { service: services.agentManagement, dataDirectory: agentDataDirectory ) + lazy var agentInstructionsFeature = AgentInstructionsFeatureModel(service: services.agentInstructions) lazy var agentSkillsFeature = AgentSkillsFeatureModel(service: services.agentManagement) /// Settings › Plugins marketplace and plugin repositories, created when /// the page is first shown. diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 2b66eef229..a628f5cbb0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -12,6 +12,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case lsp = "LSP" case ai = "AI & Commit" case agents = "Agents" + case instructions = "Instructions" case skills = "Skills" case providers = "AI Providers" case git = "Git" @@ -46,6 +47,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .lsp: "server.rack" case .ai: "wand.and.stars" case .agents: "sparkles" + case .instructions: "doc.text" case .skills: "book" case .providers: "key" case .git: "arrow.triangle.branch" @@ -80,7 +82,7 @@ enum SettingsGroup: String, CaseIterable, Identifiable { case .build: [.project, .run] case .languages: [.lsp] case .tools: [.terminal, .diagnostics] - case .ai: [.mcp, .ai, .agents, .skills, .providers] + case .ai: [.mcp, .ai, .instructions, .agents, .skills, .providers] } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index cc6cad68ae..723cae611c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -742,6 +742,7 @@ final class MacServiceContainer { diagnosticsExportService: diagnosticsExportService, credentialResolver: credentialResolver, agentManagement: RustAgentManagementService(core: rustCore), + agentInstructions: RustAgentInstructionsService(core: rustCore), agentProviderConfigurationParser: MacAgentProviderConfigurationParser(core: rustCore), aiConfigurationSources: aiConfigurationSources, recentProjectsStore: RecentProjectsStore(store: store), diff --git a/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift new file mode 100644 index 0000000000..15a56162f9 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift @@ -0,0 +1,97 @@ +import SwiftUI +import LitheCoreContracts + +struct AgentInstructionsSettingsView: View { + @Environment(\.locale) private var locale + var localizationBundle: Bundle = .main + @ObservedObject var feature: AgentInstructionsFeatureModel + @State private var operation: Task? + @State private var confirmsReload = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(key("Instructions")).font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheSettingsSection(localized("Global instructions")) { + HStack { + LitheSettingsLabel(key("Agent"), info: key("Global instructions apply to all projects using this Agent. Each Agent keeps its own native file.")) + LitheSettingsSelect(selection: $feature.selectedAgent, + options: AgentInstructionKind.allCases, width: 220, + accessibilityLabel: localized("Instruction Agent"), title: { $0.title }, localizesTitles: false) + .disabled(feature.isBusy) + } + if let file = feature.file { + Text(verbatim: file.path) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + if file.path.hasSuffix("AGENTS.override.md") { + Text(key("Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.")) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + if !file.exists { + Text(key("The file will be created when you save.")) + .foregroundStyle(LitheTheme.secondaryText) + } + TextEditor(text: Binding(get: { feature.draft }, set: { feature.edit($0) })) + .autocorrectionDisabled() + .litheSettingsTextEditor(height: 320) + .disabled(feature.isBusy) + .accessibilityLabel(Text(key("Global instructions"))) + } + HStack(spacing: 8) { + Button(localized("Save instructions")) { + operation = Task { await feature.save() } + } + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) + .disabled(!feature.canSave) + .opacity(feature.canSave ? 1 : 0.5) + Button(localized("Reload from disk")) { + if feature.isDirty { confirmsReload = true } + else { reload() } + } + .buttonStyle(LitheSecondaryButtonStyle()) + .disabled(feature.isBusy) + if feature.isBusy { + ProgressView().controlSize(.small) + } else if feature.saved.contains(feature.selectedAgent) { + Text(key("Instructions saved.")).foregroundStyle(LitheTheme.secondaryText) + } else if feature.isDirty { + Text(key("Unsaved changes")).foregroundStyle(LitheTheme.secondaryText) + } + } + if case .failed(let message) = feature.phase { + Text(key(String.LocalizationValue(message))).foregroundStyle(LitheTheme.error) + .textSelection(.enabled) + } + if feature.draft.utf8.count > 1024 * 1024 { + Text(key("Global instructions must be no larger than 1 MiB.")).foregroundStyle(LitheTheme.error) + } + Text(key("Start a new conversation after saving to load the updated instructions. Project instructions still apply.")) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: 760, alignment: .leading) + .task(id: feature.selectedAgent) { await feature.load() } + .onDisappear { operation?.cancel(); operation = nil } + .alert(localized("Discard draft and reload?"), isPresented: $confirmsReload) { + Button(localized("Cancel"), role: .cancel) {} + Button(localized("Reload from disk"), role: .destructive) { reload() } + } message: { + Text(key("Reloading replaces this draft with the current file contents.")) + } + } + + private func localized(_ value: String.LocalizationValue) -> String { + AgentLocalization.string(value, locale: locale, bundle: localizationBundle) + } + + private func key(_ value: String.LocalizationValue) -> LocalizedStringKey { + LocalizedStringKey(localized(value)) + } + + private func reload() { + operation = Task { await feature.load(reload: true) } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift index cd8ef361c2..7e98ffab15 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift @@ -104,9 +104,9 @@ struct AgentSkillsSettingsView: View { if feature.phase == .loading { ProgressView().controlSize(.small) } Text("Switches save native Agent settings. Reconnect existing conversations to refresh available skills.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) - if feature.didChangeEnablement { - Text("Skill setting saved.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) - } + Text("Skill setting saved.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + .opacity(feature.didChangeEnablement ? 1 : 0) + .accessibilityHidden(!feature.didChangeEnablement) if feature.savedURL != nil { Text("Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index 8903891ec0..d5ffb298db 100644 --- a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift +++ b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift @@ -33,6 +33,8 @@ enum SettingsSearchVocabulary { ["AI & Commit", "Commit message", "Pull request"] case .agents: ["Agents", "Agent conversation", "Codex", "Claude", "ACP adapter", "Preflight", "Providers"] + case .instructions: + ["Instructions", "Global instructions", "AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "Codex", "Claude"] case .skills: ["Skills", "Agent skills", "SKILL.md", "Global skills", "Project skills", "Import skill", "New skill", "Codex", "Claude"] case .providers: diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 1f993a8c38..7d4f200888 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -362,6 +362,8 @@ struct SettingsView: View { case .project: EmptyView() case .run: EmptyView() case .ai: aiSettings + case .instructions: + AgentInstructionsSettingsView(feature: model.agentInstructionsFeature) case .agents: AgentPanelSettingsView(model: model, settings: settings, feature: model.agentManagementFeature) .id(model.settingsCategoryRequest) diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift new file mode 100644 index 0000000000..86b43dc34d --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum AgentInstructionKind: String, CaseIterable, Identifiable, Sendable { + case codex = "codex-acp" + case claude = "claude-acp" + public var id: String { rawValue } + public var title: String { self == .codex ? "Codex" : "Claude Code" } +} + +/// Snapshot of the effective native user file. Revision/path guard a later save. +public struct AgentInstructionFile: Decodable, Equatable, Sendable { + public let agentId: String + public let path: String + public let content: String + public let exists: Bool + public let revision: String + + public init(agentId: String, path: String, content: String, exists: Bool, revision: String) { + self.agentId = agentId + self.path = path + self.content = content + self.exists = exists + self.revision = revision + } +} + +public protocol AgentInstructionsService: Sendable { + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile +} + +public struct UnavailableAgentInstructionsService: AgentInstructionsService { + public init() {} + public func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } + public func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift new file mode 100644 index 0000000000..db18624ed3 --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift @@ -0,0 +1,92 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Global instruction settings appearance") +@MainActor +struct AgentInstructionsAppearanceTests { + @Test(arguments: [false, true]) + func nativeEditorFitsSettingsAndPreservesUnicode(_ dark: Bool) async throws { + let feature = AgentInstructionsFeatureModel(service: AppearanceInstructions()) + await feature.load() + for language in ["en", "zh-Hans"] { + let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Resources") + let bundle = try #require(Bundle(url: resources.appendingPathComponent("\(language).lproj"))) + #expect(AgentLocalization.string("Instructions", locale: Locale(identifier: language), bundle: bundle) + == (language == "en" ? "Instructions" : "指令")) + for width in [CGFloat(500), CGFloat(760)] { + let root = SettingsAppearanceContainer(themePreference: dark ? .dark : .light) { + ScrollView { AgentInstructionsSettingsView(localizationBundle: bundle, feature: feature).padding(16) } + }.environment(\.locale, Locale(identifier: language)) + let host = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 660), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { window.contentView = nil; window.close() } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var editors: [NSTextView] = [] + while clock.now < deadline { + host.layoutSubtreeIfNeeded() + editors = textViews(host).filter { $0.string == AppearanceInstructions.text } + if !editors.isEmpty { break } + await Task.yield() + } + let editor = try #require(editors.first, "the shared editor must host native UTF-8 text") + let rect = editor.convert(editor.bounds, to: host) + #expect(rect.minX >= 0 && rect.maxX <= width + 1) + #expect(editor.isEditable) + #expect(feature.file?.path == "/fixture/user/.codex/AGENTS.md") + if let folder = ProcessInfo.processInfo.environment["LITHE_INSTRUCTIONS_SCREENSHOTS"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let path = URL(fileURLWithPath: folder).appendingPathComponent("instructions-\(dark ? "dark" : "light")-\(language)-\(Int(width)).png") + try png.write(to: path) + } + // Exercise native typing through SwiftUI's binding, not just model.edit. + window.makeFirstResponder(editor) + editor.insertText("\n原生输入", replacementRange: NSRange(location: editor.string.utf16.count, length: 0)) + let typingDeadline = clock.now.advanced(by: .seconds(2)) + while !feature.draft.hasSuffix("原生输入") && clock.now < typingDeadline { await Task.yield() } + #expect(feature.draft == AppearanceInstructions.text + "\n原生输入") + #expect(feature.canSave) + #expect(feature.file?.content == AppearanceInstructions.text, "typing must not save automatically") + feature.edit(AppearanceInstructions.text) + } + } + } + + private func textViews(_ view: NSView) -> [NSTextView] { + if let editor = view as? NSTextView { return [editor] } + return view.subviews.flatMap(textViews) + } +} + +private struct AppearanceInstructions: AgentInstructionsService { + static let text = "# 全局指令\n\n使用简体中文回复。\n保留代码标识符和命令的原始拼写。\n" + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + AgentInstructionFile(agentId: agent.rawValue, path: "/fixture/user/.codex/AGENTS.md", + content: Self.text, exists: true, revision: "fixture") + } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } +} + +@Suite("Global instruction contract") +struct AgentInstructionsContractTests { + @Test func sharedSnapshotsDecodeOnBothPlatforms() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + struct Fixture: Decodable { let file: AgentInstructionFile; let windowsFile: AgentInstructionFile } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/agent-instructions-v1.json"))) + #expect(fixture.file.agentId == "codex-acp") + #expect(fixture.file.revision.count == 64) + #expect(fixture.windowsFile.agentId == "claude-acp") + #expect(fixture.windowsFile.path == "C:\\Users\\fixture\\.claude\\CLAUDE.md") + } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift new file mode 100644 index 0000000000..0ef0d8afd6 --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Global Agent instructions") +@MainActor +struct AgentInstructionsFeatureModelTests { + @Test func draftsAreIndependentAndSurviveNavigation() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("Codex draft 中文") + feature.selectedAgent = .claude + await feature.load() + feature.edit("Claude draft") + feature.selectedAgent = .codex + await feature.load() + #expect(feature.draft == "Codex draft 中文") + #expect(feature.isDirty && feature.canSave) + #expect(await service.reads == [.codex, .claude]) + } + + @Test func successfulSaveConfirmsNativeResultAndDoesNotOverwriteAnotherAgentDraft() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("new global instructions") + await service.onSave { + await MainActor.run { + #expect(feature.phase == .saving) + #expect(!feature.canSave) + } + // A duplicate save and reload must not race the pending write. + await feature.save() + await feature.load(reload: true) + await MainActor.run { feature.selectedAgent = .claude } + await feature.load() + await MainActor.run { feature.edit("other draft") } + } + await feature.save() + #expect(feature.selectedAgent == .claude) + #expect(feature.draft == "other draft") + #expect(feature.files[.codex]?.content == "new global instructions") + #expect(feature.saved.contains(.codex)) + #expect(await service.writes == 1) + feature.selectedAgent = .codex + #expect(!feature.isDirty) + } + + @Test func failedSaveKeepsDraftAndExplicitReloadUsesExternalContents() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("keep this draft") + await service.setFailure(.conflict) + await feature.save() + #expect(feature.phase == .failed("External file changed")) + #expect(feature.draft == "keep this draft") + #expect(feature.file?.content == "Codex original") + #expect(feature.saved.isEmpty) + await service.setFailure(nil) + await service.replaceCodex("External text") + await feature.load(reload: true) + #expect(feature.draft == "External text") + #expect(!feature.isDirty) + } + + @Test func failedReloadAndFailedInitialLoadAreRetryable() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await service.setFailure(.read) + await feature.load() + #expect(feature.file == nil) + #expect(!feature.canSave) + #expect(feature.phase == .failed("Cannot read")) + await service.setFailure(nil) + await feature.load() + feature.edit("draft") + await service.setFailure(.read) + await feature.load(reload: true) + #expect(feature.draft == "draft") + #expect(feature.file?.content == "Codex original") + } + + @Test func emptyTextIsSavedAndOversizedTextIsRejectedBeforeNativeWrite() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit(String(repeating: "文", count: 350_000)) + #expect(!feature.canSave, "the limit counts UTF-8 bytes") + await feature.save() + #expect(await service.writes == 0) + feature.edit("") + await feature.save() + #expect(feature.file?.content == "") + #expect(feature.saved.contains(.codex)) + } + + @Test func nativeReadbackCanSwitchBackFromAnEmptyOverride() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("") + await service.returnEffectiveBaseAfterSave() + await feature.save() + #expect(feature.file?.path == "/fixture/codex/AGENTS.md") + #expect(feature.draft == "Effective base") + #expect(!feature.isDirty) + } +} + +private actor InstructionsStub: AgentInstructionsService { + enum Failure: LocalizedError { + case read, conflict + var errorDescription: String? { self == .read ? "Cannot read" : "External file changed" } + } + var reads: [AgentInstructionKind] = [] + var writes = 0 + private var failure: Failure? + private var saveCallback: (@Sendable () async -> Void)? + private var effectiveBase = false + private var codexContent = "Codex original" + + func onSave(_ callback: @escaping @Sendable () async -> Void) { saveCallback = callback } + func setFailure(_ value: Failure?) { failure = value } + func replaceCodex(_ content: String) { codexContent = content } + func returnEffectiveBaseAfterSave() { effectiveBase = true } + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + reads.append(agent) + if failure == .read { throw Failure.read } + return AgentInstructionFile(agentId: agent.rawValue, path: "/fixture/\(agent.id)/instructions.md", + content: agent == .codex ? codexContent : "Claude original", exists: true, revision: "snapshot") + } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { + writes += 1 + let callback = saveCallback + saveCallback = nil + await callback?() + if let failure { throw failure } + return AgentInstructionFile(agentId: file.agentId, + path: effectiveBase ? "/fixture/codex/AGENTS.md" : file.path, + content: effectiveBase ? "Effective base" : content, exists: true, revision: "saved") + } +} diff --git a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift index e70c80b4b3..fa5b78308f 100644 --- a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift @@ -285,6 +285,50 @@ struct AgentSkillsRustBridgeTests { @MainActor struct AgentSkillsAppearanceTests { + @Test(arguments: [false, true], [500, 760]) + func toggleFeedbackKeepsRowsInPlace(dark: Bool, width: Int) async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: dark ? .dark : .light) { + ScrollView { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + } + }.environment(\.locale, Locale(identifier: "zh-Hans"))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: CGFloat(width), height: 640), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderFront(nil) + defer { service.releaseWrite(); feature.cancel(); window.contentView = nil; window.close() } + layout(host) + try #require(await awaitChange(on: feature) { feature.phase == .ready && service.listRequests >= 4 }) + let initial = try await renderedSwitches(host, state: .on, enabled: true) + let originalFrames = initial.map { $0.convert($0.bounds, to: host) } + let name = "skills-stable-feedback-\(dark ? "dark" : "light")-\(width)" + try capture(host, name: name + "-initial") + + // Hold each native write so both the first save and the next click are observable. + for (index, enabled) in [false, true].enumerated() { + service.holdWrite = true + let item = try #require(feature.visibleItems.first) + feature.setEnabled(enabled, for: item, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == index + 1 }) + #expect(!feature.didChangeEnablement, "success is hidden until native confirmation") + let pending = try await renderedSwitches(host, state: enabled ? .on : .off, enabled: false) + #expect(pending.map { $0.convert($0.bounds, to: host) } == originalFrames, + "starting another save must not collapse the success message's space") + try capture(host, name: name + "-\(index)-pending") + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting && feature.didChangeEnablement }) + let saved = try await renderedSwitches(host, state: enabled ? .on : .off, enabled: true) + #expect(saved.map { $0.convert($0.bounds, to: host) } == originalFrames, + "showing save confirmation must not shift any skill row") + try capture(host, name: name + "-\(index)-saved") + } + } + @Test func openingAnActionMenuPreservesTheNativeSwitchStateAndAppearance() async throws { let service = SkillsTestService() let feature = AgentSkillsFeatureModel(service: service) @@ -428,6 +472,23 @@ struct AgentSkillsAppearanceTests { feature.cancel() } + private func renderedSwitches(_ host: NSView, state: NSControl.StateValue, enabled: Bool) async throws -> [NSSwitch] { + func switches(_ view: NSView) -> [NSSwitch] { + if let control = view as? NSSwitch { return [control] } + return view.subviews.flatMap(switches) + } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + var controls: [NSSwitch] = [] + while clock.now < deadline { + layout(host) + controls = switches(host) + if controls.count == 2 && controls.first?.state == state && controls.allSatisfy({ $0.isEnabled == enabled }) { return controls } + await Task.yield() + } + Issue.record("Native skill switches did not render the expected state before the local deadline") + throw CancellationError() + } + private func layout(_ host: NSView) { CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) host.layoutSubtreeIfNeeded() diff --git a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift index 71d3435245..0a7a285737 100644 --- a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift +++ b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift @@ -11,6 +11,12 @@ import Testing @Suite("Settings search vocabulary") @MainActor struct SettingsSearchVocabularyTests { + @Test func globalInstructionsAreSearchableByNativeFileAndChineseName() throws { + let translations = try simplifiedChineseTranslations() + for query in ["Global instructions", "AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "全局指令"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .instructions, localize: { translations[$0] ?? $0 })) + } + } @Test func everySettingsPageKeepsSearchTerms() { for category in SettingsCategory.allCases { #expect( diff --git a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift index 97bdf9bd98..333ef1800a 100644 --- a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift +++ b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift @@ -37,6 +37,6 @@ struct SettingsSidebarLayoutTests { #expect(SettingsGroup.build.categories == [.project, .run]) #expect(SettingsGroup.languages.categories == [.lsp]) #expect(SettingsGroup.tools.categories == [.terminal, .diagnostics]) - #expect(SettingsGroup.ai.categories == [.mcp, .ai, .agents, .skills, .providers]) + #expect(SettingsGroup.ai.categories == [.mcp, .ai, .instructions, .agents, .skills, .providers]) } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 26df940af8..0a5f60ae22 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2010,6 +2010,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha2", "tokio", "tokio-util", "tungstenite", diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index 7873c543f0..530f1fa008 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -18,6 +18,7 @@ kill_tree = { version = "0.2", features = ["tokio"] } serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde_json = "1" +sha2 = "0.10" serde_yaml_ng = "0.10.0" semver = "1" tokio = { version = "1", features = ["io-util", "net", "process", "rt", "sync", "time"] } diff --git a/rust/lithe-agent-host/src/instructions.rs b/rust/lithe-agent-host/src/instructions.rs new file mode 100644 index 0000000000..b89909fbdd --- /dev/null +++ b/rust/lithe-agent-host/src/instructions.rs @@ -0,0 +1,285 @@ +//! Native global instruction files. The upstream Agent owns prompt loading; +//! this adapter only resolves, reads and atomically saves the user's file. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const MAX_BYTES: usize = 1024 * 1024; +static SAVE_LOCK: Mutex<()> = Mutex::new(()); + +/// Native user file snapshot; paths are display/write guards, not arbitrary inputs. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InstructionFile { + pub agent_id: String, + pub path: PathBuf, + pub content: String, + pub exists: bool, + /// Digest includes absence, so creating an empty file invalidates an old draft. + pub revision: String, +} + +/// A save must match both the selected native file and its previously read bytes. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveRequest { + pub agent_id: String, + pub content: String, + pub expected_path: PathBuf, + pub expected_revision: String, +} + +/// Stable instruction-specific reason, carried in the Core envelope's details. +#[derive(Debug)] +pub struct InstructionError { + pub reason: &'static str, + pub message: &'static str, +} + +fn error(reason: &'static str, message: &'static str) -> InstructionError { + InstructionError { reason, message } +} + +fn io_error(_: std::io::Error) -> InstructionError { + error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + ) +} + +/// Resolve the same environment-based profile inherited by native Agent launches. +pub fn read(agent_id: &str) -> Result { + read_in(agent_id, &profile(agent_id)?) +} + +/// Save user text without modifying repository instructions or Agent configuration. +pub fn save(request: SaveRequest) -> Result { + let directory = profile(&request.agent_id)?; + save_in(request, &directory) +} + +// Resolution stays outside Core and never derives a destination from packaged resources. +fn profile(agent_id: &str) -> Result { + let (variable, fallback) = match agent_id { + "codex-acp" => ("CODEX_HOME", ".codex"), + "claude-acp" => ("CLAUDE_CONFIG_DIR", ".claude"), + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + resolve_profile( + std::env::var_os(variable).map(PathBuf::from), + user_home().map(|home| home.join(fallback)), + ) +} + +fn user_home() -> Option { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_profile( + configured: Option, + fallback: Option, +) -> Result { + let path = configured + .filter(|path| !path.as_os_str().is_empty()) + .or(fallback) + .ok_or_else(|| error("profile_missing", "Cannot locate the Agent user directory."))?; + if !path.is_absolute() { + return Err(error("profile_relative", "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.")); + } + Ok(path) +} + +fn read_in(agent_id: &str, directory: &Path) -> Result { + let filename = match agent_id { + "codex-acp" => { + let override_file = snapshot(agent_id, directory.join("AGENTS.override.md"))?; + if !override_file.content.trim().is_empty() { + return Ok(override_file); + } + "AGENTS.md" + } + "claude-acp" => "CLAUDE.md", + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + snapshot(agent_id, directory.join(filename)) +} + +fn snapshot(agent_id: &str, path: PathBuf) -> Result { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(error)), + }; + if metadata + .as_ref() + .is_some_and(|metadata| !metadata.file_type().is_file()) + { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + let mut bytes = Vec::new(); + if metadata.is_some() { + open_regular(&path)? + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(io_error)?; + } + if bytes.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let content = String::from_utf8(bytes).map_err(|_| { + error( + "invalid_utf8", + "The global instruction file must use UTF-8 encoding.", + ) + })?; + let exists = metadata.is_some(); + let revision = format!( + "{:x}", + Sha256::digest([&[u8::from(exists)][..], content.as_bytes()].concat()) + ); + Ok(InstructionFile { + agent_id: agent_id.into(), + path, + content, + exists, + revision, + }) +} + +fn open_regular(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a replacement symlink or block on a replacement FIFO. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options.open(path).map_err(io_error)?; + let kind = file.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + Ok(file) +} + +fn save_in(request: SaveRequest, directory: &Path) -> Result { + let _guard = SAVE_LOCK + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + if request.content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let previous = read_in(&request.agent_id, directory)?; + check_revision(&previous, &request)?; + let permissions = if previous.exists { + let permissions = fs::metadata(&previous.path) + .map_err(io_error)? + .permissions(); + if permissions.readonly() { + return Err(error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + )); + } + Some(permissions) + } else { + None + }; + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories.create(directory).map_err(io_error)?; + let parent = fs::canonicalize(directory).map_err(io_error)?; + let stage_path = parent.join(format!(".lithe-instructions-{}.tmp", uuid::Uuid::new_v4())); + let stage = StagedFile(stage_path.clone()); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&stage_path).map_err(io_error)?; + file.write_all(request.content.as_bytes()) + .map_err(io_error)?; + if let Some(permissions) = permissions { + file.set_permissions(permissions).map_err(io_error)?; + } + file.sync_all().map_err(io_error)?; + drop(file); + // Detect edits/profile precedence changes made while preparing the write. + // Other applications do not share our lock, so this is an optimistic guard. + check_revision(&read_in(&request.agent_id, directory)?, &request)?; + if fs::canonicalize(directory).map_err(io_error)? != parent { + return Err(error( + "file_changed", + "The instruction directory changed. Reload before saving.", + )); + } + let target = parent.join( + previous + .path + .file_name() + .expect("native instruction filename"), + ); + fs::rename(&stage.0, target).map_err(io_error)?; + // Clearing an override can make AGENTS.md effective again. Return that actual source. + read_in(&request.agent_id, directory) +} + +fn check_revision(file: &InstructionFile, request: &SaveRequest) -> Result<(), InstructionError> { + if file.path != request.expected_path || file.revision != request.expected_revision { + return Err(error("file_changed", "Global instructions changed outside this editor. Your draft is kept; reload before saving.")); + } + Ok(()) +} + +struct StagedFile(PathBuf); +impl Drop for StagedFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/tests.rs b/rust/lithe-agent-host/src/instructions/tests.rs new file mode 100644 index 0000000000..dc8b00b562 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/tests.rs @@ -0,0 +1,203 @@ +use super::*; + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + Self(std::env::temp_dir().join(format!("lithe-instructions-{}", uuid::Uuid::new_v4()))) + } + fn write(&self, name: &str, bytes: &[u8]) { + fs::create_dir_all(&self.0).unwrap(); + fs::write(self.0.join(name), bytes).unwrap(); + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(file: &InstructionFile, content: &str) -> SaveRequest { + SaveRequest { + agent_id: file.agent_id.clone(), + content: content.into(), + expected_path: file.path.clone(), + expected_revision: file.revision.clone(), + } +} + +#[test] +fn native_sources_follow_override_and_agent_boundaries_without_creating_files() { + let directory = Directory::new(); + let empty = read_in("codex-acp", &directory.0).unwrap(); + assert!(!empty.exists); + assert!(!directory.0.exists(), "opening settings is read only"); + directory.write("AGENTS.md", "base 中文\n".as_bytes()); + directory.write("CLAUDE.md", b"claude"); + directory.write("AGENTS.override.md", b" \n"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "base 中文\n" + ); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "override" + ); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap().content, + "claude" + ); + assert_eq!( + read_in("custom", &directory.0).unwrap_err().reason, + "unsupported_agent" + ); +} + +#[test] +fn save_roundtrip_guards_external_edits_and_native_precedence() { + // All saves in this case share the native save lock; no environment mutation. + let directory = Directory::new(); + let missing = read_in("codex-acp", &directory.0).unwrap(); + let saved = save_in(request(&missing, "中文\r\nexact bytes\n"), &directory.0).unwrap(); + assert_eq!(saved.content, "中文\r\nexact bytes\n"); + assert!(saved.exists); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&saved.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&directory.0).unwrap().permissions().mode() & 0o777, + 0o700 + ); + fs::set_permissions(&saved.path, fs::Permissions::from_mode(0o640)).unwrap(); + } + let updated = save_in(request(&saved, "base"), &directory.0).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&updated.path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + } + let original_permissions = fs::metadata(&updated.path).unwrap().permissions(); + let mut readonly = original_permissions.clone(); + readonly.set_readonly(true); + fs::set_permissions(&updated.path, readonly).unwrap(); + assert_eq!( + save_in(request(&updated, "read only overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_access" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "base"); + fs::set_permissions(&updated.path, original_permissions).unwrap(); + directory.write("AGENTS.md", b"external edit"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "external edit"); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + let active = read_in("codex-acp", &directory.0).unwrap(); + let effective = save_in(request(&active, ""), &directory.0).unwrap(); + assert!(effective.path.ends_with("AGENTS.md")); + assert_eq!(effective.content, "external edit"); + let cleared = save_in(request(&effective, ""), &directory.0).unwrap(); + assert_eq!(cleared.content, ""); + let claude = read_in("claude-acp", &directory.0).unwrap(); + let claude = save_in(request(&claude, "Claude only"), &directory.0).unwrap(); + assert_eq!(claude.content, "Claude only"); + assert_eq!(read_in("codex-acp", &directory.0).unwrap().content, ""); + let mut wrong_path = request(&claude, "bad"); + wrong_path.expected_path = directory.0.join("arbitrary.md"); + assert_eq!( + save_in(wrong_path, &directory.0).unwrap_err().reason, + "file_changed" + ); + let oversized = "x".repeat(MAX_BYTES + 1); + assert_eq!( + save_in(request(&claude, &oversized), &directory.0) + .unwrap_err() + .reason, + "file_too_large" + ); + assert!(!fs::read_dir(&directory.0).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".lithe-"))); +} + +#[test] +fn profile_resolution_respects_explicit_configuration_and_rejects_relative_paths() { + let directory = Directory::new(); + assert_eq!( + resolve_profile(Some(directory.0.clone()), None).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(None, Some(directory.0.clone())).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(Some(PathBuf::from("relative")), Some(directory.0.clone())) + .unwrap_err() + .reason, + "profile_relative" + ); + assert_eq!( + resolve_profile(None, None).unwrap_err().reason, + "profile_missing" + ); +} + +#[test] +fn rejects_non_text_and_non_regular_files_and_distinguishes_absence() { + let directory = Directory::new(); + let absent = read_in("claude-acp", &directory.0).unwrap(); + directory.write("CLAUDE.md", b""); + assert_ne!( + read_in("claude-acp", &directory.0).unwrap().revision, + absent.revision + ); + directory.write("CLAUDE.md", &[0xff]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "invalid_utf8" + ); + directory.write("CLAUDE.md", &vec![b'x'; MAX_BYTES + 1]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "file_too_large" + ); + fs::remove_file(directory.0.join("CLAUDE.md")).unwrap(); + fs::create_dir(directory.0.join("CLAUDE.md")).unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + #[cfg(unix)] + { + fs::remove_dir(directory.0.join("CLAUDE.md")).unwrap(); + directory.write("target.md", b"keep"); + std::os::unix::fs::symlink(directory.0.join("target.md"), directory.0.join("CLAUDE.md")) + .unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + assert_eq!(fs::read(directory.0.join("target.md")).unwrap(), b"keep"); + } +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index e30319d5b0..17bace8588 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -13,6 +13,7 @@ mod codex_history; mod codex_retry; pub mod environment; pub mod install; +pub mod instructions; mod model_calls; mod model_proxy; mod prompt; diff --git a/rust/lithe-core/src/agent/instructions.rs b/rust/lithe-core/src/agent/instructions.rs new file mode 100644 index 0000000000..31a8587af5 --- /dev/null +++ b/rust/lithe-core/src/agent/instructions.rs @@ -0,0 +1,40 @@ +//! Typed global-instruction requests; native file ownership stays in Agent Host. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_agent_host::instructions::{self, InstructionError, SaveRequest}; +use serde::Deserialize; +use serde_json::Value; + +/// Selects a supported upstream Agent, never an arbitrary filesystem path. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadRequest { + agent_id: String, +} + +/// Reads the effective native global file without creating directories or files. +pub(crate) fn read(request: ReadRequest) -> Result { + cancellation::check()?; + encode(instructions::read(&request.agent_id)) +} + +/// Atomically saves against the snapshot the user edited; preserves native loading. +pub(crate) fn save(request: SaveRequest) -> Result { + cancellation::check()?; + encode(instructions::save(request)) +} + +fn encode( + result: Result, +) -> Result { + let file = result.map_err(|error| { + let code = match error.reason { + "unsupported_agent" => ErrorCode::NotSupported, + "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, + _ => ErrorCode::InvalidRequest, + }; + CoreError::new(code, error.message).with_details(error.reason) + })?; + serde_json::to_value(file) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs index 718a961f04..352cd8f854 100644 --- a/rust/lithe-core/src/agent/mod.rs +++ b/rust/lithe-core/src/agent/mod.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; +pub(crate) mod instructions; + use serde::Deserialize; use serde_json::Value; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 1a26ed7503..28af59ef50 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -38,6 +38,10 @@ pub enum CoreCommand { Ping, /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). AgentStatus, + /// Reads the effective native global instruction file (`agent.instructions.read`). + AgentInstructionsRead, + /// Saves a guarded native global instruction draft (`agent.instructions.save`). + AgentInstructionsSave, /// Lists native skills or creates/imports one explicitly (`agent.skills`). AgentSkills, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). @@ -413,6 +417,8 @@ impl CoreCommand { "core.ping" => Some(Self::Ping), "ideHost.control" => Some(Self::IdeHostControl), "agent.status" => Some(Self::AgentStatus), + "agent.instructions.read" => Some(Self::AgentInstructionsRead), + "agent.instructions.save" => Some(Self::AgentInstructionsSave), "agent.skills" => Some(Self::AgentSkills), "agent.install" => Some(Self::AgentInstall), "agent.uninstall" => Some(Self::AgentUninstall), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 01cf5daa60..153573fda7 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -139,6 +139,12 @@ fn execute(request: &str) -> CoreResponse { }), ), CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), + CoreCommand::AgentInstructionsRead => { + agent_response(id, parsed.payload, crate::agent::instructions::read) + } + CoreCommand::AgentInstructionsSave => { + agent_response(id, parsed.payload, crate::agent::instructions::save) + } CoreCommand::AgentSkills => agent_response(id, parsed.payload, crate::agent::skills), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index 155e3e952a..46688f81ba 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -1,6 +1,34 @@ use crate::execute_json; use serde_json::{json, Value}; +#[test] +fn global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/agent-instructions-v1.json" + )) + .unwrap(); + for key in ["readRequest", "saveRequest"] { + let command = fixture[key]["command"].as_str().unwrap(); + assert!(crate::protocol::CoreCommand::parse(command).is_some()); + let mut payload = fixture[key]["payload"].clone(); + payload["agentId"] = json!("unknown-agent"); + let response = run(command, payload); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "not_supported"); + assert_eq!(response["error"]["details"], "unsupported_agent"); + } + let response = run( + "agent.instructions.save", + json!({"agentId":"codex-acp","content":"no guard"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); + let response = run( + "agent.instructions.read", + json!({"agentId":"codex-acp","path":"/untrusted/arbitrary.md"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); +} + fn run(command: &str, payload: Value) -> Value { let request = json!({ "id": "agent-test", "command": command, "payload": payload }); serde_json::from_str(&execute_json(&request.to_string())).expect("core response JSON") diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index f7ea4a1edb..cb7781035b 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -20,6 +20,20 @@ verification scripts are the executable source of boundary checks. ## Feature Contracts +Global Agent instructions edit each upstream Agent's native user file, independently +of the current project and provider. Opening settings is read-only. Codex uses +`CODEX_HOME` (otherwise the user home `.codex`) and its first non-empty +`AGENTS.override.md` / `AGENTS.md`; Claude uses `CLAUDE_CONFIG_DIR` (otherwise +the user home `.claude`) and `CLAUDE.md`. The host resolves these locations; +UI supplies no arbitrary read/write destination. Absolute native paths in snapshots +are display and optimistic-write guards, not portable identifiers. A save checks +the snapshot's path and revision, replaces the file atomically, and reads the +effective source back. Failures retain the draft; reloading explicitly discards it +only after a successful read. Drafts stay separate by Agent. Updated instructions +load in a new native conversation; existing sessions are not rewritten or stopped. +Project instructions retain the upstream Agent's precedence rules. See +`fixtures/agent/agent-instructions-v1.json`. + Shared Monaco highlighting is owned by the current view/model pair. A queued callback for a closed or replaced model must finish without running against the new model or locking surviving documents. Primary, split and diff views share diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 07fc0a90d7..7ad3c13df5 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -683,6 +683,24 @@ and the 64 KiB limit still fail with redacted errors. Omitting this flag keeps the existing parser behavior; saving still requires all fields, a compatible protocol and explicit consent for HTTP. +`agent.instructions.read` takes `{agentId}` (`codex-acp` or `claude-acp`) and +returns `{agentId, path, content, exists, revision}`. It does not create files. +`agent.instructions.save` takes `{agentId, content, expectedPath, expectedRevision}`. +The host resolves the native user profile again and rejects a changed effective +path or byte revision before replacing the file. The result is a fresh effective +snapshot: clearing a Codex override may return the base `AGENTS.md` instead. +No prompt injection, account settings, repository files, or native history are +modified. Files must be regular UTF-8 files at most 1 MiB; terminal symlinks are +rejected. Read-only files cannot be overwritten. Existing file permissions are +preserved; Unix new files use `0600` and new directories use `0700`. +Save staging is a sibling temporary file removed on completion/failure. +Other Lithe saves are serialized by a non-waiting lock; external writers do not +share it, so revision checks are optimistic rather than an OS-wide transaction. +Errors use the existing envelope codes with stable `details` reasons including +`unsupported_agent`, `file_access`, `unsafe_file`, `file_changed`, `file_too_large`, +`invalid_utf8`, `profile_missing`, `profile_relative`, and `busy`. +The fixture is `shared/fixtures/agent/agent-instructions-v1.json`. + `agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in `/agents/`, using the Node.js and npm the user installed. Lithe never installs Node.js or npm; the agents' own command-line tools are installed only through `agent.installCli` on an explicit user action. `agent.status` diff --git a/shared/fixtures/agent/agent-instructions-v1.json b/shared/fixtures/agent/agent-instructions-v1.json new file mode 100644 index 0000000000..9a91a9049f --- /dev/null +++ b/shared/fixtures/agent/agent-instructions-v1.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.read", + "payload": { + "agentId": "codex-acp" + } + }, + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "# Working agreements\nUse concise replies.\n", + "exists": true, + "revision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + }, + "saveRequest": { + "command": "agent.instructions.save", + "payload": { + "agentId": "codex-acp", + "content": "# Updated\n", + "expectedPath": "/fixture/user/.codex/AGENTS.md", + "expectedRevision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + } + }, + "windowsFile": { + "agentId": "claude-acp", + "path": "C:\\Users\\fixture\\.claude\\CLAUDE.md", + "content": "Claude only", + "exists": true, + "revision": "6200025904539440f7dde4fa1a3a0ca731c61c31bc8f0d188acf741caf0bdd40" + }, + "conflict": { + "ok": false, + "error": { + "code": "invalid_request", + "message": "Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "details": "file_changed" + } + } +} diff --git a/shared/platform-feature-matrix/features/agent-global-instructions.json b/shared/platform-feature-matrix/features/agent-global-instructions.json new file mode 100644 index 0000000000..5949f004aa --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-global-instructions.json @@ -0,0 +1,33 @@ +{ + "id": "agent-global-instructions", + "area": "AI", + "group": "Agent 对话", + "capability": "全局指令:在 AI 设置中分别编辑 Codex、Claude 原生用户文件,显示有效来源、保留独立草稿,校验外部修改后原子保存并读取实际结果,支持显式重新加载与中英文检索", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift", + "macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift", + "macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift", + "macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift", + "rust/lithe-agent-host/src/instructions.rs", + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/instructions.rs", + "rust/lithe-core/src/agent/instructions.rs", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/agent/agent-instructions-v1.json" + ] + }, + "owner": "Agent", + "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。", + "notes": "复用上游原生指令发现,不额外拼接提示词,不自动重启会话或改写项目指令;新会话加载更新。只在用户保存时写原生用户目录,临时文件随保存清理,不写发行资源或新增工作树复用资源。" +} diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json index f54beb51a0..2f7e9a7bda 100644 --- a/shared/platform-feature-matrix/features/agent-skill-management.json +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -2,7 +2,7 @@ "id": "agent-skill-management", "area": "AI", "group": "Agent 对话", - "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈);操作菜单保留所属窗口焦点和开启开关的颜色", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表);操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", @@ -29,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;写入失败恢复已确认值并显示错误、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index bc8b48e9c6..733552e401 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2855,6 +2855,7 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", "http-body-util", "hyper", @@ -2865,6 +2866,8 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml_ng", + "sha2", "tokio", "tokio-util", "tungstenite", From 6088be975d4d34da7d80e21bf9a22c4efae4c7cb Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 10:37:56 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(agent):=20=E6=8A=80=E8=83=BD=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=BB=85=E9=94=81=E5=AE=9A=E5=BD=93=E5=89=8D=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=85=B6=E4=BB=96=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=8F=98=E7=81=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将待确认选择按点击顺序串行保存,其他技能保留原生启用外观和交互能力。覆盖同一 Agent 连续写入、单行失败回滚、队列继续、重复点击去重、项目切换及离页清理;同步 Agent Note 与平台矩阵。 --- .../2026-10-20-agent-skill-management.md | 4 +- .../Features/AgentSkillsFeatureModel.swift | 49 +++++--- .../Views/Agent/AgentSkillsSettingsView.swift | 2 +- .../AgentSkillsFeatureModelTests.swift | 119 ++++++++++++++++-- .../features/agent-skill-management.json | 4 +- 5 files changed, 147 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md index 081f5ce6aa..6bb60fc844 100644 --- a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -30,7 +30,7 @@ Claude 固定适配器把 SDK 的 `supportedCommands()` 转成 ACP 命令列表 ### 原生启停与配置作用范围 -纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行使用既有原生小开关。点击后立即显示用户选择的状态,后台写入并回读原生配置,不在开关旁增加加载转圈;保存期间禁止重复操作。用户选择暂存在功能模型,不能提前替换已确认的原生清单或显示“已保存”。确认成功后采用回读结果;失败恢复已确认状态并显示错误,提示刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会清除临时选择、取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 +纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行使用既有原生小开关。点击后立即显示用户选择的状态,后台写入并回读原生配置,不在开关旁增加加载转圈。仅等待确认的技能行禁止重复操作,其他技能保留原生开关的启用状态和颜色;点击其他行时立即显示其选择,并按点击顺序串行执行原生写入与回读,不能并发交换整份清单而覆盖较新的确认。单行失败回滚该行,后续已排队的操作继续执行,错误保留至下一批操作;项目切换或页面离开同时清除未执行队列。不要用页面级忙碌状态禁用全部开关,否则关闭一个技能会让所有仍开启的技能也变灰,误导用户以为它们一起被关闭。用户选择暂存在功能模型,不能提前替换已确认的原生清单或显示“已保存”。确认成功后采用回读结果;失败恢复已确认状态并显示错误,提示刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会清除临时选择、取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 Codex 使用官方 [skills/config/write](https://developers.openai.com/codex/app-server) 按 SKILL.md 路径设置 enabled。一次有界 App Server 交换先用所选工作区发现技能,拒绝未知路径,再写配置并强制读取列表验证最终状态。Codex 的此接口写入用户配置,因此项目技能的开关也影响所有使用相同路径的工作区;页面提示这一点,不将项目分类误称为独立项目开关。不直接重写 Codex 的 TOML 或插件文件。 @@ -73,7 +73,7 @@ Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windo - `./scripts/verify-platform-feature-matrix.sh` - `./scripts/verify-agent-notes.sh` -原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;开关测试分别阻塞两次原生写入,对比首次点击前、关闭保存中、关闭确认后、再次开启保存中和开启确认后的全部原生开关位置,确保提示变化不移动技能行。可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间开关立即关闭且不增加加载转圈。 +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;开关测试分别阻塞两次原生写入,对比首次点击前、关闭保存中、关闭确认后、再次开启保存中和开启确认后的全部原生开关位置,确保提示变化不移动技能行。可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间当前开关立即关闭且不增加加载转圈,其他开启行的原生状态、交互能力与像素外观不变;功能模型控制两次写入的释放次序,验证同一 Agent 的连续保存不丢确认、单项失败后队列继续、重复点击去重,以及切项目/退出时未执行请求不再启动。窗口失去焦点时保留 macOS 原生非活动外观,滑块位置和实际原生配置不随窗口焦点变化。 ## 适用范围 diff --git a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift index 51b5402a1e..b0a4ed4ab7 100644 --- a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift @@ -14,8 +14,9 @@ final class AgentSkillsFeatureModel: ObservableObject { } enum Phase: Equatable { case idle, loading, ready, failed } struct PendingEnablement { - let itemID: String + let item: Item let enabled: Bool + var itemID: String { item.id } } static let agents = ["codex-acp", "claude-acp"] @Published private(set) var phase: Phase = .idle @@ -25,7 +26,7 @@ final class AgentSkillsFeatureModel: ObservableObject { @Published private(set) var error: String? @Published private(set) var savedURL: URL? @Published private(set) var isWriting = false - @Published private(set) var pendingEnablement: PendingEnablement? + @Published private(set) var pendingEnablements: [PendingEnablement] = [] @Published private(set) var didChangeEnablement = false @Published var query = "" @Published var scope = "user" @@ -55,9 +56,13 @@ final class AgentSkillsFeatureModel: ObservableObject { var visibleItems: [Item] { matchingItems.filter { belongs($0, to: scope) } } /// Display the user's choice immediately without replacing confirmed native state. func displayedEnablement(for item: Item) -> Bool? { - if let pendingEnablement, pendingEnablement.itemID == item.id { return pendingEnablement.enabled } + if let pending = pendingEnablements.first(where: { $0.itemID == item.id }) { return pending.enabled } return item.skill.enabled } + func canChangeEnablement(for item: Item) -> Bool { + phase != .loading && (!isWriting || !pendingEnablements.isEmpty) + && !pendingEnablements.contains(where: { $0.itemID == item.id }) + } private func belongs(_ item: Item, to scope: String) -> Bool { scope == "repo" ? (context?.workspace != nil && item.skill.scope == "repo") : ["user", "system", "admin"].contains(item.skill.scope) } @@ -79,7 +84,7 @@ final class AgentSkillsFeatureModel: ObservableObject { guard !isWriting || context != next else { return } if context != next { writeTask?.cancel(); writeTask = nil; isWriting = false - pendingEnablement = nil; didChangeEnablement = false + pendingEnablements = []; didChangeEnablement = false listings = [:]; directories = [:]; discoveryErrors = [:]; savedURL = nil } context = next @@ -124,26 +129,34 @@ final class AgentSkillsFeatureModel: ObservableObject { /// Capture the project before awaiting; switching projects invalidates delayed results. func setEnabled(_ enabled: Bool, for item: Item, context expected: Context) { - guard !isWriting, phase != .loading, context == expected, + guard canChangeEnablement(for: item), context == expected, item.skill.enabled != nil, item.skill.enabled != enabled, listings[item.agentID]?.skills.contains(item.skill) == true else { return } - pendingEnablement = .init(itemID: item.id, enabled: enabled) + pendingEnablements.append(.init(item: item, enabled: enabled)) + guard !isWriting else { return } isWriting = true; error = nil; didChangeEnablement = false let token = UUID(); revision = token let service = service - let request = AgentSkillEnablement(agentID: item.agentID, workspace: expected.workspace, skill: item.skill, enabled: enabled) writeTask = Task { [weak self] in - do { - let listing = try await service.setSkillEnabled(request) - guard let self, !Task.isCancelled, self.revision == token else { return } - self.listings[item.agentID] = listing - self.discoveryErrors[item.agentID] = nil - self.pendingEnablement = nil; self.isWriting = false; self.didChangeEnablement = true; self.writeTask = nil - } catch { - guard let self, !Task.isCancelled, self.revision == token else { return } - self.error = error.localizedDescription - self.pendingEnablement = nil; self.isWriting = false; self.writeTask = nil + // Serialize native read/write/read exchanges while only locking the rows awaiting confirmation. + while let pending = self?.pendingEnablements.first { + guard !Task.isCancelled, self?.revision == token else { return } + let item = pending.item + let request = AgentSkillEnablement(agentID: item.agentID, workspace: expected.workspace, + skill: item.skill, enabled: pending.enabled) + do { + let listing = try await service.setSkillEnabled(request) + guard let self, !Task.isCancelled, self.revision == token else { return } + self.listings[item.agentID] = listing + self.discoveryErrors[item.agentID] = nil + } catch { + guard let self, !Task.isCancelled, self.revision == token else { return } + self.error = error.localizedDescription + } + self?.pendingEnablements.removeFirst() } + guard let self, !Task.isCancelled, self.revision == token else { return } + self.isWriting = false; self.didChangeEnablement = self.error == nil; self.writeTask = nil } } @@ -169,7 +182,7 @@ final class AgentSkillsFeatureModel: ObservableObject { func dismissResult() { savedURL = nil; error = nil; didChangeEnablement = false } func cancel() { revision = UUID(); writeTask?.cancel(); writeTask = nil; refreshTask?.cancel(); refreshTask = nil; isWriting = false - pendingEnablement = nil; didChangeEnablement = false + pendingEnablements = []; didChangeEnablement = false if phase == .loading { phase = .idle } } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift index 7e98ffab15..c8117d6779 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift @@ -144,7 +144,7 @@ struct AgentSkillsSettingsView: View { .help(item.agentID == "codex-acp" ? "Codex saves this setting by path for all projects using this skill." : "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.") - .disabled(busy) + .disabled(!feature.canChangeEnablement(for: item)) } else { Text("State unknown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } diff --git a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift index fa5b78308f..bf9e5cbefb 100644 --- a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift @@ -138,7 +138,7 @@ struct AgentSkillsFeatureModelTests { #expect(!feature.didChangeEnablement, "the immediate display is not a native confirmation") feature.setEnabled(false, for: item, context: context) #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) - #expect(feature.isWriting && feature.pendingEnablement?.itemID == item.id) + #expect(feature.isWriting && feature.pendingEnablements.first?.itemID == item.id) #expect(feature.visibleItems.first?.skill.enabled == true, "pending writes do not invent native state") await feature.refresh(context) #expect(feature.isWriting, "manual refresh cannot invalidate the write") @@ -147,7 +147,7 @@ struct AgentSkillsFeatureModelTests { service.releaseWrite() #expect(await awaitChange(on: feature) { feature.didChangeEnablement && !feature.isWriting }) #expect(feature.visibleItems.first?.skill.enabled == false) - #expect(feature.pendingEnablement == nil) + #expect(feature.pendingEnablements.isEmpty) #expect(service.toggleRequests.count == 1) } @@ -166,7 +166,7 @@ struct AgentSkillsFeatureModelTests { service.releaseWrite() #expect(await awaitChange(on: feature) { feature.error != nil && !feature.isWriting }) #expect(feature.visibleItems.first?.skill.enabled == true) - #expect(feature.displayedEnablement(for: item) == true && feature.pendingEnablement == nil) + #expect(feature.displayedEnablement(for: item) == true && feature.pendingEnablements.isEmpty) #expect(!feature.didChangeEnablement) service.unknownEnablement = true await feature.refresh(context) @@ -175,6 +175,88 @@ struct AgentSkillsFeatureModelTests { #expect(!feature.isWriting && service.toggleRequests.count == 1) } + @Test func otherRowsQueueTheirOwnChoiceWithoutOverwritingEarlierConfirmation() async throws { + let service = SkillsTestService() + service.additionalCodexSkill = true + let feature = AgentSkillsFeatureModel(service: service) + feature.agentFilter = "codex-acp" + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + service.holdWrite = true + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + #expect(!feature.canChangeEnablement(for: first) && feature.canChangeEnablement(for: second)) + #expect(feature.displayedEnablement(for: second) == true) + feature.setEnabled(false, for: second, context: context) + feature.setEnabled(false, for: second, context: context) + #expect(feature.pendingEnablements.count == 2 && service.toggleRequests.count == 1) + #expect(feature.displayedEnablement(for: second) == false) + + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 2 }) + #expect(feature.visibleItems.first?.skill.enabled == false) + #expect(feature.visibleItems.last?.skill.enabled == true, "the queued row still awaits native confirmation") + #expect(feature.canChangeEnablement(for: first) && !feature.canChangeEnablement(for: second)) + #expect(feature.isWriting && !feature.didChangeEnablement) + let confirmedFirst = try #require(feature.visibleItems.first) + feature.setEnabled(true, for: confirmedFirst, context: context) + #expect(feature.displayedEnablement(for: confirmedFirst) == true) + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 3 }) + #expect(feature.visibleItems.allSatisfy { $0.skill.enabled == false }) + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true && feature.visibleItems.last?.skill.enabled == false) + #expect(feature.pendingEnablements.isEmpty && feature.didChangeEnablement) + #expect(service.toggleRequests.map(\.source) == [first.skill.path, second.skill.path, first.skill.path]) + } + + @Test func aFailedRowRollsBackWhileTheNextQueuedRowCanStillSave() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + service.holdWrite = true; service.failToggle = true + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + feature.setEnabled(false, for: second, context: context) + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 2 }) + #expect(feature.error != nil && feature.visibleItems.first?.skill.enabled == true) + #expect(feature.displayedEnablement(for: first) == true && feature.displayedEnablement(for: second) == false) + service.failToggle = false + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true && feature.visibleItems.last?.skill.enabled == false) + #expect(feature.error != nil && !feature.didChangeEnablement, "a later success must not hide an earlier failure") + } + + @Test(arguments: [false, true]) + func leavingTheContextDropsQueuedWritesAndRejectsLateConfirmation(switchProject: Bool) async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + service.holdWrite = true + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + feature.setEnabled(false, for: second, context: context) + if switchProject { await feature.refresh(.init(workspace: service.secondProject)) } + else { feature.cancel() } + let selected = feature.listings + service.releaseWrite() + try #require(await awaitChange(on: service) { service.finishedToggles == 1 }) + #expect(service.toggleRequests.count == 1, "a queued write cannot start after leaving its project or page") + #expect(feature.listings == selected && !feature.didChangeEnablement) + #expect(feature.pendingEnablements.isEmpty && !feature.isWriting) + } + @Test func oldToggleCannotReplaceAnotherProjectsState() async throws { let service = SkillsTestService() let feature = AgentSkillsFeatureModel(service: service) @@ -190,7 +272,7 @@ struct AgentSkillsFeatureModelTests { service.releaseWrite() #expect(await awaitChange(on: service) { service.finishedToggles == 1 }) #expect(feature.listings == selected && !feature.didChangeEnablement) - #expect(feature.pendingEnablement == nil && !feature.isWriting) + #expect(feature.pendingEnablements.isEmpty && !feature.isWriting) } } @@ -199,6 +281,7 @@ private final class SkillsTestService: ObservableObject, AgentManagementService let firstProject = URL(fileURLWithPath: "/fixture/first") let secondProject = URL(fileURLWithPath: "/fixture/second") var failCodex = false + var additionalCodexSkill = false var holdFirstProject = false var holdWrite = false @Published var held: [String: CheckedContinuation] = [:] @@ -220,10 +303,16 @@ private final class SkillsTestService: ObservableObject, AgentManagementService } if failCodex && agentID == "codex-acp" { throw Failure() } let root = workspace?.path ?? "/fixture/global" - return .init(skills: [ + var skills: [AgentSkill] = [ .init(name: agentID + "-global", description: "Global test", path: root + "/user/" + agentID + "/SKILL.md", scope: "user", enabled: unknownEnablement ? nil : (enablements[root + "/user/" + agentID + "/SKILL.md"] ?? true)), .init(name: agentID + "-project", description: "Project test", path: root + "/project/" + agentID + "/SKILL.md", scope: "repo", enabled: unknownEnablement ? nil : (enablements[root + "/project/" + agentID + "/SKILL.md"] ?? true)) - ], source: agentID == "codex-acp" ? "agent" : "directory") + ] + if additionalCodexSkill && agentID == "codex-acp" { + let path = root + "/user/codex-secondary/SKILL.md" + skills.append(.init(name: "codex-secondary", description: "Another skill in the same native settings", + path: path, scope: "user", enabled: enablements[path] ?? true)) + } + return .init(skills: skills, source: agentID == "codex-acp" ? "agent" : "directory") } func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { ["user": "/fixture/user/skills", "repo": (workspace?.path ?? "") + "/skills"] @@ -247,7 +336,10 @@ private final class SkillsTestService: ObservableObject, AgentManagementService return "---\nname: example\ndescription: A skill for testing\n---\n\n# Workflow\n\n1. Inspect the project.\n2. Explain the result." } func releaseLists() { let gates = held.values; held = [:]; for gate in gates { gate.resume() } } - func releaseWrite() { holdWrite = false; writeGate?.resume(); writeGate = nil } + func releaseWrite(holdingNext: Bool = false) { + holdWrite = holdingNext + let gate = writeGate; writeGate = nil; gate?.resume() + } func status(dataDirectory: URL) async throws -> AgentManagementStatus { throw Failure() } func install(agentID: String, dataDirectory: URL) async throws -> String { throw Failure() } func uninstall(agentID: String, dataDirectory: URL) async throws { throw Failure() } @@ -360,8 +452,10 @@ struct AgentSkillsAppearanceTests { return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) } let control = try #require(switches(host).first) + let otherControl = try #require(switches(host).last) #expect(control.state == .on && control.isEnabled) let originalPixels = try pixels(control) + let otherPixels = try pixels(otherControl) try capture(host, name: "skills-menu-before") let before = feature.listings var copies = 0 @@ -382,6 +476,13 @@ struct AgentSkillsAppearanceTests { } #expect(copies == 1, "arrow and Return navigation still execute the action once") #expect(window.childWindows?.contains(where: { $0.isVisible }) != true) + window.resignKey() + layout(host) + #expect(control.state == .on && otherControl.state == .on, "inactive window styling does not turn skills off") + #expect(feature.listings == before && service.toggleRequests.isEmpty) + window.makeKey() + layout(host) + #expect(try pixels(otherControl) == otherPixels, "reactivation restores the original native appearance") service.holdWrite = true let item = try #require(feature.visibleItems.first) feature.setEnabled(false, for: item, context: .init(workspace: service.firstProject)) @@ -390,6 +491,8 @@ struct AgentSkillsAppearanceTests { let pendingControl = try #require(switches(host).first) #expect(pendingControl.state == .off, "the native switch reflects the choice before saving finishes") #expect(!pendingControl.isEnabled, "a real pending write must still disable the control") + #expect(otherControl.state == .on && otherControl.isEnabled, "saving one row must not disable another skill") + #expect(try pixels(otherControl) == otherPixels, "an unrelated enabled switch must keep its appearance") func progressIndicators(_ view: NSView) -> [NSProgressIndicator] { if let indicator = view as? NSProgressIndicator { return [indicator] } return view.subviews.flatMap(progressIndicators) @@ -482,7 +585,7 @@ struct AgentSkillsAppearanceTests { while clock.now < deadline { layout(host) controls = switches(host) - if controls.count == 2 && controls.first?.state == state && controls.allSatisfy({ $0.isEnabled == enabled }) { return controls } + if controls.count == 2 && controls.first?.state == state && controls.first?.isEnabled == enabled && controls.dropFirst().allSatisfy({ $0.isEnabled }) { return controls } await Task.yield() } Issue.record("Native skill switches did not render the expected state before the local deadline") diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json index 2f7e9a7bda..f5a6eb8cd8 100644 --- a/shared/platform-feature-matrix/features/agent-skill-management.json +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -2,7 +2,7 @@ "id": "agent-skill-management", "area": "AI", "group": "Agent 对话", - "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表);操作菜单保留所属窗口焦点和开启开关的颜色", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表、仅待确认行禁用、其他行保持颜色并按序保存);操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", @@ -29,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、忙碌禁止重复提交、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" } From d414e5600558fcaa27dff9008e72a34be288515b Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 21:01:54 +0800 Subject: [PATCH 05/16] feat(agent): add shared instruction cards and native synchronization --- .../2026-10-21-agent-global-instructions.md | 41 +- docs/ci-builds.md | 6 + macos/Resources/en.lproj/Localizable.strings | 23 + .../zh-Hans.lproj/Localizable.strings | 23 + .../AgentInstructionsFeatureModel.swift | 124 +++++- .../Rust/RustAgentInstructionsService.swift | 43 +- .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../Agent/AgentInstructionsSettingsView.swift | 274 +++++++++--- .../Agent/AgentInstructions.swift | 30 ++ .../AgentInstructionsAppearanceTests.swift | 21 +- .../SharedInstructionsFeatureTests.swift | 139 ++++++ rust/lithe-agent-host/src/instructions.rs | 51 ++- .../src/instructions/shared.rs | 416 ++++++++++++++++++ .../src/instructions/shared/tests.rs | 228 ++++++++++ rust/lithe-core/src/agent/instructions.rs | 27 +- rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/runtime/dispatcher.rs | 3 + rust/lithe-core/src/tests/agent.rs | 15 + scripts/reuse-worktree-resources.mjs | 1 + scripts/test-reuse-worktree-resources.mjs | 2 +- scripts/worktree-resources.json | 12 + shared/contracts/application-boundary.md | 11 + shared/contracts/rust-core-api.md | 27 ++ .../agent/shared-instructions-v1.json | 43 ++ .../features/agent-global-instructions.json | 15 +- 25 files changed, 1479 insertions(+), 102 deletions(-) create mode 100644 macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift create mode 100644 rust/lithe-agent-host/src/instructions/shared.rs create mode 100644 rust/lithe-agent-host/src/instructions/shared/tests.rs create mode 100644 shared/fixtures/agent/shared-instructions-v1.json diff --git a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md index 79f5ad744a..75dfa185e6 100644 --- a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md +++ b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md @@ -4,7 +4,7 @@ ## 先说结论 -macOS 设置的「AI → 指令」可以编辑 Codex、Claude Code 各自的全局指令。保存直接更新原生用户文件,新对话由上游 Agent 加载;Lithe 不额外拼接一份指令,也不改项目规则。页面显示实际路径,保存冲突或失败时保留草稿。 +macOS 设置的「AI → 指令」可以编辑 Codex、Claude Code 各自的全局指令。页面同时支持一份共享正文:选择 Agent 后,只把它同步到原生文件中的 Lithe 标记区块,保留用户自有内容。新对话由上游 Agent 加载;Lithe 不额外拼接提示词,也不改项目规则。页面显示实际路径,保存冲突或失败时保留草稿。 ## 问题 @@ -12,22 +12,50 @@ Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口 ## 决策 -- 页面沿用设置分组、搜索、共享选择器和原生多行编辑框。Codex、Claude 分别保留内存草稿,切换页面或 Agent 不自动保存。明确选择重新加载并确认放弃草稿后,成功读取才替换草稿。 +- 页面参考 Magpie 的紧凑卡片和 Agent 行:共享正文默认收起,Agent 行显示品牌图标、实际路径、自有非空行数与共享开关,点击展开完整原生编辑器。说明放在 ⓘ,不把长段落放到页面。使用现有设置卡片和原生多行编辑框。Codex、Claude 分别保留内存草稿,切换页面或 Agent 不自动保存。明确选择重新加载并确认放弃草稿后,成功读取才替换草稿。 - 全局指令和技能管理的入口、功能模型、Core 命令分别保留,在同一 AI 分组中并列可用;预览构建必须包含两个功能后再交给用户。全局指令编辑器不负责加载、启停或复制技能。 - 原生文件路径由 Agent Host(负责原生文件和进程的 Rust 适配层)解析,使用 Agent 启动时继承的 `CODEX_HOME`、`CLAUDE_CONFIG_DIR` 或平台用户目录。Codex 选择首个非空的 `AGENTS.override.md` / `AGENTS.md`,Claude 选择 `CLAUDE.md`;不支持的自定义 Agent 不猜路径。 - Core 只分派带类型的请求和稳定错误;Swift 应用模型管理加载、保存、失败和草稿,视图不直接访问文件。原生路径仅作显示和保存校验,不成为跨平台持久标识。 - 保存比较原文件的路径与摘要(字节内容的 SHA-256 标识),防止覆盖读取后发生的外部修改。临时文件在目标目录内创建,写完同步后原子替换;保留已有权限,拒绝覆盖只读文件,Unix 新文件为 `0600`、新目录为 `0700`。同进程保存使用不等待的锁;其他程序不共享锁,检查与替换之间仍存在很小的外部竞争窗口,不声称提供系统级事务。 - 清空 override 后重新按原生规则读取,页面可能回到基础 `AGENTS.md`。正确做法是展示这个实际生效的来源;不要保留一个已被原生跳过的空 override 并声称已清空全部指令。 - 文件只接受至多 1 MiB 的 UTF-8 普通文件,符号链接拒绝编辑。限制用于保护设置页资源;Codex 自身的上下文截断预算仍由原生配置决定,Lithe 不修改它。 -- 仅用户点击保存时写用户配置目录;不写发行 bundle、安装目录、代码签名资源或 Sparkle 增量更新基线。不新增下载、缓存或可跨工作树复用的生成资源,因此不注册到工作树资源清单。临时文件由单次保存拥有,成功和失败都清理,异常退出遗留只是一份随机命名临时文件,不会被 Agent 加载。 +- 仅用户点击保存时写用户配置目录;不写发行 bundle、安装目录、代码签名资源或 Sparkle 增量更新基线。不新增下载、缓存或可跨工作树复用的生成资源,共享正文及同步锁作为用户状态登记到工作树资源排除清单。临时文件由单次保存拥有,成功和失败都清理,异常退出遗留只是一份随机命名临时文件,不会被 Agent 加载。 + +### 共享正文与原生区块 + +用户明确要求共享同步后,增加一份 `Shared.md` 作为已保存共享正文的来源。macOS +组合根通过 `FileStorage.applicationSupportDirectory()` 提供 `Lithe/AgentInstructions/`, +Agent Host 负责原子文件操作,Core 负责严格请求分派,功能模型负责草稿、排队和逐项反馈。 +这是用户配置,不是缓存,不存在版本/平台/架构/工具链身份,也不能跨工作树复制。 +`Shared.md` 和 `.sync.lock` 随用户资料保留;同目录暂存由单次保存拥有,完成或失败清理, +异常退出可能遗留随机命名暂存但 Agent 不读取。目录不从 bundle 或安装目录推导, +因此不影响代码签名与 Sparkle delta。资源复用脚本显式拒绝该用户状态。 + +同步不替换整份规则,而是插入/替换 `LITHE:SHARED-INSTRUCTIONS:BEGIN/END` 注释之间的正文。 +开关由实际文件区块推导,关闭只移除拥有的区块和它自己的换行,原始字节保持。 +非空 Codex override 仍是有效目标;不为了共享功能绕过上游优先级。正文中不能包含标记, +重复、损坏或不完整标记使开关成为未知并提示修复,不猜测删除范围。 + +共享源先保存,再依次更新已开启 Agent。每个文件单独原子替换;这不是多文件事务, +一项权限错误或摘要冲突不能回滚其他已完成项或丢掉共享正文。失败行保留原因, +实际区块与源不同显示待同步,用户显式重试。多个开关按点击顺序串行处理, +只锁定待处理行,其他行不因全局禁用变灰;取消拥有队列的页面任务后不启动后续写入。 +重新进入页面只回读实际状态,不自动同步,也不丢弃未保存草稿。 + +正确做法:用户在 Codex 文件写了自有规则,开启共享后两者并存;关闭后自有规则逐字节保留。 +不要把关闭共享解释成关闭 Codex 或删除其整份 AGENTS.md,也不要在启动时自动覆盖外部修改。 + +布局与行为核对 [Magpie 指令页](https://github.com/yetone/magpie/blob/ccbc7278180e513bf95461c62eeb15729fc7cae7/internal/gui/assets/plugins/library/client.js#L1173) +的共享卡片、Agent 行和区块保留方式。本次独立实现,只支持 Lithe 已有原生文件能力的 Codex、Claude Code, +一份共享正文,不引入截图中其他 Agent、多个命名方案、导入私有规则或文件链接。 交互结构参考 [PI-Desktop 的全局指令页](https://github.com/vastsa/PI-Desktop/blob/a6f4ba121e524a167c42466c7b4debbb3cfd0744/apps/desktop/src/features/settings/agent-sections.tsx):路径、文本编辑和显式保存。没有复制其源码或视觉样式。原生规则依据 [Codex AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 和 [Claude Code memory](https://code.claude.com/docs/en/memory)。 ## 考虑过的备选方案 -### 所有 Agent 共用一个 Lithe 指令副本 +### 把共享正文直接注入每次请求 -这需要维护额外的注入、去重和历史恢复语义,也会和用户 CLI 的全局文件产生两份来源。采用各自的原生文件,代价是用户希望同一规则用于两种 Agent 时需分别保存。 +会和原生文件加载产生两份提示词,并需要维护上下文去重和历史恢复语义。因此共享正文通过显式区块同步落到原生用户文件,由上游加载;不修改 Agent 的请求或注入链路。 ### 保存时停止并重连所有项目 @@ -40,6 +68,8 @@ Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口 ## 验证 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 +- `SharedInstructionsFeatureTests` 覆盖共享草稿、切换排队、逐项失败与页面取消;Host shared 测试用临时目录验证真实读写、关闭字节还原、override、损坏标记和跨文件部分成功。 +- 典型共享流程前后比较 app bundle 文件清单与哈希,用户目录不参与发行资源写入;`node --test scripts/test-reuse-worktree-resources.mjs` 确认共享指令用户状态不能复制。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentInstructionsFeatureModelTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests'`。 - Agent Host 计时测试覆盖原生优先级、读取不创建文件、保存和清空、外部修改与路径变化、UTF-8/大小限制、符号链接拒绝、权限保留和临时文件清理。 - Core 的 `global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access` 验证共享 fixture 与严格请求边界。 @@ -49,6 +79,7 @@ Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口 ## 适用范围 - `rust/lithe-agent-host/src/instructions.rs` +- `rust/lithe-agent-host/src/instructions/shared.rs` - `rust/lithe-core/src/agent/instructions.rs` - `macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift` - `macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 200dd4a9df..2d26cfde4c 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -357,6 +357,12 @@ Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先 ### 独立工作树的本地编译 +共享指令的 `Lithe/AgentInstructions/Shared.md`、`.sync.lock` 和同目录原子保存暂存文件登记为 +`agent-instruction-library-state`,由平台 storage adapter 的 Application Support 根目录提供。 +它们是可变用户配置,不具有版本/平台/架构/工具链身份;所有工作树复用阶段均拒绝复制, +不从其他工作树或发行包导入。原生 Agent 指令文件同样保留用户归属;暂存文件随保存完成或失败清理, +空锁文件属于用户资料的生命周期。运行时不写 app bundle、安装目录或 Sparkle delta 基线。 + MCP 发现页读取的官方 Registry 在线响应登记为 `mcp-registry-metadata`,仅在当前进程内存中保存。 响应最多 1 MiB,由 Rust Core 校验;按搜索、分页和取消请求管理生命周期,没有持久缓存、 平台/架构/工具链身份或允许复制的阶段。资源复用脚本明确拒绝该项,不下载服务器包或图片, diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 07629d6209..7980cd205e 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2254,3 +2254,26 @@ "Open pages, click, type and inspect a browser through its accessibility tree." = "Open pages, click, type and inspect a browser through its accessibility tree."; "Inspect Chrome pages, console messages, network requests and performance." = "Inspect Chrome pages, console messages, network requests and performance."; "Search official Microsoft and Azure documentation and code samples." = "Search official Microsoft and Azure documentation and code samples."; + +/* Shared instruction library */ +"Shared instructions" = "Shared instructions"; +"Default instructions" = "Default instructions"; +"Empty" = "Empty"; +"Save shared instructions" = "Save shared instructions"; +"Shared instructions saved." = "Shared instructions saved."; +"Use shared instructions" = "Use shared instructions"; +"Sync needed" = "Sync needed"; +"Sync again" = "Sync again"; +"%d lines of its own" = "%d lines of its own"; +"Native instruction file" = "Native instruction file"; +"Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply." = "Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply."; +"The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; +"This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents." = "This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents."; +"The shared instruction directory must be absolute." = "The shared instruction directory must be absolute."; +"Shared instructions changed outside this editor. Reload before saving." = "Shared instructions changed outside this editor. Reload before saving."; +"Invalid shared instruction request." = "Invalid shared instruction request."; +"Instruction synchronization was cancelled." = "Instruction synchronization was cancelled."; +"Reload this Agent before synchronizing instructions." = "Reload this Agent before synchronizing instructions."; +"The Lithe shared instruction markers were changed. Repair the native file before syncing." = "The Lithe shared instruction markers were changed. Repair the native file before syncing."; +"Shared instructions cannot contain Lithe synchronization markers." = "Shared instructions cannot contain Lithe synchronization markers."; +"Instruction Agents" = "Agents"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 4d6d7d625d..bef7c34262 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3829,3 +3829,26 @@ "Open pages, click, type and inspect a browser through its accessibility tree." = "打开网页、点击、输入,并通过无障碍树读取浏览器页面。"; "Inspect Chrome pages, console messages, network requests and performance." = "检查 Chrome 页面、控制台消息、网络请求和性能。"; "Search official Microsoft and Azure documentation and code samples." = "搜索微软与 Azure 的官方文档和代码示例。"; + +/* Shared instruction library */ +"Shared instructions" = "共享指令"; +"Default instructions" = "默认指令"; +"Empty" = "空"; +"Save shared instructions" = "保存共享指令"; +"Shared instructions saved." = "共享指令已保存。"; +"Use shared instructions" = "使用共享指令"; +"Sync needed" = "待同步"; +"Sync again" = "重新同步"; +"%d lines of its own" = "%d 行自有内容"; +"Native instruction file" = "原生指令文件"; +"Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply." = "共享指令只需写一次,再为各 Agent 开启。Lithe 只修改标记区块,保留原生文件的其他内容。新建对话后加载更新,项目指令仍然生效。"; +"The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; +"This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents." = "此处显示包含 Lithe 共享区块在内的完整原生文件。在此修改共享部分可能导致不同步;请在共享编辑器中更新所有已开启的 Agent。"; +"The shared instruction directory must be absolute." = "共享指令目录必须为绝对路径。"; +"Shared instructions changed outside this editor. Reload before saving." = "共享指令已被其他程序修改,请重新加载后再保存。"; +"Invalid shared instruction request." = "共享指令请求无效。"; +"Instruction synchronization was cancelled." = "指令同步已取消。"; +"Reload this Agent before synchronizing instructions." = "请重新加载此 Agent 的指令后再同步。"; +"The Lithe shared instruction markers were changed. Repair the native file before syncing." = "Lithe 共享指令标记已被修改,请先修复原生文件再同步。"; +"Shared instructions cannot contain Lithe synchronization markers." = "共享指令正文不能包含 Lithe 同步标记。"; +"Instruction Agents" = "Agent"; diff --git a/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift index 910b130272..5d17809516 100644 --- a/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift @@ -7,10 +7,19 @@ final class AgentInstructionsFeatureModel: ObservableObject { enum Phase: Equatable { case idle, loading, ready, saving, failed(String) } @Published var selectedAgent: AgentInstructionKind = .codex + @Published private(set) var isEditorExpanded = false @Published private(set) var files: [AgentInstructionKind: AgentInstructionFile] = [:] @Published private(set) var drafts: [AgentInstructionKind: String] = [:] @Published private(set) var phases: [AgentInstructionKind: Phase] = [:] @Published private(set) var saved: Set = [] + @Published private(set) var sharedLibrary: SharedInstructionLibrary? + @Published private(set) var sharedDraft = "" + @Published private(set) var sharedPhase: Phase = .idle + @Published private(set) var sharedSaved = false + @Published private(set) var pendingSharedAgents: Set = [] + @Published private(set) var sharedSyncErrors: [AgentInstructionKind: String] = [:] + private var sharedSwitches: [(AgentInstructionKind, Bool)] = [] + private var drainsSharedSwitches = false private let service: any AgentInstructionsService init(service: any AgentInstructionsService) { self.service = service } @@ -20,18 +29,121 @@ final class AgentInstructionsFeatureModel: ObservableObject { var phase: Phase { phases[selectedAgent] ?? .idle } var isBusy: Bool { phase == .loading || phase == .saving } var isDirty: Bool { file.map { draft != $0.content } ?? false } - var canSave: Bool { isDirty && !isBusy && draft.utf8.count <= 1024 * 1024 } + var canSave: Bool { isDirty && !isBusy && !isSharedBusy && draft.utf8.count <= 1024 * 1024 } + + var isSharedBusy: Bool { sharedPhase == .loading || sharedPhase == .saving } + var isSharedDirty: Bool { sharedLibrary.map { sharedDraft != $0.file.content } ?? false } + var canSaveShared: Bool { sharedLibrary != nil && isSharedDirty && !isSharedBusy + && !phases.values.contains(.saving) && sharedDraft.utf8.count <= 1024 * 1024 } + + func sharedState(_ agent: AgentInstructionKind) -> SharedInstructionAgent? { + sharedLibrary?.agents.first { $0.agentId == agent.rawValue } + } + + func canToggleShared(_ agent: AgentInstructionKind) -> Bool { + (!isSharedBusy || drainsSharedSwitches) && !pendingSharedAgents.contains(agent) + && !isSharedDirty && phases[agent] != .saving + && drafts[agent] == files[agent]?.content && sharedState(agent)?.enabled != nil + } + + func editShared(_ text: String) { + guard !isSharedBusy else { return } + sharedDraft = text + sharedSaved = false + } + + func loadShared(reload: Bool = false) async { + guard !isSharedBusy, !phases.values.contains(.saving) else { return } + let retainedFile = !reload && isSharedDirty ? sharedLibrary?.file : nil + sharedPhase = .loading + do { + let library = try await service.readShared() + // Navigation refreshes real switch state, but only explicit reload + // replaces an unsaved shared draft and its original conflict guard. + applyShared(retainedFile.map { SharedInstructionLibrary(file: $0, agents: library.agents) } ?? library) + if retainedFile == nil { sharedDraft = library.file.content } + sharedSaved = false + sharedSyncErrors.removeAll() + sharedPhase = .ready + } catch is CancellationError { sharedPhase = .idle } + catch { sharedPhase = .failed(error.localizedDescription) } + } + + func saveShared() async { + guard canSaveShared, let library = sharedLibrary else { return } + sharedPhase = .saving + do { + applyShared(try await service.saveShared(content: sharedDraft, library: library)) + sharedSaved = true + sharedPhase = .ready + } catch { sharedPhase = .failed(error.localizedDescription) } + } + + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind) async { + guard canToggleShared(agent), sharedLibrary != nil else { return } + pendingSharedAgents.insert(agent) + sharedSwitches.append((agent, enabled)) + guard !drainsSharedSwitches else { return } + drainsSharedSwitches = true + defer { + drainsSharedSwitches = false + pendingSharedAgents.removeAll() + sharedSwitches.removeAll() + } + // The first view-owned task drains the queue. Later rows stay interactive + // and use the preceding operation's fresh native/source revisions. + while !sharedSwitches.isEmpty, !Task.isCancelled { + let (target, value) = sharedSwitches.removeFirst() + guard let library = sharedLibrary else { break } + sharedPhase = .saving + do { + applyShared(try await service.setSharedEnabled(value, agent: target, library: library)) + sharedSyncErrors.removeValue(forKey: target) + } catch { sharedSyncErrors[target] = error.localizedDescription } + pendingSharedAgents.remove(target) + sharedPhase = .ready + } + } + + private func applyShared(_ library: SharedInstructionLibrary) { + sharedLibrary = library + for agent in AgentInstructionKind.allCases { + guard let updated = sharedState(agent)?.file else { continue } + // A shared update never overwrites an independent native-file draft. + guard files[agent] == nil || drafts[agent] == files[agent]?.content else { continue } + files[agent] = updated + drafts[agent] = updated.content + phases[agent] = .ready + } + } func edit(_ text: String) { - guard !isBusy else { return } + guard !isBusy, !isSharedBusy else { return } drafts[selectedAgent] = text saved.remove(selectedAgent) } + func toggleEditor(_ agent: AgentInstructionKind) { + isEditorExpanded = selectedAgent == agent ? !isEditorExpanded : true + selectedAgent = agent + } + + /// Listing both files is read-only. One unavailable Agent must not hide the other. + func loadAll() async { + for agent in AgentInstructionKind.allCases { + guard !Task.isCancelled else { return } + await load(agent: agent) + } + } + /// Initial loads retain cached drafts. Explicit reload replaces a draft only after success. func load(reload: Bool = false) async { - let agent = selectedAgent - guard !isBusy, reload || files[agent] == nil else { return } + await load(agent: selectedAgent, reload: reload) + } + + private func load(agent: AgentInstructionKind, reload: Bool = false) async { + let phase = phases[agent] ?? .idle + guard phase != .loading, phase != .saving, reload || files[agent] == nil else { return } phases[agent] = .loading do { let file = try await service.read(agent: agent) @@ -58,6 +170,10 @@ final class AgentInstructionsFeatureModel: ObservableObject { drafts[agent] = updated.content saved.insert(agent) phases[agent] = .ready + if sharedLibrary != nil { + do { applyShared(try await service.readShared()) } + catch { sharedPhase = .failed(error.localizedDescription) } + } } catch { phases[agent] = .failed(error.localizedDescription) } diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift index fe566467ba..0ec6d7302d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift +++ b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift @@ -3,6 +3,7 @@ import LitheCoreContracts struct RustAgentInstructionsService: AgentInstructionsService { let core: RustCoreBridge + var sharedDirectory: URL? = nil private struct Read: Encodable, Sendable { let agentId: String } private struct Save: Encodable, Sendable { let agentId: String @@ -20,7 +21,47 @@ struct RustAgentInstructionsService: AgentInstructionsService { expectedPath: file.path, expectedRevision: file.revision)) } - private func run(_ command: String, _ payload: Payload) async throws -> AgentInstructionFile { + private struct SharedGuard: Encodable, Sendable { + let agentId: String + let path: String + let revision: String + init(_ file: AgentInstructionFile) { agentId = file.agentId; path = file.path; revision = file.revision } + } + private struct SharedRequest: Encodable, Sendable { + let dataDirectory: String + let action: String + var content: String? = nil + var expectedRevision: String? = nil + var files: [SharedGuard]? = nil + var agentId: String? = nil + var enabled: Bool? = nil + var file: SharedGuard? = nil + } + + func readShared() async throws -> SharedInstructionLibrary { + try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "read")) + } + + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "save", + content: content, expectedRevision: library.file.revision, + files: library.agents.compactMap { $0.file.map(SharedGuard.init) })) + } + + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + guard let file = library.agents.first(where: { $0.agentId == agent.rawValue })?.file else { + throw CocoaError(.fileReadUnknown) + } + return try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "setEnabled", + expectedRevision: library.file.revision, agentId: agent.rawValue, enabled: enabled, file: SharedGuard(file))) + } + + private func directory() throws -> String { + guard let sharedDirectory else { throw CocoaError(.featureUnsupported) } + return sharedDirectory.path + } + + private func run(_ command: String, _ payload: Payload) async throws -> Result { let core = core let operationID = UUID().uuidString return try await withTaskCancellationHandler { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index eb7d65fc28..2021e4009a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -756,7 +756,8 @@ final class MacServiceContainer { diagnosticsExportService: diagnosticsExportService, credentialResolver: credentialResolver, agentManagement: RustAgentManagementService(core: rustCore), - agentInstructions: RustAgentInstructionsService(core: rustCore), + agentInstructions: RustAgentInstructionsService(core: rustCore, + sharedDirectory: fileStorage.applicationSupportDirectory().appendingPathComponent("Lithe/AgentInstructions", isDirectory: true)), agentProviderConfigurationParser: MacAgentProviderConfigurationParser(core: rustCore), aiConfigurationSources: aiConfigurationSources, recentProjectsStore: RecentProjectsStore(store: store), diff --git a/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift index 15a56162f9..9a2316f85a 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift @@ -1,97 +1,231 @@ import SwiftUI import LitheCoreContracts +/// A compact shared library followed by native Agent rows. Only explicit actions write. struct AgentInstructionsSettingsView: View { @Environment(\.locale) private var locale var localizationBundle: Bundle = .main @ObservedObject var feature: AgentInstructionsFeatureModel - @State private var operation: Task? + @State private var operations: [UUID: Task] = [:] + private enum Editor: Hashable { case shared, native } + @FocusState private var focusedEditor: Editor? + @State private var expandsShared = false @State private var confirmsReload = false + @State private var reloadsShared = false var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(key("Instructions")).font(LitheTheme.uiFont(size: 16, weight: .semibold)) - LitheSettingsSection(localized("Global instructions")) { - HStack { - LitheSettingsLabel(key("Agent"), info: key("Global instructions apply to all projects using this Agent. Each Agent keeps its own native file.")) - LitheSettingsSelect(selection: $feature.selectedAgent, - options: AgentInstructionKind.allCases, width: 220, - accessibilityLabel: localized("Instruction Agent"), title: { $0.title }, localizesTitles: false) - .disabled(feature.isBusy) + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 6) { + Text(key("Instructions")).font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheInfoTip(key("Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply.")) + Spacer(minLength: 0) + Button(localized("Refresh")) { + run { await feature.loadShared(); await feature.loadAll() } + }.buttonStyle(LitheSecondaryButtonStyle()) + .disabled(feature.isSharedBusy || feature.phases.values.contains(.saving)) + } + sharedCard + LitheSettingsSection(title: localized("Instruction Agents"), accessory: { + LitheInfoTip(key("The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.")) + }) { + ForEach(AgentInstructionKind.allCases) { agent in + agentRow(agent) + if feature.isEditorExpanded && feature.selectedAgent == agent { nativeEditor } } - if let file = feature.file { - Text(verbatim: file.path) - .font(LitheTheme.uiFont(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .textSelection(.enabled) - if file.path.hasSuffix("AGENTS.override.md") { - Text(key("Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.")) - .foregroundStyle(LitheTheme.secondaryText) - .fixedSize(horizontal: false, vertical: true) + } + } + .font(LitheTheme.settingsFont) + .frame(maxWidth: 860, alignment: .leading) + .task { + await feature.loadShared() + await feature.loadAll() + } + .onDisappear { operations.values.forEach { $0.cancel() }; operations.removeAll() } + .alert(localized("Discard draft and reload?"), isPresented: $confirmsReload) { + Button(localized("Cancel"), role: .cancel) {} + Button(localized("Reload from disk"), role: .destructive) { reload(shared: reloadsShared) } + } message: { Text(key("Reloading replaces this draft with the current file contents.")) } + } + + private var sharedCard: some View { + LitheSettingsCard { + HStack(spacing: 10) { + Image(systemName: "doc.text").foregroundStyle(LitheTheme.secondaryText) + Text(key("Shared instructions")).font(LitheTheme.uiFont(size: 13, weight: .semibold)) + Spacer(minLength: 0) + Text("Markdown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.padding(.vertical, 8) + Button { expandsShared.toggle() } label: { + HStack(spacing: 10) { + Image(systemName: "doc.plaintext").foregroundStyle(LitheTheme.accent) + VStack(alignment: .leading, spacing: 4) { + Text(key("Default instructions")).font(LitheTheme.uiFont(size: 13, weight: .medium)) + Text(verbatim: feature.sharedDraft.split(whereSeparator: \.isNewline).first.map(String.init) ?? localized("Empty")) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) } - if !file.exists { - Text(key("The file will be created when you save.")) - .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + if feature.isSharedDirty { badge("Unsaved changes", color: LitheTheme.warning) } + Image(systemName: expandsShared ? "chevron.down" : "chevron.right") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.padding(.vertical, 8).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("shared-instructions-expand") + if expandsShared { + VStack(alignment: .leading, spacing: 10) { + TextEditor(text: Binding(get: { feature.sharedDraft }, set: feature.editShared)) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)).autocorrectionDisabled() + .litheSettingsTextEditor(height: 230) + .focused($focusedEditor, equals: .shared) + .disabled(feature.sharedLibrary == nil || feature.isSharedBusy) + .accessibilityLabel(Text(key("Shared instructions"))) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { sharedActions } + VStack(alignment: .leading, spacing: 8) { sharedActions } } - TextEditor(text: Binding(get: { feature.draft }, set: { feature.edit($0) })) - .autocorrectionDisabled() - .litheSettingsTextEditor(height: 320) - .disabled(feature.isBusy) - .accessibilityLabel(Text(key("Global instructions"))) + }.padding(.vertical, 8) + } + if case .failed(let message) = feature.sharedPhase { + HStack { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + Spacer(minLength: 0) + Button(localized("Reload from disk")) { requestReload(shared: true) } + .buttonStyle(LitheSecondaryButtonStyle()).disabled(feature.isSharedBusy) } - HStack(spacing: 8) { - Button(localized("Save instructions")) { - operation = Task { await feature.save() } - } - .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) - .disabled(!feature.canSave) - .opacity(feature.canSave ? 1 : 0.5) - Button(localized("Reload from disk")) { - if feature.isDirty { confirmsReload = true } - else { reload() } + } + } + } + + @ViewBuilder private var sharedActions: some View { + Button(localized("Save shared instructions")) { run { await feature.saveShared() } } + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) + .disabled(!feature.canSaveShared).opacity(feature.canSaveShared ? 1 : 0.5) + .keyboardShortcut(focusedEditor == .shared ? KeyboardShortcut("s", modifiers: .command) : nil) + Button(localized("Reload from disk")) { requestReload(shared: true) } + .buttonStyle(LitheSecondaryButtonStyle()).disabled(feature.isSharedBusy) + if feature.isSharedBusy { ProgressView().controlSize(.small) } + else if feature.sharedSaved { Text(key("Shared instructions saved.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if feature.sharedDraft.utf8.count > 1024 * 1024 { + Text(key("Global instructions must be no larger than 1 MiB.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + } + + private func agentRow(_ agent: AgentInstructionKind) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Button { feature.toggleEditor(agent) } label: { + HStack(spacing: 12) { + AgentBrandIcon(name: agent.title, size: 24, style: .brand) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: agent.title).font(LitheTheme.uiFont(size: 13, weight: .medium)) + if let file = feature.files[agent] { + Text(verbatim: file.path).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).lineLimit(1).truncationMode(.middle).help(file.path) + } + } + Spacer(minLength: 0) + }.contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("instruction-agent-\(agent.rawValue)") + if let state = feature.sharedState(agent) { + if state.needsSync { badge("Sync needed", color: LitheTheme.warning) } + else if state.ownLineCount > 0 { + Text(verbatim: String(format: localized("%d lines of its own"), state.ownLineCount)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } - .buttonStyle(LitheSecondaryButtonStyle()) - .disabled(feature.isBusy) - if feature.isBusy { - ProgressView().controlSize(.small) - } else if feature.saved.contains(feature.selectedAgent) { - Text(key("Instructions saved.")).foregroundStyle(LitheTheme.secondaryText) - } else if feature.isDirty { - Text(key("Unsaved changes")).foregroundStyle(LitheTheme.secondaryText) + if let enabled = state.enabled { + Toggle(localized("Use shared instructions"), isOn: Binding(get: { enabled }, set: { value in + run { await feature.setSharedEnabled(value, agent: agent) } + })) + .toggleStyle(.switch).controlSize(.small).labelsHidden() + .accessibilityLabel(Text(verbatim: agent.title + ": " + localized("Use shared instructions"))) + .disabled(!feature.canToggleShared(agent)) } } - if case .failed(let message) = feature.phase { - Text(key(String.LocalizationValue(message))).foregroundStyle(LitheTheme.error) - .textSelection(.enabled) - } - if feature.draft.utf8.count > 1024 * 1024 { - Text(key("Global instructions must be no larger than 1 MiB.")).foregroundStyle(LitheTheme.error) - } - Text(key("Start a new conversation after saving to load the updated instructions. Project instructions still apply.")) - .foregroundStyle(LitheTheme.secondaryText) - .fixedSize(horizontal: false, vertical: true) + Button { feature.toggleEditor(agent) } label: { + Image(systemName: feature.isEditorExpanded && feature.selectedAgent == agent ? "chevron.down" : "chevron.right") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.buttonStyle(.plain).accessibilityLabel(Text(verbatim: agent.title)) + }.padding(.vertical, 9) + if let issue = feature.sharedSyncErrors[agent] ?? feature.sharedState(agent)?.issue { + Text(key(String.LocalizationValue(issue))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + if feature.sharedState(agent)?.needsSync == true { + Button(localized("Sync again")) { run { await feature.setSharedEnabled(true, agent: agent) } } + .buttonStyle(LitheSecondaryButtonStyle()).disabled(!feature.canToggleShared(agent)) + } + if feature.files[agent] == nil, case .failed(let message) = feature.phases[agent] { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) } - } - .frame(maxWidth: 760, alignment: .leading) - .task(id: feature.selectedAgent) { await feature.load() } - .onDisappear { operation?.cancel(); operation = nil } - .alert(localized("Discard draft and reload?"), isPresented: $confirmsReload) { - Button(localized("Cancel"), role: .cancel) {} - Button(localized("Reload from disk"), role: .destructive) { reload() } - } message: { - Text(key("Reloading replaces this draft with the current file contents.")) } } - private func localized(_ value: String.LocalizationValue) -> String { - AgentLocalization.string(value, locale: locale, bundle: localizationBundle) + private var nativeEditor: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Text(key("Native instruction file")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip(key("This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents.")) + Spacer(minLength: 0) + } + if let file = feature.file { + if file.path.hasSuffix("AGENTS.override.md") { + Text(key("Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.")) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).fixedSize(horizontal: false, vertical: true) + } + if !file.exists { + Text(key("The file will be created when you save.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + TextEditor(text: Binding(get: { feature.draft }, set: feature.edit)) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)).autocorrectionDisabled() + .litheSettingsTextEditor(height: 280) + .focused($focusedEditor, equals: .native).disabled(feature.isBusy || feature.isSharedBusy) + .accessibilityLabel(Text(key("Global instructions"))) + } + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { nativeActions } + VStack(alignment: .leading, spacing: 8) { nativeActions } + } + if case .failed(let message) = feature.phase { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error).textSelection(.enabled) + } + if feature.draft.utf8.count > 1024 * 1024 { + Text(key("Global instructions must be no larger than 1 MiB.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + }.padding(.vertical, 8) } - private func key(_ value: String.LocalizationValue) -> LocalizedStringKey { - LocalizedStringKey(localized(value)) + @ViewBuilder private var nativeActions: some View { + Button(localized("Save instructions")) { run { await feature.save() } } + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) + .disabled(!feature.canSave).opacity(feature.canSave ? 1 : 0.5) + .keyboardShortcut(focusedEditor == .native ? KeyboardShortcut("s", modifiers: .command) : nil) + Button(localized("Reload from disk")) { requestReload(shared: false) } + .buttonStyle(LitheSecondaryButtonStyle()).disabled(feature.isBusy || feature.isSharedBusy) + if feature.isBusy { ProgressView().controlSize(.small) } + else if feature.isDirty { Text(key("Unsaved changes")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning) } + else if feature.saved.contains(feature.selectedAgent) { Text(key("Instructions saved.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } } - private func reload() { - operation = Task { await feature.load(reload: true) } + private func badge(_ value: String.LocalizationValue, color: Color) -> some View { + Text(key(value)).font(LitheTheme.smallFont).foregroundStyle(color) + } + private func run(_ action: @escaping @MainActor () async -> Void) { + let id = UUID() + operations[id] = Task { + await action() + operations.removeValue(forKey: id) + } + } + private func requestReload(shared: Bool) { + reloadsShared = shared + if shared ? feature.isSharedDirty : feature.isDirty { confirmsReload = true } + else { reload(shared: shared) } + } + private func reload(shared: Bool) { + run { + if shared { await feature.loadShared(reload: true) } + else { await feature.load(reload: true) } + } + } + private func localized(_ value: String.LocalizationValue) -> String { + AgentLocalization.string(value, locale: locale, bundle: localizationBundle) } + private func key(_ value: String.LocalizationValue) -> LocalizedStringKey { LocalizedStringKey(localized(value)) } } diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift index 86b43dc34d..1e15531bed 100644 --- a/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift +++ b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift @@ -27,6 +27,9 @@ public struct AgentInstructionFile: Decodable, Equatable, Sendable { public protocol AgentInstructionsService: Sendable { func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile + func readShared() async throws -> SharedInstructionLibrary + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary } public struct UnavailableAgentInstructionsService: AgentInstructionsService { @@ -34,3 +37,30 @@ public struct UnavailableAgentInstructionsService: AgentInstructionsService { public func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } public func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } } + +/// Native snapshots and per-target failures keep partial synchronization explicit. +public struct SharedInstructionAgent: Decodable, Sendable { + public let agentId: String + public let file: AgentInstructionFile? + public let enabled: Bool? + public let needsSync: Bool + public let ownLineCount: Int + public let issue: String? + public init(agentId: String, file: AgentInstructionFile?, enabled: Bool?, needsSync: Bool, + ownLineCount: Int, issue: String?) { + self.agentId = agentId; self.file = file; self.enabled = enabled; self.needsSync = needsSync + self.ownLineCount = ownLineCount; self.issue = issue + } +} + +public struct SharedInstructionLibrary: Decodable, Sendable { + public let file: AgentInstructionFile + public let agents: [SharedInstructionAgent] + public init(file: AgentInstructionFile, agents: [SharedInstructionAgent]) { self.file = file; self.agents = agents } +} + +public extension AgentInstructionsService { + func readShared() async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift index db18624ed3..f9c176ac21 100644 --- a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift +++ b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift @@ -11,6 +11,7 @@ struct AgentInstructionsAppearanceTests { func nativeEditorFitsSettingsAndPreservesUnicode(_ dark: Bool) async throws { let feature = AgentInstructionsFeatureModel(service: AppearanceInstructions()) await feature.load() + feature.toggleEditor(.codex) for language in ["en", "zh-Hans"] { let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().appendingPathComponent("Resources") @@ -34,7 +35,7 @@ struct AgentInstructionsAppearanceTests { while clock.now < deadline { host.layoutSubtreeIfNeeded() editors = textViews(host).filter { $0.string == AppearanceInstructions.text } - if !editors.isEmpty { break } + if !editors.isEmpty && feature.sharedLibrary != nil && !feature.isSharedBusy { break } await Task.yield() } let editor = try #require(editors.first, "the shared editor must host native UTF-8 text") @@ -75,10 +76,28 @@ private struct AppearanceInstructions: AgentInstructionsService { content: Self.text, exists: true, revision: "fixture") } func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } + func readShared() async throws -> SharedInstructionLibrary { + SharedInstructionLibrary(file: .init(agentId: "shared", path: "/fixture/library/Shared.md", content: "", exists: false, revision: "shared"), + agents: [SharedInstructionAgent(agentId: "codex-acp", file: try await read(agent: .codex), enabled: false, + needsSync: false, ownLineCount: 4, issue: nil), + SharedInstructionAgent(agentId: "claude-acp", file: try await read(agent: .claude), enabled: false, + needsSync: false, ownLineCount: 4, issue: nil)]) + } } @Suite("Global instruction contract") struct AgentInstructionsContractTests { + @Test func sharedLibraryDecodesActualUnknownAndEnabledTargets() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + struct Fixture: Decodable { let library: SharedInstructionLibrary } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/shared-instructions-v1.json"))) + #expect(fixture.library.agents[0].enabled == true) + #expect(fixture.library.agents[1].enabled == nil) + #expect(fixture.library.agents[1].issue != nil) + #expect(fixture.library.file.agentId == "shared") + } + @Test func sharedSnapshotsDecodeOnBothPlatforms() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() diff --git a/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift new file mode 100644 index 0000000000..f5b3be6e15 --- /dev/null +++ b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Shared instruction synchronization") +@MainActor +struct SharedInstructionsFeatureTests { + @Test func loadingAndExpandingIsReadOnlyAndKeepsIndependentDrafts() async throws { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + #expect(feature.sharedDraft == "Shared original") + #expect(feature.files.count == 2) + feature.toggleEditor(.codex) + feature.edit("Unsaved native draft") + feature.toggleEditor(.codex) + #expect(!feature.isEditorExpanded) + feature.toggleEditor(.claude) + feature.toggleEditor(.codex) + #expect(feature.draft == "Unsaved native draft") + #expect(!feature.canToggleShared(.codex)) + feature.editShared("New shared text") + #expect(feature.canSaveShared) + #expect(!feature.canToggleShared(.claude), "save source changes before switching targets") + await feature.saveShared() + #expect(feature.sharedLibrary?.file.content == "New shared text") + #expect(feature.draft == "Unsaved native draft") + #expect(await service.operations == ["save"]) + } + + @Test func rapidAgentChoicesUseFreshSnapshotsAndOneFailureDoesNotClearAnotherChoice() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + await service.setFailsCodex(true) + await service.onToggle { + await MainActor.run { + #expect(!feature.canToggleShared(.codex)) + #expect(feature.canToggleShared(.claude), "another row stays enabled and retains its native colors") + } + await feature.setSharedEnabled(true, agent: .claude) + } + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == false) + #expect(feature.sharedState(.claude)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == "External file changed") + #expect(feature.pendingSharedAgents.isEmpty) + #expect(await service.operations == ["codex-acp", "claude-acp"]) + await service.setFailsCodex(false) + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == nil) + } + + @Test func sourceConflictsKeepDraftAndFailedReloadDoesNotDiscardIt() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + feature.editShared("Keep this shared draft") + await service.setFailsSource(true) + await feature.saveShared() + #expect(feature.sharedPhase == .failed("External file changed")) + #expect(feature.sharedDraft == "Keep this shared draft") + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Keep this shared draft") + await service.setFailsSource(false) + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Shared original") + #expect(!feature.isSharedDirty) + } + + @Test func cancellingTheOwnerDropsQueuedSwitchesAndDoesNotWriteThemLater() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + let started = TestGate(), released = TestGate() + await service.onToggle { + started.open() + let wasReleased = await released.waitUntilOpen() + if !Task.isCancelled { #expect(wasReleased) } + } + await feature.loadShared() + let task = Task { await feature.setSharedEnabled(true, agent: .codex) } + defer { released.open(); task.cancel() } + #expect(await started.waitUntilOpen()) + await feature.setSharedEnabled(true, agent: .claude) + task.cancel() + released.open() + await task.value + #expect(feature.pendingSharedAgents.isEmpty) + #expect(feature.sharedState(.claude)?.enabled == false) + #expect(await service.operations == ["codex-acp"]) + } +} + +private actor SharedInstructionsStub: AgentInstructionsService { + struct Conflict: LocalizedError { var errorDescription: String? { "External file changed" } } + var operations: [String] = [] + private var text = "Shared original" + private var enabled: Set = [] + private var failsCodex = false, failsSource = false + private var callback: (@Sendable () async -> Void)? + func onToggle(_ value: @escaping @Sendable () async -> Void) { callback = value } + func setFailsCodex(_ value: Bool) { failsCodex = value } + func setFailsSource(_ value: Bool) { failsSource = value } + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { native(agent) } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } + func readShared() async throws -> SharedInstructionLibrary { + if failsSource { throw Conflict() } + return library() + } + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append("save") + if failsSource { throw Conflict() } + text = content + return self.library() + } + func setSharedEnabled(_ value: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append(agent.rawValue) + let called = callback; callback = nil + await called?() + try Task.checkCancellation() + if failsCodex && agent == .codex { throw Conflict() } + if value { enabled.insert(agent) } else { enabled.remove(agent) } + return self.library() + } + private func native(_ agent: AgentInstructionKind) -> AgentInstructionFile { + .init(agentId: agent.rawValue, path: "/fixture/\(agent.rawValue)/instructions.md", + content: "Own \(agent.title)", exists: true, revision: "native-\(agent.rawValue)") + } + private func library() -> SharedInstructionLibrary { + .init(file: .init(agentId: "shared", path: "/fixture/library/Shared.md", content: text, + exists: true, revision: "source-\(text)"), + agents: AgentInstructionKind.allCases.map { agent in + SharedInstructionAgent(agentId: agent.rawValue, file: native(agent), enabled: enabled.contains(agent), + needsSync: false, ownLineCount: 1, issue: nil) + }) + } +} diff --git a/rust/lithe-agent-host/src/instructions.rs b/rust/lithe-agent-host/src/instructions.rs index b89909fbdd..35d1fe18d7 100644 --- a/rust/lithe-agent-host/src/instructions.rs +++ b/rust/lithe-agent-host/src/instructions.rs @@ -9,6 +9,8 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +pub mod shared; + const MAX_BYTES: usize = 1024 * 1024; static SAVE_LOCK: Mutex<()> = Mutex::new(()); @@ -25,7 +27,7 @@ pub struct InstructionFile { } /// A save must match both the selected native file and its previously read bytes. -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SaveRequest { pub agent_id: String, @@ -199,6 +201,13 @@ fn save_in(request: SaveRequest, directory: &Path) -> Result Result { if request.content.len() > MAX_BYTES { return Err(error( "file_too_large", @@ -207,6 +216,30 @@ fn save_in(request: SaveRequest, directory: &Path) -> Result Result, +) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let directory = previous + .path + .parent() + .ok_or_else(|| io_error(std::io::Error::other("missing parent")))?; let permissions = if previous.exists { let permissions = fs::metadata(&previous.path) .map_err(io_error)? @@ -240,8 +273,7 @@ fn save_in(request: SaveRequest, directory: &Path) -> Result Result Result Result<(), InstructionError> { diff --git a/rust/lithe-agent-host/src/instructions/shared.rs b/rust/lithe-agent-host/src/instructions/shared.rs new file mode 100644 index 0000000000..40c6481404 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared.rs @@ -0,0 +1,416 @@ +//! User-owned shared text and guarded, per-Agent synchronization of a marked block. +//! The platform supplies its writable application-support directory; opening the +//! page is read-only. No prompt injection, package writes or background sync occurs. + +use super::*; +use fs2::FileExt; + +const BEGIN: &str = ""; +const END: &str = ""; +const AGENTS: [&str; 2] = ["codex-acp", "claude-acp"]; + +/// Platform-selected storage plus an explicit read, text save or Agent switch. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub data_directory: PathBuf, + #[serde(flatten)] + pub action: Action, +} + +/// Writes always name the source revision and the native snapshots being changed. +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +pub enum Action { + Read {}, + Save { + content: String, + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, + SetEnabled { + #[serde(rename = "agentId")] + agent_id: String, + enabled: bool, + #[serde(rename = "expectedRevision")] + expected_revision: String, + file: FileGuard, + }, +} + +/// A native path is only a snapshot guard; destinations are resolved by the host. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileGuard { + pub agent_id: String, + pub path: PathBuf, + pub revision: String, +} + +/// Enabled state comes from actual file bytes, never from a persisted switch value. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentState { + pub agent_id: String, + pub file: Option, + pub enabled: Option, + pub needs_sync: bool, + pub own_line_count: usize, + pub issue: Option, +} + +/// A text save can succeed while one Agent fails; every failure stays visible. +#[derive(Serialize)] +pub struct Library { + pub file: InstructionFile, + pub agents: Vec, +} + +/// Execute a bounded operation. Cancellation is observed between native writes; +/// completed file replacements remain committed and are reported by the next read. +pub fn execute( + request: Request, + cancelled: &impl Fn() -> bool, +) -> Result { + if !request.data_directory.is_absolute() { + return Err(error( + "profile_relative", + "The shared instruction directory must be absolute.", + )); + } + execute_in(request, &profile, cancelled, &SAVE_LOCK) +} + +fn execute_in( + request: Request, + resolve: &impl Fn(&str) -> Result, + cancelled: &impl Fn() -> bool, + write_lock: &Mutex<()>, +) -> Result { + let path = request.data_directory.join("Shared.md"); + if matches!(request.action, Action::Read {}) { + return library(snapshot("shared", path)?, resolve); + } + let _guard = write_lock + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + let before = snapshot("shared", path.clone())?; + let expected = match &request.action { + Action::Save { + expected_revision, .. + } + | Action::SetEnabled { + expected_revision, .. + } => expected_revision, + Action::Read {} => unreachable!(), + }; + if &before.revision != expected { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + // One nonblocking OS lock protects separate Lithe processes sharing this library. + // Its persistent empty file is user state, never a transferable build resource. + match &request.action { + Action::Save { content, files, .. } => { + validate_text(content)?; + let ids: std::collections::HashSet<_> = + files.iter().map(|file| file.agent_id.as_str()).collect(); + if files.len() > AGENTS.len() + || ids.len() != files.len() + || ids.iter().any(|id| !AGENTS.contains(id)) + { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + } + Action::SetEnabled { agent_id, file, .. } + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != *agent_id => + { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + _ => {} + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories + .create(&request.data_directory) + .map_err(io_error)?; + let lock_path = request.data_directory.join(".sync.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let lock = options.open(lock_path).map_err(io_error)?; + let kind = lock.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + lock.try_lock_exclusive() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + if snapshot("shared", path.clone())?.revision != before.revision { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + let mut failures = Vec::new(); + match request.action { + Action::Save { content, files, .. } => { + validate_text(&content)?; + if files.len() > AGENTS.len() { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + write_snapshot(&before, &content, || snapshot("shared", path.clone()))?; + // Commit the source first. Failed targets retain their old block and + // expose needsSync; retrying is explicit and does not lose user text. + for agent in AGENTS { + let result = (|| { + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(agent)?; + let current = read_in(agent, &directory)?; + if block(¤t.content)?.is_none() { + return Ok(()); + } + let guard = files + .iter() + .find(|file| file.agent_id == agent) + .ok_or_else(|| { + error( + "file_changed", + "Reload this Agent before synchronizing instructions.", + ) + })?; + sync(¤t, guard, Some(&content), &directory) + })(); + if let Err(err) = result { + failures.push((agent.to_string(), err.message.to_string())); + } + } + } + Action::SetEnabled { + agent_id, + enabled, + file, + .. + } => { + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != agent_id { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(&agent_id)?; + let current = read_in(&agent_id, &directory)?; + sync( + ¤t, + &file, + enabled.then_some(before.content.as_str()), + &directory, + )?; + } + Action::Read {} => unreachable!(), + } + let mut result = library(snapshot("shared", path)?, resolve)?; + for (agent, message) in failures { + if let Some(state) = result + .agents + .iter_mut() + .find(|state| state.agent_id == agent) + { + state.issue = Some(message); + } + } + Ok(result) +} + +fn library( + file: InstructionFile, + resolve: &impl Fn(&str) -> Result, +) -> Result { + let agents = AGENTS + .iter() + .map(|agent| { + let result = resolve(agent).and_then(|directory| read_in(agent, &directory)); + match result { + Ok(native) => match block(&native.content) { + Ok(managed) => { + let own = without_block(&native.content, managed.as_ref()); + AgentState { + agent_id: (*agent).into(), + enabled: Some(managed.is_some()), + needs_sync: managed + .as_ref() + .is_some_and(|block| block.text != file.content), + own_line_count: own + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + file: Some(native), + issue: None, + } + } + Err(err) => AgentState { + agent_id: (*agent).into(), + file: Some(native), + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + }, + Err(err) => AgentState { + agent_id: (*agent).into(), + file: None, + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + } + }) + .collect(); + Ok(Library { file, agents }) +} + +fn sync( + current: &InstructionFile, + guard: &FileGuard, + text: Option<&str>, + directory: &Path, +) -> Result<(), InstructionError> { + let request = SaveRequest { + agent_id: current.agent_id.clone(), + content: String::new(), + expected_path: guard.path.clone(), + expected_revision: guard.revision.clone(), + }; + check_revision(current, &request)?; + let managed = block(¤t.content)?; + if let Some(text) = text { + validate_text(text)?; + } + let replacement = text + .map(|text| format!("\n{BEGIN}\n{text}\n{END}\n")) + .unwrap_or_default(); + let content = if let Some(managed) = managed { + format!( + "{}{}{}", + ¤t.content[..managed.start], + replacement, + ¤t.content[managed.end..] + ) + } else { + format!("{}{}", current.content, replacement) + }; + if content == current.content { + return Ok(()); + } + save_unlocked(SaveRequest { content, ..request }, directory)?; + Ok(()) +} + +struct Block<'a> { + start: usize, + end: usize, + text: &'a str, +} + +fn block(content: &str) -> Result>, InstructionError> { + let opens = content.matches(BEGIN).count(); + let closes = content.matches(END).count(); + if opens == 0 && closes == 0 { + return Ok(None); + } + let invalid = || { + error("invalid_markers", "The Lithe shared instruction markers were changed. Repair the native file before syncing.") + }; + if opens != 1 || closes != 1 { + return Err(invalid()); + } + // Include our own leading/trailing newline in the range. Removing a block + // restores the exact original bytes, even when the user file had no newline. + let open = format!("\n{BEGIN}\n"); + let close = format!("\n{END}\n"); + let start = content.find(&open).ok_or_else(invalid)?; + let text_start = start + open.len(); + let close_start = content[text_start..] + .find(&close) + .map(|index| text_start + index) + .ok_or_else(invalid)?; + Ok(Some(Block { + start, + end: close_start + close.len(), + text: &content[text_start..close_start], + })) +} + +fn without_block(content: &str, managed: Option<&Block<'_>>) -> String { + managed + .map(|block| format!("{}{}", &content[..block.start], &content[block.end..])) + .unwrap_or_else(|| content.into()) +} + +fn validate_text(content: &str) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + if content.contains(BEGIN) || content.contains(END) { + return Err(error( + "invalid_markers", + "Shared instructions cannot contain Lithe synchronization markers.", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/shared/tests.rs b/rust/lithe-agent-host/src/instructions/shared/tests.rs new file mode 100644 index 0000000000..4939fc717c --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared/tests.rs @@ -0,0 +1,228 @@ +use super::*; + +struct Fixture { + path: PathBuf, + write_lock: Mutex<()>, +} +impl Fixture { + fn new() -> Self { + Self { + path: std::env::temp_dir().join(format!("lithe-shared-{}", uuid::Uuid::new_v4())), + write_lock: Mutex::new(()), + } + } + fn profile(&self, agent: &str) -> Result { + Ok(self.path.join(agent)) + } + fn read(&self) -> Library { + self.run(Action::Read {}).unwrap() + } + fn run(&self, action: Action) -> Result { + execute_in( + Request { + data_directory: self.path.join("library"), + action, + }, + &|agent| self.profile(agent), + &|| false, + &self.write_lock, + ) + } + fn save(&self, library: &Library, text: &str) -> Library { + self.run(Action::Save { + content: text.into(), + expected_revision: library.file.revision.clone(), + files: library + .agents + .iter() + .filter_map(|agent| agent.file.as_ref().map(guard)) + .collect(), + }) + .unwrap() + } + fn toggle( + &self, + library: &Library, + agent: &str, + enabled: bool, + ) -> Result { + self.run(Action::SetEnabled { + agent_id: agent.into(), + enabled, + expected_revision: library.file.revision.clone(), + file: guard( + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .file + .as_ref() + .unwrap(), + ), + }) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} +fn guard(file: &InstructionFile) -> FileGuard { + FileGuard { + agent_id: file.agent_id.clone(), + path: file.path.clone(), + revision: file.revision.clone(), + } +} +fn enabled(library: &Library, agent: &str) -> bool { + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .enabled + == Some(true) +} + +#[test] +fn shared_sync_preserves_own_bytes_checks_revisions_and_reports_partial_failures() { + let fixture = Fixture::new(); + let first = fixture.read(); + assert!( + !fixture.path.exists(), + "opening settings must not create any files" + ); + let locked = fixture.write_lock.try_lock().unwrap(); + assert_eq!( + fixture + .run(Action::Save { + content: "not written".into(), + expected_revision: first.file.revision.clone(), + files: vec![] + }) + .err() + .unwrap() + .reason, + "busy" + ); + assert!(!fixture.path.exists()); + drop(locked); + let codex = fixture.profile("codex-acp").unwrap(); + let claude = fixture.profile("claude-acp").unwrap(); + fs::create_dir_all(&codex).unwrap(); + fs::create_dir_all(&claude).unwrap(); + let own_codex = "# 私有规则\r\n无结尾换行"; + fs::write(codex.join("AGENTS.md"), own_codex).unwrap(); + fs::write(claude.join("CLAUDE.md"), "Claude only\n").unwrap(); + let library = fixture.save(&first, "共享中文\nKeep identifiers."); + assert!(!enabled(&library, "codex-acp")); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + let library = fixture.toggle(&library, "claude-acp", true).unwrap(); + assert!(enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + let library = fixture.save(&library, "第二版"); + assert!(library + .agents + .iter() + .all(|row| !row.needs_sync && row.issue.is_none())); + assert!(fs::read_to_string(codex.join("AGENTS.md")) + .unwrap() + .starts_with(own_codex)); + let stale = library; + fs::write( + codex.join("AGENTS.md"), + format!( + "外部修改{}", + fs::read_to_string(codex.join("AGENTS.md")).unwrap() + ), + ) + .unwrap(); + let library = fixture.save(&stale, "第三版"); + let row = &library.agents[0]; + assert!(row.needs_sync && row.issue.is_some()); + assert!( + !library.agents[1].needs_sync, + "another Agent can complete despite one conflict" + ); + assert_eq!( + fixture + .toggle(&stale, "codex-acp", false) + .err() + .unwrap() + .reason, + "file_changed" + ); + let library = fixture.save(&library, "第三版"); + assert!(library.agents.iter().all(|row| !row.needs_sync)); + let library = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert!(!enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.md")).unwrap(), + format!("外部修改{own_codex}") + ); + let _ = fixture.toggle(&library, "claude-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), + "Claude only\n" + ); + // Reopening uses disk snapshots; no in-memory preference can claim a stale switch. + let reopened = fixture.read(); + assert!(reopened.agents.iter().all(|row| row.enabled == Some(false))); + assert_eq!(reopened.file.content, "第三版"); + fs::write(codex.join("AGENTS.override.md"), "override text").unwrap(); + let library = fixture.read(); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + assert!(library.agents[0] + .file + .as_ref() + .unwrap() + .path + .ends_with("AGENTS.override.md")); + let _ = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.override.md")).unwrap(), + "override text" + ); + // Malformed or duplicate markers are left untouched and the switch becomes unknown. + fs::write(claude.join("CLAUDE.md"), BEGIN).unwrap(); + let library = fixture.read(); + assert!(library.agents[1].enabled.is_none() && library.agents[1].issue.is_some()); + assert_eq!( + fixture + .toggle(&library, "claude-acp", true) + .err() + .unwrap() + .reason, + "invalid_markers" + ); + assert_eq!(fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), BEGIN); +} + +#[test] +fn marked_ranges_roundtrip_and_reject_ambiguous_boundaries() { + for own in ["", "no newline", "ends in LF\n", "CRLF\r\n", "中文\n\n"] { + for shared in ["", "中文", "line\n", "line\r\n"] { + let combined = format!("{own}\n{BEGIN}\n{shared}\n{END}\nextra"); + let managed = block(&combined).unwrap().unwrap(); + assert_eq!(managed.text, shared); + assert_eq!( + without_block(&combined, Some(&managed)), + format!("{own}extra") + ); + } + } + for malformed in [ + BEGIN.to_string(), + END.to_string(), + format!("\n{BEGIN}\n{BEGIN}\nx\n{END}\n"), + format!("{BEGIN}\nx\n{END}"), + ] { + assert!(block(&malformed).is_err()); + } + assert!(validate_text(BEGIN).is_err()); + assert!(validate_text(&"x".repeat(MAX_BYTES + 1)).is_err()); + let request: Request = + serde_json::from_value(serde_json::json!({"dataDirectory":"/fixture", "action":"read"})) + .unwrap(); + assert!(matches!(request.action, Action::Read {})); +} diff --git a/rust/lithe-core/src/agent/instructions.rs b/rust/lithe-core/src/agent/instructions.rs index 31a8587af5..5e1ebb46d8 100644 --- a/rust/lithe-core/src/agent/instructions.rs +++ b/rust/lithe-core/src/agent/instructions.rs @@ -27,14 +27,25 @@ pub(crate) fn save(request: SaveRequest) -> Result { fn encode( result: Result, ) -> Result { - let file = result.map_err(|error| { - let code = match error.reason { - "unsupported_agent" => ErrorCode::NotSupported, - "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, - _ => ErrorCode::InvalidRequest, - }; - CoreError::new(code, error.message).with_details(error.reason) - })?; + let file = result.map_err(core_error)?; serde_json::to_value(file) .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) } + +/// Reads or synchronizes a platform-owned shared instruction library. +pub(crate) fn shared(request: instructions::shared::Request) -> Result { + cancellation::check()?; + let result = instructions::shared::execute(request, &|| cancellation::check().is_err()) + .map_err(core_error)?; + serde_json::to_value(result) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +fn core_error(error: InstructionError) -> CoreError { + let code = match error.reason { + "unsupported_agent" => ErrorCode::NotSupported, + "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, + _ => ErrorCode::InvalidRequest, + }; + CoreError::new(code, error.message).with_details(error.reason) +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 09b5ea909c..176a18e3f5 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -42,6 +42,8 @@ pub enum CoreCommand { AgentInstructionsRead, /// Saves a guarded native global instruction draft (`agent.instructions.save`). AgentInstructionsSave, + /// Synchronizes a marked shared block in native user files (`agent.instructions.shared`). + AgentInstructionsShared, /// Lists native skills or creates/imports one explicitly (`agent.skills`). AgentSkills, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). @@ -432,6 +434,7 @@ impl CoreCommand { "agent.status" => Some(Self::AgentStatus), "agent.instructions.read" => Some(Self::AgentInstructionsRead), "agent.instructions.save" => Some(Self::AgentInstructionsSave), + "agent.instructions.shared" => Some(Self::AgentInstructionsShared), "agent.skills" => Some(Self::AgentSkills), "agent.install" => Some(Self::AgentInstall), "agent.uninstall" => Some(Self::AgentUninstall), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 2452343b9d..aeb419d256 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -145,6 +145,9 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::AgentInstructionsSave => { agent_response(id, parsed.payload, crate::agent::instructions::save) } + CoreCommand::AgentInstructionsShared => { + agent_response(id, parsed.payload, crate::agent::instructions::shared) + } CoreCommand::AgentSkills => agent_response(id, parsed.payload, crate::agent::skills), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index 46688f81ba..251ac2ef83 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -29,6 +29,21 @@ fn global_instruction_contract_dispatches_and_rejects_unknown_agents_without_fil assert_eq!(response["error"]["code"], "invalid_request"); } +#[test] +fn shared_instruction_requests_have_strict_typed_dispatch_without_arbitrary_native_paths() { + assert!(crate::protocol::CoreCommand::parse("agent.instructions.shared").is_some()); + for payload in [ + json!({"dataDirectory":"relative", "action":"read"}), + json!({"dataDirectory":"/fixture", "action":"unknown"}), + json!({"dataDirectory":"/fixture", "action":"read", "path":"/arbitrary"}), + json!({"dataDirectory":"/fixture", "action":"setEnabled", "agentId":"codex-acp", "enabled":true}), + ] { + let response = run("agent.instructions.shared", payload); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + fn run(command: &str, payload: Value) -> Value { let request = json!({ "id": "agent-test", "command": command, "payload": payload }); serde_json::from_str(&execute_json(&request.to_string())).expect("core response JSON") diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 1fa71f89ea..005cf6c861 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -387,6 +387,7 @@ async function main() { // Includes agent-ui-test-evidence: fixture screenshots belong to one test // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. + // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. // Includes mcp-registry-metadata: fetched responses have no copy stage; featured.json follows Git source. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index aedcc26223..a3f7225a4d 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -225,7 +225,7 @@ try { await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "agent-instruction-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 1e94aec223..06aa8a6999 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -308,6 +308,18 @@ ], "identity": "Fetched public directory responses are bounded to 1 MiB, normalized by Rust Core and owned by one cancellable search; there is no persistent cache or build identity stamp. The separate featured.json catalog is Git-owned compile-time source, not a reusable generated or downloaded resource.", "reason": "The macOS ephemeral URLSessions keep fetched directory metadata in memory only. Never copy, bundle or share fetched search responses between worktrees; query, pagination and the explicitly selected system/direct route belong to the current page. The static rust/lithe-core/src/mcp/featured.json catalog follows the source checkout and is compiled read-only into Core; never copy it from a runtime cache. No server packages or images are downloaded, and no runtime writes touch the installed bundle or Sparkle delta baseline." + }, + { + "id": "agent-instruction-library-state", + "reusable": false, + "locations": [ + "/Lithe/AgentInstructions/Shared.md", + "/Lithe/AgentInstructions/.sync.lock", + "/Lithe/AgentInstructions/.lithe-instructions-.tmp", + "/{AGENTS.md,AGENTS.override.md,CLAUDE.md}" + ], + "identity": "Mutable user-authored instruction text, native snapshots and nonblocking synchronization locks; no version/platform/architecture/toolchain build identity.", + "reason": "The platform storage adapter supplies the writable application-support root. Agent Host owns bounded UTF-8 reads and guarded atomic saves; only explicit saves and switches write. Temporary siblings are removed after success/failure, the empty lock stays with the user library, and native files retain upstream ownership outside the Lithe block. Never copy, bundle, share or validate these as worktree build resources; installed packages and Sparkle baselines remain read-only." } ] } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index cb7781035b..3e61c7ec56 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -34,6 +34,17 @@ load in a new native conversation; existing sessions are not rewritten or stoppe Project instructions retain the upstream Agent's precedence rules. See `fixtures/agent/agent-instructions-v1.json`. +A user can also author one shared instruction text in platform-owned application +support storage and opt each supported Agent into a delimited block in its native +file. Synchronization preserves every byte outside that block. Turning a switch +off removes only that block. Enabled, unknown and out-of-date states come from +native snapshots, not a stored preference. Source saves and each target replacement +are atomic individually, not a cross-file transaction: per-Agent conflicts remain +visible and retryable while the saved shared draft and successful targets survive. +No startup/background synchronization or extra prompt injection occurs. A malformed +or duplicate marker is never repaired by overwriting it automatically. See +`fixtures/agent/shared-instructions-v1.json`. + Shared Monaco highlighting is owned by the current view/model pair. A queued callback for a closed or replaced model must finish without running against the new model or locking surviving documents. Primary, split and diff views share diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 144816caed..80d5e4d379 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -725,6 +725,33 @@ Errors use the existing envelope codes with stable `details` reasons including `invalid_utf8`, `profile_missing`, `profile_relative`, and `busy`. The fixture is `shared/fixtures/agent/agent-instructions-v1.json`. +`agent.instructions.shared` takes a platform-selected absolute `dataDirectory` +from the writable application-support adapter plus one tagged action: + +- `read`: returns `{file, agents}` without creating files. `file` is the shared + `Shared.md` snapshot; each Agent reports `{agentId, file, enabled, needsSync, + ownLineCount, issue}`. A native read or marker failure sets `enabled: null` and + an actionable issue rather than claiming disabled. +- `save`: takes `content`, `expectedRevision` for Shared.md and `files` guards + (`{agentId,path,revision}`) for targets. It saves the shared source, then updates + already-enabled native targets. A failed target retains its bytes and an issue; + other targets continue. `needsSync` compares the actual block with the saved source. +- `setEnabled`: takes `agentId`, `enabled`, the shared `expectedRevision` and one + `file` guard. It inserts/replaces or removes only the Lithe-delimited block in the + effective native file; it never disables the Agent or removes its own instructions. + +Marker tokens are `` and +``. The owned range includes its separator +newlines, so disabling restores the original bytes. Duplicate, incomplete or edited +markers are rejected; shared content may not contain the marker tokens. Native +source selection, size, permission, symlink and optimistic revision guards remain +those of `agent.instructions.save`. A nonblocking OS file lock in `.sync.lock` +serializes shared writes across app processes. Shared.md uses the same 1 MiB bound, +private Unix modes and atomic replacement; source/target commits are individually +atomic and partial success is explicit. Cancellation stops remaining targets and the +next page read reconciles actual state. None of these files is a build cache or +installed resource. The fixture is `fixtures/agent/shared-instructions-v1.json`. + `agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in `/agents/`, using the Node.js and npm the user installed. Lithe never installs Node.js or npm; the agents' own command-line tools are installed only through `agent.installCli` on an explicit user action. `agent.status` diff --git a/shared/fixtures/agent/shared-instructions-v1.json b/shared/fixtures/agent/shared-instructions-v1.json new file mode 100644 index 0000000000..607a41288e --- /dev/null +++ b/shared/fixtures/agent/shared-instructions-v1.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.shared", + "payload": { + "dataDirectory": "/fixture/app-support/AgentInstructions", + "action": "read" + } + }, + "library": { + "file": { + "agentId": "shared", + "path": "/fixture/app-support/AgentInstructions/Shared.md", + "content": "Use concise Chinese replies.", + "exists": true, + "revision": "source-sha256" + }, + "agents": [ + { + "agentId": "codex-acp", + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "Own rules\n\nUse concise Chinese replies.\n\n", + "exists": true, + "revision": "native-sha256" + }, + "enabled": true, + "needsSync": false, + "ownLineCount": 1, + "issue": null + }, + { + "agentId": "claude-acp", + "file": null, + "enabled": null, + "needsSync": false, + "ownLineCount": 0, + "issue": "The instruction path must be a regular file, not a symbolic link or directory." + } + ] + } +} diff --git a/shared/platform-feature-matrix/features/agent-global-instructions.json b/shared/platform-feature-matrix/features/agent-global-instructions.json index 5949f004aa..020f262143 100644 --- a/shared/platform-feature-matrix/features/agent-global-instructions.json +++ b/shared/platform-feature-matrix/features/agent-global-instructions.json @@ -2,7 +2,7 @@ "id": "agent-global-instructions", "area": "AI", "group": "Agent 对话", - "capability": "全局指令:在 AI 设置中分别编辑 Codex、Claude 原生用户文件,显示有效来源、保留独立草稿,校验外部修改后原子保存并读取实际结果,支持显式重新加载与中英文检索", + "capability": "全局指令卡片:一份共享正文按 Agent 显式启停同步到原生用户文件的 Lithe 区块,保留自有内容、实际路径、独立草稿及逐项错误;支持展开编辑、外部修改校验、原子保存和显式重新加载", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -14,7 +14,10 @@ "macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift", "rust/lithe-agent-host/src/instructions.rs", "macos/Sources/Lithe/Views/App/SettingsView.swift", - "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift" + "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift", + "macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift", + "rust/lithe-agent-host/src/instructions/shared.rs", + "rust/lithe-agent-host/src/instructions/shared/tests.rs" ] }, "windows": { @@ -24,10 +27,12 @@ "rust/lithe-agent-host/src/instructions.rs", "rust/lithe-core/src/agent/instructions.rs", "windows/tauri/src-tauri/src/platform.rs", - "shared/fixtures/agent/agent-instructions-v1.json" + "shared/fixtures/agent/agent-instructions-v1.json", + "rust/lithe-agent-host/src/instructions/shared.rs", + "shared/fixtures/agent/shared-instructions-v1.json" ] }, "owner": "Agent", - "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。", - "notes": "复用上游原生指令发现,不额外拼接提示词,不自动重启会话或改写项目指令;新会话加载更新。只在用户保存时写原生用户目录,临时文件随保存清理,不写发行资源或新增工作树复用资源。" + "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。 新增共享卡片、Agent 图标/路径/自有行数与展开编辑;隔离用户目录真实保存、启用两 Agent、更新、关闭其中一个,逐字节确认私有内容不变,重新打开从原生区块回读开关;外部修改、损坏标记、只读目标与取消后不覆盖,单项失败继续其他项并保留错误。SharedInstructionsFeatureTests 覆盖连续开关排队、取消、失败和草稿;深浅色中英文宽窄布局需实机检查。", + "notes": "共享源保存在平台 storage adapter 的 Application Support/Lithe/AgentInstructions/Shared.md,不额外拼接提示词。开关只控制 Lithe 区块,新会话由上游加载;按文件原子替换,跨文件部分失败可见。源、锁、暂存文件及原生指令均为用户状态,不可工作树复用,不写发行资源。" } From 617f7a1c40e6037315c6a2021e9635c7933b6dc1 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 22:00:44 +0800 Subject: [PATCH 06/16] feat(mcp): add bundled publisher icons and common featured servers --- .../feature/2026-10-17-mcp-server-library.md | 17 ++ Package.swift | 1 + docs/ci-builds.md | 7 + macos/Resources/en.lproj/Localizable.strings | 12 ++ .../zh-Hans.lproj/Localizable.strings | 12 ++ .../McpServerLibraryFeatureModel.swift | 35 +++ .../Lithe/Core/Ports/McpRegistry.swift | 7 + .../Resources/McpIcons/LICENSE-Playwright.txt | 202 ++++++++++++++++++ .../Lithe/Resources/McpIcons/NOTICE.json | 46 ++++ .../Resources/McpIcons/chrome-devtools.png | Bin 0 -> 7315 bytes .../Lithe/Resources/McpIcons/context7.png | Bin 0 -> 3157 bytes .../Lithe/Resources/McpIcons/deepwiki.png | Bin 0 -> 1584 bytes .../Sources/Lithe/Resources/McpIcons/exa.png | Bin 0 -> 2208 bytes .../Lithe/Resources/McpIcons/github.png | Bin 0 -> 2397 bytes .../Sources/Lithe/Resources/McpIcons/mcp.png | Bin 0 -> 2108 bytes .../Resources/McpIcons/microsoft-learn.png | Bin 0 -> 425 bytes .../Lithe/Resources/McpIcons/playwright.svg | 9 + macos/Sources/Lithe/Theme/McpBrandIcon.swift | 62 ++++++ .../Lithe/Views/App/McpDiscoveryView.swift | 44 ++-- .../Views/App/McpServerLibraryView.swift | 7 +- .../McpArgumentRoundTripTests.swift | 49 ++++- macos/Tests/LitheTests/McpRegistryTests.swift | 30 ++- rust/lithe-core/src/mcp/featured.json | 143 ++++++++++++- rust/lithe-core/src/mcp/registry.rs | 63 +++++- scripts/reuse-worktree-resources.mjs | 3 +- scripts/test-reuse-worktree-resources.mjs | 9 + scripts/worktree-resources.json | 13 +- shared/contracts/rust-core-api.md | 12 ++ shared/fixtures/mcp/featured-setup-v1.json | 48 +++++ .../features/mcp-registry-discovery.json | 10 +- 30 files changed, 808 insertions(+), 33 deletions(-) create mode 100644 macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt create mode 100644 macos/Sources/Lithe/Resources/McpIcons/NOTICE.json create mode 100644 macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/context7.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/deepwiki.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/exa.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/github.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/mcp.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/microsoft-learn.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/playwright.svg create mode 100644 macos/Sources/Lithe/Theme/McpBrandIcon.swift create mode 100644 shared/fixtures/mcp/featured-setup-v1.json diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index 0824630469..ebd2aa9ed4 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -84,6 +84,23 @@ 元数据核对后固定,远程地址按发布者文档核对;中文介绍随应用本地化。维护目录时 必须重新核对包版本、远程地址和文档,不复制 Magpie Image 等产品专属能力。 +精选包含 Context7、Playwright、Chrome DevTools、Microsoft Learn、GitHub、DeepWiki、 +Exa、Filesystem、Fetch 和 Memory。2026-10-22 核对发布者文档与 npm/PyPI 元数据, +Filesystem/Memory 固定为 2.0.0(需要 Node.js 24+),Fetch 固定为 2026.10.19 +(需要 uv 和 Python 3.10+);GitHub、DeepWiki、Exa 使用发布者的 HTTPS 入口。 +GitHub 的令牌和 Filesystem 的允许目录不能猜测,因此只有版本内的精选可声明 +`setup`,让编辑器显示说明并阻止缺少必填内容的保存。GitHub 预置空 Authorization +请求头,用户填写 Bearer 令牌;Filesystem 必须在包参数后逐行填写至少一个绝对目录, +路径中的空格保留。公开 Registry 不能声明这一可信模板类型,原有复杂输入限制不变。 + +卡片图标使用发布者的品牌标记,不再让所有远程服务都显示地球、所有命令都显示终端。 +图标源文件与来源、审核日期、SHA-256 清单位于 `Resources/McpIcons`,Playwright 的 +Apache-2.0 许可一同保留。它们是 Git 跟踪、SwiftPM 打包的固定输入;运行时只从资源 +bundle 读取并在内存缓存,不访问图片服务器、不下载头像、不写 bundle,因而不改变 +代码签名或 Sparkle delta。三个参考服务器共用 MCP 标记,未认识的条目保留传输方式 +图标。只按可信精选的完整 ID 选择有限文件名,公开条目即便伪装成同名也不使用品牌图标。 +深色页面的品牌标记放在白色小底板上,保留原始颜色和几何;不是工具栏动作图标。 + 搜索时使用官方 `registry.modelcontextprotocol.io/v0.1/servers`,按名称搜索、只查最新 版本,并使用返回的 opaque cursor(必须原样传回的分页标记)。这是一份目录,不是 Lithe 的托管服务器或插件市场,也不把收录标成安全审核。Core 的 `mcp.parseRegistry` diff --git a/Package.swift b/Package.swift index c43df490cb..dfa5cec9e4 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,7 @@ let package = Package( path: "macos/Sources/Lithe", resources: [ .copy("Resources/AgentIcons"), + .copy("Resources/McpIcons"), .copy("Resources/MarkdownPreview"), .copy("Resources/SyntaxHighlighting"), .copy("Resources/WorkbenchBackgrounds") diff --git a/docs/ci-builds.md b/docs/ci-builds.md index f17a1080b5..3cbb51a42f 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -371,6 +371,13 @@ MCP 发现页读取的官方 Registry 在线响应登记为 `mcp-registry-metada 源码编译进 Core,运行时只读;不从缓存或其他工作树复制,不登记为下载/生成资源。 查询路由默认使用系统代理,显式直连只改变当前页面的内存请求,不产生可复用状态。 +精选品牌图标 `macos/Sources/Lithe/Resources/McpIcons` 也是 Git 跟踪的固定输入, +`NOTICE.json` 记录每个来源与 SHA-256,随 SwiftPM 资源 bundle 在构建时打包。 +该目录与精选 JSON 一起归入 `mcp-registry-metadata` 的不可复用说明:没有下载缓存、 +生成 identity stamp 或跨工作树复制阶段;版本由当前 Git 源码和清单哈希决定, +不从其他工作树或已安装应用提取。运行时只读并在内存缓存解码结果,不访问图标网络, +不改写签名包或 Sparkle delta 基线;Windows 未消费此 macOS 资源目录。 + Cargo 社区插件在 [独立仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 中构建, 最终 ZIP、摘要、vendored Node SDK 和集成测试产物由该仓库的 `dist/`、`.artifacts/` 持有, 不作为 Lithe 主仓库的下载缓存或可复用资源。Cargo 专属清单、模型金样与兼容性测试也由 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index ddf362edda..ea37b41ede 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2307,3 +2307,15 @@ "The Lithe shared instruction markers were changed. Repair the native file before syncing." = "The Lithe shared instruction markers were changed. Repair the native file before syncing."; "Shared instructions cannot contain Lithe synchronization markers." = "Shared instructions cannot contain Lithe synchronization markers."; "Instruction Agents" = "Agents"; + +/* Featured MCP publishers and setup */ +"Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required."; +"Ask questions about public GitHub repositories and read their generated documentation." = "Ask questions about public GitHub repositories and read their generated documentation."; +"Search the web and code, and retrieve page content for coding agents." = "Search the web and code, and retrieve page content for coding agents."; +"Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "Read, write and search files only in the directories you allow. Requires Node.js 24 or later."; +"Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later." = "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later."; +"Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later." = "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later."; +"GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file." = "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file."; +"Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; +"Enter an Authorization header with Bearer and your GitHub token." = "Enter an Authorization header with Bearer and your GitHub token."; +"Add at least one absolute allowed folder path after the Filesystem package argument." = "Add at least one absolute allowed folder path after the Filesystem package argument."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 50e7516b11..a44c7d1966 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3882,3 +3882,15 @@ "The Lithe shared instruction markers were changed. Repair the native file before syncing." = "Lithe 共享指令标记已被修改,请先修复原生文件再同步。"; "Shared instructions cannot contain Lithe synchronization markers." = "共享指令正文不能包含 Lithe 同步标记。"; "Instruction Agents" = "Agent"; + +/* Featured MCP publishers and setup */ +"Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "访问 GitHub 仓库、Issue、PR 和 Actions。需要个人访问令牌。"; +"Ask questions about public GitHub repositories and read their generated documentation." = "向公开 GitHub 仓库提问,阅读自动生成的项目文档。"; +"Search the web and code, and retrieve page content for coding agents." = "为编程 Agent 搜索网页和代码,获取页面内容。"; +"Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。"; +"Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later." = "抓取网页并转换为 Markdown。需要 uv 和 Python 3.10 或更高版本。"; +"Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later." = "跨对话保留知识图谱。需要 Node.js 24 或更高版本。"; +"GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file." = "GitHub 需要个人访问令牌。请将 Authorization 请求头填写为 Bearer 加空格和令牌。令牌会保存在 Agent 配置文件中。"; +"Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; +"Enter an Authorization header with Bearer and your GitHub token." = "请填写 Authorization 请求头,内容为 Bearer 加空格和 GitHub 令牌。"; +"Add at least one absolute allowed folder path after the Filesystem package argument." = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; diff --git a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift index bf333c1f4d..0e74e019a3 100644 --- a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift @@ -104,6 +104,18 @@ struct McpServerDraft: Equatable { var env: [McpKeyValue] = [] var headers: [McpKeyValue] = [] var enabledAgents: Set = [] + /// Present only while reviewing an incomplete featured template. + var setup: McpRegistrySetup? + + var setupMessage: String? { + switch setup { + case .bearerToken: + String(localized: "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file.") + case .allowedDirectories: + String(localized: "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later.") + case nil: nil + } + } var isNew: Bool { originalRowID == nil } @@ -145,6 +157,15 @@ struct McpServerDraft: Equatable { env = definition.env headers = definition.headers } + + init?(entry: McpRegistryEntry) { + guard let definition = entry.draft else { return nil } + self.init(definition: definition) + if entry.source == .featured { + setup = entry.setup + if setup == .bearerToken { headers = [McpKeyValue(key: "Authorization", value: "")] } + } + } } /// Project-level Lithe registration outcome, shown next to the access section. @@ -360,6 +381,20 @@ final class McpServerLibraryFeatureModel: ObservableObject { return String(localized: "Enter a complete server URL.") } } + switch draft.setup { + case .bearerToken: + let value = draft.headers.first { $0.key.lowercased() == "authorization" }?.value ?? "" + let parts = value.split(whereSeparator: \.isWhitespace) + if draft.transport == .stdio || parts.count != 2 || parts[0].lowercased() != "bearer" { + return String(localized: "Enter an Authorization header with Bearer and your GitHub token.") + } + case .allowedDirectories: + let folders = Array(draft.arguments.dropFirst(2)) + if draft.transport != .stdio || folders.isEmpty || folders.contains(where: { !$0.hasPrefix("/") }) { + return String(localized: "Add at least one absolute allowed folder path after the Filesystem package argument.") + } + case nil: break + } return nil } diff --git a/macos/Sources/Lithe/Core/Ports/McpRegistry.swift b/macos/Sources/Lithe/Core/Ports/McpRegistry.swift index d7a86d1eac..625096eb1b 100644 --- a/macos/Sources/Lithe/Core/Ports/McpRegistry.swift +++ b/macos/Sources/Lithe/Core/Ports/McpRegistry.swift @@ -10,6 +10,13 @@ struct McpRegistryEntry: Decodable, Identifiable, Sendable { let draft: McpServerDefinition? var publisher: String? = nil var source: McpRegistrySource? = nil + var setup: McpRegistrySetup? = nil +} + +/// Incomplete, release-reviewed templates; never accepted from public metadata. +enum McpRegistrySetup: String, Decodable, Sendable { + case bearerToken = "bearer-token" + case allowedDirectories = "allowed-directories" } enum McpRegistrySource: String, Decodable, Sendable { case featured, registry } diff --git a/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt b/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt new file mode 100644 index 0000000000..4ace03ddb6 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json b/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json new file mode 100644 index 0000000000..c3a7b237d9 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json @@ -0,0 +1,46 @@ +{ + "reviewed": "2026-10-22", + "purpose": "Publisher brand marks identify featured MCP services. All rights in the marks remain with their owners; no affiliation is implied. These fixed source assets are bundled read-only, never fetched at runtime.", + "assets": { + "context7.png": { + "source": "https://avatars.githubusercontent.com/u/74989412?v=4&s=96", + "publisher": "upstash", + "sha256": "076a02c39aee75f059d4ddd4fccac6350e601b32f8d0659c0cdc907cd7800f4b" + }, + "chrome-devtools.png": { + "source": "https://avatars.githubusercontent.com/u/11260967?v=4&s=96", + "publisher": "ChromeDevTools", + "sha256": "be958c77f44d61b821ce9476c68a5d65a16b52430fafe7160ff1ac83e822caa6" + }, + "microsoft-learn.png": { + "source": "https://avatars.githubusercontent.com/u/6154722?v=4&s=96", + "publisher": "microsoft", + "sha256": "5fd80b0c551b5ad5d40acde0604fc0866bed3cdb0371e10b6aa2930e57bfdf8f" + }, + "github.png": { + "source": "https://avatars.githubusercontent.com/u/9919?v=4&s=96", + "publisher": "github", + "sha256": "4a309065f9744816e3bb1e3e002c4e947929a8d76aeb1acd6f99ec3a2834ffb9" + }, + "deepwiki.png": { + "source": "https://avatars.githubusercontent.com/u/163493330?v=4&s=96", + "publisher": "CognitionAI", + "sha256": "4dd715338111d28fe1f3484a0bf73c04291b367511cd90630a20154b7739c1cd" + }, + "exa.png": { + "source": "https://avatars.githubusercontent.com/u/77906174?v=4&s=96", + "publisher": "exa-labs", + "sha256": "c7fe707b4c5950fb00276bcab65ead9d7ce3c23c1e9e6adae1f0b40ebfd62e88" + }, + "mcp.png": { + "source": "https://avatars.githubusercontent.com/u/182288589?v=4&s=96", + "publisher": "modelcontextprotocol", + "sha256": "a298f5068002e0f8c62e4cb6465f171b641111bc8cde9b8cb52055d1fc7cc102" + }, + "playwright.svg": { + "source": "https://playwright.dev/img/playwright-logo.svg", + "publisher": "Microsoft", + "sha256": "6b0a4367bdeab10995bc239278f04c68c10e48adbec15e799e01909a0d66dcb9" + } + } +} diff --git a/macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png b/macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png new file mode 100644 index 0000000000000000000000000000000000000000..11e24797d514cabacf3aff56a70d3fe938242c52 GIT binary patch literal 7315 zcmV;E9Bku>P)2n&VW^jrUZ5E_Um#~XyQJd7s(tH<*H4bcP`o{Pc1)g>K_DCb(l@&f!H>j(f~ zp^)+dyvf}oA5i#<6Tv^Q2DvkS5OJ|h;>4bB(CD#$V2vg~Ssr%0K?uaCEHw6vuQS#l zvd*=LaS9!cJMZg>wM1k^DV{-JL#!o5R+R9h*Ysem5Wnd*WbcDfFf11znvLbz(JV8X zWkD$du`}um^RY6^E8eiu6_i@N@}&;3(Je(1-tA2x3x$;BVdq-J#I@5i&Qw=Mv39&c zxQRS+nvl6;=tbk`vBe~g8NEuBz{w_lM>V(m6zAt|+F&5EsvCq<+yMX}1y8i;-$G?@ ztH=o%JUGtPKmaJq!)J{m?-@gnDj={JSj!6N3P@c+*&UKu9w3_BQxw3ENVWxanUMij z4DBlnYnp|3&u|AX@aI~4r_~QvgC;f(z$!v-b~iXtniRE{G<49W;=2mlMJs8zGfWqGO=jELa<8t$Ew+$VJrKAn4S z(3_`lFm*UNzlxq;#o!vF>f6QjdxP)SM5J_6Hi)d==@?{?L97kW#hzVkuPP?Ac}lMz zX15$+ultOKFER~x6uYpclbB_b7`UKLUlX3#?mvG$C2Wi%D@qwKC@t0=7*GG|UYm(j zh3e6(!irZt-V6gY{fh_LXk_zB$Hc)T06>ty>aD(=C%MGaGD23AFzan=jGxU*perbs zdiRr1iHWy;WO?6pUnvqn(>fNjUv z6`Q>E9U?^_{fO`Nk3j$chY3B=C~W?M&9$NBg~aGW;`U+W(W?R+gM~sy=Bs{II1^(9 zQW94qEyen4qw?|ntFx+#2|)sDw)ed;HJhGt`X zR=FqwY4b_{x+hTIZZv^`0FZA-hh$@{0NQ*~U7Hvy>~n2OoE9T6kYh!M<=`{{u69Xn zKG`6j>f6vpXrJoR^aPZs`J4DuZHv0~o=Z(ZhC|tFYDvNLe zSB-$`Mxn*qM-?U|K>{sa`9za&yip)AP+5cz%fX0rUM`Ga95~Ofe9Z^3_WTk%)vv{n zu@(bB)#!JYQ-TB@-R%AHijb(+7y@C)UMCAbg~(*pnGqjmrP^; z06+Pg|6m;tzto9C+8Ly(m}vLQXIsRZqTm7%rKUw}^GTIOgq?|hT%ykU=mP(fzxe?G zOC~b+kEb(zTxJX!nU8H)Y=`zvoX$#&L!t;6l8qI+u{;ciEFv1+((DcHq$Vl!*E#~SnM7y;x$cvV z_zf5W3Z1Caj21dDZSonI;JG%;N+aQj66TdWJKESKv0{>enMvg3Z#zsRa;izV=Y?*? z@cY7@CZi_D1(o=SScAi_fT z(;~~1s)~sPRn#XpghN`woo1sWTQkQ3ee{p(B05)*m@1X6G_cSpjwOFn(}$CHRnV11 zxP|(E#FtoFHPdpY*=z7*++YpHcW!5f<>GKj56Z$mJfHjxpa>W<5Z`}3Vx?3?*5sCB zk%#~=YZUo|8Rl_=NchdU7U5WfaHd(T?-1QVnbG7ID}XEuIwB7*FTigdLXODq(a#NC z;tK~tA6?*My-*8{lxAZgUbQ51tmxTRQBDm|hD3J%z*&}uFC0s6*cUP~aYHi5MkA-5 z&ZdYSupGO}FMQFH^vogISSX^X$TFjyemOQaf+FCzi>ym0nc$fx8in6}5Ij`hhtWyH z9aD#qKflLPUf3i2cN}MbxYZB;jz%!DZKx+ClQ?1~kt-pKwc7OZNo=Y97>RvRSd1blXVL}phR=Zy|Q;40` zld4;evJnyB7+6?kNaY(^L7)6{R%s5tvzlGC)xYUs$Lw;dwpIM*bM9C>m?1&sK%%-) z5Trg?4H^dv#?n8VYt6EtK2~1$LGX>kEVL&{Og%JnBsF0Wk!O$BzO7!Vx=}cIA+q}v z#|k~?FT0g_@-B;=LES<5SMLROo#dds6Ajvv7#KH*IMpPusUsUNF0(Bh%UtRZr>}FT zRcGl}(5M2eRLe>1I?X8x_~8~`m{&#>;Q#XubL?q@vhcb#QAk+bW(tAa5hSqe74NH` z_i$&*F!Ix<20XXKKChBa^?}BErupfBZPKdbZ{JGtKYS zaF;X>kiY=EbTT_)qKk>IR{yTcQa>QIv=X zie1>m!Nl}3YV<&&$c1fM;n;JEd+Kj~AFI@Nh#U3=SI@R&SmQCRtlY} z>z67S`$-+&6_f{9(3vBt3vFTj>8JO_{&k@>*M|LWZ|KO?o>hCQNnA9ZcA3#)7xuRH zHil$lEgm_Vw1B>tpH-e)Vp}kl@v-u~FLv)g7b!2qhh$@$S2`zX@#CE**cGpO|MV&Q zclmuN1j3cPKf2R8LR=)5<7^34Q%=niy6Zh}meQVjv;O;LRhw zH(~|ll}}a1&m2jO%Eu8+nplq9Xo&<8L$*BTD#^lpti0?M&kx@8 zH+#|;&5dqp)fWGf|LFFFtp4TEPVX~HYksu-W31 z*JIEy&HRxS0HK~p%^vyn!>UKK@^*9VdY)ZQn<=EWRe-kg{3^Q0g^M!yU5xuzCup%odU22!0eK0gz&(_gnP=P}|+7-~N z?%H=Y5{f7k4j!9kihg;QhOeVH%mO&{v(2h5kG)pE9x_!vtJx!0H}Fu0!f)V%?imx0 zGEvq`AMuhpHJ~LCbeN!bY=Bk~;120&Vn3@qwL>-47Edt2U>$;h37WHhuC<3j69y3) zI+DbLQ(;ZNZtzphWQDhlN|OnLNG!u_T31k=T9$6WNAH+Jvk~B3l3$FdM%UXXBfC$r z6b`mL=CsjBp#!yOqVYnTsBsyAo*gRIv-|49KP%E^DC!YwKH! z^O^SoVP46#VUJHwV%I|1bhF5K$o;Dmu9J1}vv43ts&lmP zGYXHk`{ng}gTI((9aYdHJHx!(?w6aodlnQY=w&!yw(poGr;1#dem$g#?0K(E-BP<> zrf#6JJ3`}nYMYQ}06K}-#k|=}NTIW|j4WDi4mBa(Aed4FZ zu9W6Rtj;oGLq=}};XL4m)awky&l5v|PIjBou*nK~3sR3kJwiUU7F2aa-=gQAza5Bv zgmz|UK!%>$xRT^EPSjaxZAd7pTD=lh5(&v2WZFWN2YZdq$&6T zx>^wFd-G!rLe#T`-Uj><05CPdpVYAi^=-)MS_}^=djLSW2I^xCe4=d97ku#DcLntj zs5g125e8T>GPScJDe-#r`2#)vMAVQwa9%YL#|g6ai0t(-J)`tu>emYfsF!;HYX7>^`M(0JRRT>s@B3-mS;^98w9)j&&iXPAi)CD7#VFT_L{oNVG_rAZcYLz1uBD1Hu3sqWEw+pN3}Yu+e%ZadIf+honn4M-e${DwpMdi z*DmM@+^pH|Z*+@x2Hm{Uful%9^~%ABLSV>WRyv$!^jf#{)DAyn!T+9b)shIpfeY~r z%ClovyYx0Dz)8Ug>QEX6y?2I_WH7pru%!;oUH8d}PLQI2-+T~+P+xW{1Ht&@TYgCf z77E??uwzPUrYF+$GIG-jhlN5V8T@Fg4+3Y|tqj~m^oN5nGU_qyqk^5_t$37w=ta6UuejqF0$+!Fy2a^LV=<3;)gBKz- zEuxQA9{;QF?GxPE1=b=LR#i;E{6{Lf)-A1h%m3MpN{5jJ#Om1=Sm?U(P&jc3I8BHs zgG8kkrK!WoEQ@;mK?A9$^xk}3y99&w3&-kZNE+SJ$b3A}q~rwUnfC%+L7Bk7Ti3Mt8;?35cl@lJC}Igr zT|N!E6)-T_cd0|HO_r^RBSY044^L)(^-s2l2qr(%sk8aZ#vmmwtfobbR(C}AXVE0G z?ta_d6+I(cyE_2WK~c&wqXiDMNDCfa(|q)fzMki_;pEx{R#?OZ>F00z&om2g|5~)V zqoNRR>XvlOpCT9f+5gJMQSggh{*8yy@0~DIC*9zdzB|=S;vm2&UtUQG+K8wM5fVov zS#^{J35*{^oNX4v0^kI&=X9jRg^em8TxRs%TbR4bsRp+c4Ti9S(h-pToO0bKU+I!! z3x;nWM!dMp{{5Mj*x2ZW>%x<71^lcseh|?Wl+6^v2@1?%wCR?Oes7xT8$(G>P*%O^ zH?q~-5Q8{D_1bS6N)FAzwjE=2c4hilr8EbV6=0%}JUbemTbV+on^>rNx*0RaBI+)Q2|c=;xexj8$%JW?ta_E!DM~A_{eL9i#R>HS)EvV{y^}>WsZr1$tk7e zJIVGuLbA|^R^280BXTNBe@8XDqnaf!WOg}q*Jvtf8E}x3k2MJIp5_kL@w~?9UoNx~ zn5v$h`!E!{T|uM~KPPj7YL4mn7K_F)aNFkky+Px%#*Np&?>ZHE^md`Dn0R!mdHXRg z>AnJ*07J5|&n`vGB$8!Dc~QTRTzUO4`}$!P0ie){=2$W4_qKQ?Pe^I;NU>-c=tSp? zrnM^VP;Ajmo(*-G(JwFaAzsO`qMd={`?4(*vSNw}Zu~W}IXX%i1nm_C{CJx$%qt~X z*f0Odnsg975e#it%b8};Od=Hn@wA(B6fIwRqDiQ3RDVui6PvoFxH1Hv{j#U)m)_(OQGLy)r z6?QFV$bET5FjL6r0zB7-ooq_tNhQW1XN(}9xz7fRzIL8S09=(#dzA-Aj>P~uZtJj#5_A%RfLzg(8N*|9s0pcQ;`c7B=DCn!c|3hp#!b&Q16z? z+K7!qjxEMt`j(x*z==lTr#t=qm~4L-6cNFyt-gq$SZQ?Q!;YCFNh7gAK?3z1(&=lW zYCx6{w-ghakDBHx2ZKDS6(4MhH~F;x9hlGPuFCaN5a$~c&i zSTV&!6RJ`EZ|??L4Y#-e0F>rpPtUjJ+OSryw0x8Ia^`J8A@yfVCM1?uu6C&sF<-0r zu5A^s-f$7)wqzzHUIczlbuZfe>W@1p`&d=l1~{eJBeiSaJ50#h1vUahR&DWpaaqss zjSF|o7(u?e!r?Nj+dMg;?9P!nt%}#v2}^BrAZIRPaVb z^$%FK@`ndix8U#u?fW&>-#wjxfL1e)QMl4A(*mo|{&?((nPP@$L^bu5f0sekXBvZu^ zWzfU7F>@B%m3{DP@Fq0%BraiVyC<6wi z?J#q`P277fQdvag*|9<=wqzn>r;%&jvQGFajv#|D1pIEXP1{Hje*LhX^vSoQk4-cE z?g1O@c6F*rcxa>NP!04YSgWadt-KV%uFQ!S+ z!IC-*0l!{kn>ms?+ak>UeRtBCkQkU&MlBpmeY1jr{S@ljgy#>0cAZS0Y|FG~ogq8O zNWZ}-NecM<5?_1WJ7Wa()4MGcf!uowbMGxom)7L%P2DDM0H`yE^}R113?*_WvB{e# zgWrD`eCH(R2^nhZk!gQO18o8`?1~oKCeTJB4@_Vl`UaEOCh%OVSg*ASj9pf_Hgwx} zox^hROC92jb#6&f7mL=g6CX1Wzpa!U9n&W8a)-F-^YA+-Id8bXZ32z-tTWVTGLx=o zNqgFt7vOh{AnzGNSM*Ku!$yCk@&F4O*Jx6bL2aw>?iudG zTE4EWH(uGFMw7;{rpKWEbw$%}`fsC=`Bhr0(vmd0NMcEp!O2E`M>Y4}nMl&U4E<~R zZ=^qDN(S`$dOUpM&o zN;>{p9|jqz8~l5vRJF&w4EnkuE0L07t%pHhPh=%hl0I7SSFsEGdLk>4;;`1kpo{Ha tUt}dxEY|)D8bJNGpeWYIWZ}|WK002ovPDHLkV1kilC?Ws= literal 0 HcmV?d00001 diff --git a/macos/Sources/Lithe/Resources/McpIcons/context7.png b/macos/Sources/Lithe/Resources/McpIcons/context7.png new file mode 100644 index 0000000000000000000000000000000000000000..22d42ec2980d452c64b2944e4991f0ad61ac6d9b GIT binary patch literal 3157 zcmaJ^Wmppc6Q1AzVT>US4kSm&2nQ%VI;25TN@*A^9TF1K=%jIoq=bmT02xe4=@4|Z z6Ooe60UzJb@6Y$+?w7q9P z0J^2EhBOT>*eD1wW$Zodt!_;)i$^}!WAsHbjwO;c2xhTLQE1!vq#`F>m&H&gz}GBN ziXSVJ=vi!;Z>sX}2%g$9LQD~^;t~*FZAQauNM&ZHY_qbqd2S-4pemQsxr+N=!3F7E zvj=M7L_g4M&U&g31Oi3Jh30I57yqU4WdO32$+XCB+`hpV{eMBj7C0IZSN}SGc2EWN z#4`opj`FOkc^_)?XhO7$Bzipt(%RA%qF*3BPVlcFXkqa;y<;K5Vv!mC2R#SHk=U07 z#u@(QFC5qBV{-Kt6rXX|U5Dp+do7fka1D-h6f#0zLzqm=E({x2m4m1eS4p-8en(D} z_3cwFYMc10m~LclC*tL>rf<()pdfvCs}Ih-O+tIlV}5=9vr%BRh>NX+gHP#hC!XcH zKu0G}pVc*URrB1OJmVl+a}y5c))UixqgGU#{u^7ry+^%tsr!eJ19-ng@6&d+pR3I&!u(VNhpzrTv9MIO8zAm<9tf-n&#M=RbLVDXZxsr>%t z@O3w54bO~#dt0BUAO22t-)Wc0@q55v}`VB5$99a05(`3p&5_mx=J^%h(8fkYl${z%(=+n7{BioPrw;`PoO^nmcx;||k1#7M zdqx}fV<(yz}NKf#dx!gI9?w6ev?5JENf;3TtaHt|dNRS2r zw@=1oVv=_vQ5U}_{%rS%bx0=6%w5;^goommDChuIl1Vg-&GO12%zK5hH)J9&50G&g z$wm$y4D3yh3X9utF6TKJHa=%?k;l-&gkv1Z5!Xddfb&bUc-7#>=Cc->LCoe?P2*+i z{u+imXlhz>MN$pjT6qRrT|e9^zT0+` z$z1&cUh$t=`2u1_8MPc-7>dIlbz8j5R|<=B$9L6lob1~0GCXDqQ}2&epU<8Y1)iq( zba1sL%fL)|9f{aLC_C;^7M)?k}f zE_t^uF--gg$uO+2ggLrQxe0vk^EmRm*X+HX5lWe&WZA61-+3$6;;;R4nCu{M*817B zMg6Z;QV5{rKd)yTTxj%C&mny?FAGEVCoE0asj$56_}E-h5D!by{t%Y~}q$`dx;baZe`6n=* z?UHo2FwL450SvWKJ^smDnoUJVz4c2Y=+bxda>Y4#rx&}@w>|7sQryKHQS+4iuc~Z4iJi6+ z6gDmxA;YdsInf#6;qJMiPD81P2vaM4o85iHUDaAJfXfy7epDRKLt{JKmD(3SYV1-0 z9yhS^tF2U&(-7z>Vt_5C;(l~kcaJy0x*`KWvwZt%M1zVYymj0M&_VOolf}F@@mcgD>xANhDL9J|XZ^V8tii;S$;xW#s=ng=|0ZVCvIYP>RJ*0Db1{Z)l{1W+0 zOmM@~_bfr=0=JA5>L~6eZOhH)%!&z9?FmSlw++>hzXT2pw-*(^lZ9VVjw0da(*coh z1Je)k!{nE(UllqY*Q4tV(F`5Zp0}a?5@nX17`tpmjw1yW2xMtL_u7YALP^AB`e$m~ zoFW@Dt3h5zcO7AZMew4HwLQ|+)3HG!{56_0SdPZNs#T1lYHb@`k>Ea5`l<(JHLu07 zO0&O>{QJ(t#kcK<_(xzQ7n4ef$jpFuhQ*l))=R<~-`7qH8*V~=Z>!9-4(ZXxk*$`A z9R&=+65kZ{kOly=;E5ZQZ|Q~f$obP{>8l|nRHlA);(ooHvnbam`(sPwPis{tBOu7m zcpUij=D19bPTtQ%Y@}jk-QQ%3Ub~W`aI~IIi*YI!OLxtZbMVsZj6xs_mYsfL$2$Ia zS_7n3q#taZqkgZe{4NjY8}?O6gyd#G?_;W@A$7vqYO--*cEl?iG)Cb$*Aq z6emxGQvyHe>zs09a>XcNcJ8Y*Pf_~XCA6{@GqzoRYbsp!I*^UGwdg|0#^q<$+|uLS zsFc(;55T?%Rf{6PNQFncVHk!9XyE-5Gpla+F{2`n=_l-OlW1<{aBY4G+%zTH$+DHx z*DnixTh(%%e5x|udyCw1RDVU$A^&Q@mX#inflPT_cuaD1f1Ex)Lp(YXCi}P6xoHP3 zXbj%0#KxbEkyFdWWQ8-V1CMYfm*PuzIZlUnE=%{;C*?PX04b-TPcCHEe z`%A-OA2KiKT6iYqY&^>-qqwZ6(*NOalIJqBg&|VG)QG}uK08FWG6yg>m94aR%7{;oV7`)Kn(FoZuuH2sHv*79dr zl@QSK)xB`gwT>coP%Dm-+xEx5GYdUcqqx)T2DXwRhp5)nW)Bg7)vnIlP>c|Sg}7HP z>y?vRa=#d0N&(1J8e|qnj%Ex`W*V9~LkHkyI6+|m&$7>?Wf1&I&2dZ+< zZ#>vYuTS!6)P%NIlV1*d5@l>uIh?KUoH1eW2K2II+-Re+O~5&09=n1OzEtFa3I%D0 z`8_XAG6|sk1v*MJBHi&`)Vk+6hY@mY>ZxBSU1h`Go(zRFWQy>b$7ZJC_3m>LU*YBW z>)%sMN;O?f&p<2>$-oc$eiT_0n7>o+_dHHh(3#0E2M+~8B&C+|=n_DX6}W#AQO_Ng zn*u-mMuVVqo56R*feejed)ffGf;0iaJ7pVF;sWIspg%;p^#*?h{V*QChg+%i07tW=$5V7^j*qf$zoWb`_0n$RV=GgC`8HoR-P<{ zh3mohuml61;QJg!S}yve64du~vQ|M$bxGzEkot(ymvksu!KPHIA(Pbhbep*7i~C`; z%e_&p1*pE2Obn^4WpdJ4*YvbD(*bxn%4zMW!HdUWn^q8N2?hK!0T#W@K4e}Vl! fI{cCr0JyxYgvvE*=tJOF?*c$uU0`-ZFm;3{Qz{pD^ zD5Bj$1<_-jl&~%X3VKKjW+E3NoEf!!c^&ZeT^%21e4J-?dp=jc^E}_@vpe(roH??$ zw>O57MvpoHt%C&tz=8l^K>)BI09X(JEC>J=1ON*HfCT}-f&gGa0I(nc+7h(e0(BPu zLo0%IcXwSbm!XBz>C}*>Jq-{D1ZHMtIu)E{qseLa`U zq0JDPph~5(y}dm?KHmQye0+R-dwaW5si4Xeji6vKND#!}&jErUg25n?RCP$!q|cl= zQ!15YU9z~iSS%JtmXRcAdNDx|RJa5RTIuFB3l;EB9T~HS`x#IMx)Q?Yc`v(+66~YDwVQWEZwcTa^=eV_wUJ^ zwSBkh^?Iw-S}YcO)@w4E4zr+l@7_rjn8{@RYLUrg`qX=v1)V&3a`+L7%7Rp#YRt~g z4&Upkynv;upyBOs9QWhL4@l_@gobAG(P-4vA{vcK#e~CQj^k8KOYzOA;2Cu8+_|1b z#bU92GZl-)5{t$5g?;|~d2Vh_K^xILq!r8?x&?jz{(Wvg~1Z!j1JNpX33 zIUbLCb;4HVsOH_CzHL78g2di4s; zR0B^|0YF&~Vp;akE_dJsGXM|;i9{k1V~a>6A{F!c_3J-Z&>+-mwLU%OC0-*Tj#D^CE z;Nvd$8DtIv8bMyK zSMG-eo;`au%8aCeHK3Bq;Gd?rckkXG_i2hY_~Rx6fj^G}rdq8&e*Ac{h12QmUME8Z znyH2q;_KokPMkoU$bV={kOsj6MF3b304xXq76bqb0)Pbpz=8l^K>)BI09X(JEC>J= i1ON*H@J|2$0RR7ph4WZnoJZ{d0000<(>{tdqfzsGr)Z$)~}=yJ3E3#3)wQh%P(4iIRgb*33vNPVcpH`-v{`04$p8@$-p+jsCTt zN>Z8;+EmM>uM-DEs;I6WoW+tg7cx<2grF_ znV@zJX#eQ>O9OeP`UdT?Lk~3qZ|Rq|!qWa7?Zu zt5dC~{ibh*4^Kl0`~G=5`dT=0prg7Rz3F!8i)ncP;L2cj{f8pL%#J^&+yWQN@hN4D z_^*Ff5WUL-xIYrs)BIqzF_MX?2{WMZnmNkma=A?3vk+9glf#T9M3V|2_@>SB!t1I^ z{1j^uom+!K^-EjY$RkQmnqTdc*4OIOkFr&i&VsI9^&1Axq3hcwmzV)q7xCHv=4OD9>p zbdH?sr+ZPbGnC3)nk+Hov7Owj8%g+5<-8XpEK2V1ta*hsoT zhtWOxRV)%SW1zWiBLW2H4%nAI@|Rb%=pG5{$A0a~Jv5 zbURGdxNY7qTJ;tui!m=Z3=~X;qExPgZc4>Js_5FQgL-u&Wx93YzY0m~%iNb5_t)<+ z?%jUbFv|2Q!B;Y?*udCbUKXxa)s zsvD6&@HEYv4@lsGxk=N?6I4FRe!RCj&q;!YMho`wp4OGBjDp3`LFESv3&SoGg>pUK z-k$C=O+69wTFTzHGl<~2IJFjzEB1?+H_w};ABU{({i!jMLAV2$?s(`@p=)#A=I9>R z0yn>v2QHocX|=+V7!g~iSl9WGQ>I&D_$X28nM?LxdN=-H#{7*Ew4vTOQfVpy-U#zE z^t8c2&%k>%Uur&ESgT-LZ^oZ&Y#MJ-p!^lsxm>vYPpoCMaC;qs@Q-(GcPd@A!B`h~ z+qI#SQ@(M{sUgX9R>Lp6NVX!N{0-G|?uTvb|a(x;*b6St7l_l3XV`=Q%a^O*%gyhiVv)OUk7spAxWtua;EovS4f-c^6E75$Q3KYH_*3RB zD|q#yo3$95Sm63;uB7&$ve&zbb ztsKIzOYbYaF30AZoGzNZrMda9@VW7We8kb!+KU+dGkD-YWJgw$$k%korVZ=H-O z<`}f5y-Rqlv|*lsdWFb*f|9(ck_10;nS9^i;Mi6WB03okS@W+B*}80tHzy^>p-&1h z9NT6LF5PL=xt*Zd*OCE-(U8;};I{jdZXDe=SfSIsSc&g#c>F?XMwzy2Eih}GU(IR5 zr{gG0cXu)-5^2CvcJTtK3CL?DOvaY4bso?{>ECU-*1U z`l%;qI*FQn9;g&p-(vT)% znKe}q$Az@Um>7wwsWJ*?nB@JWj2FA0%9Gf|r6}G&BalPzNQ?M|R;fN$R=kuRFXP5+ zm$1JcY#o1fGP&&HqKJ@CL)v%3d2eEBm*tzZ$I1?kX1iPFc1_Gy*qWN`h2ZAZ}; zl?--2m0};Py?*N+$4u99v!6zw8Jnq-BnzZCW@~*oh}_{6xPI~7yIsz{{4R5dE*;qzYe7l>*RxZ8Ciro zTS@mdx?b%n(3;JJRGio0!WMRCz|%h0Bn!yxnPj=N?Q%K6AaJDoln2V|k6GM+=Yiqz zJ!*lD64QI!Pj+tpI&>j-a_+N{2$sK&@0MVR0RlNRsB_q}+AGr^OxV0floT>}k<#!! zYZIzHLF+JXl`3Fc1KqtV^c)q{3Qp_~3T&;hj!=;mS6M~K+86Pss#2GNNP_VGhLA_WfC&fy6A%CX+>htH%@7}%Z?(U|`s`YSocHX^vcX@evPfrhq zh*GJ%dGlsmT->Z#v*>OI*>E_Vty{NNR#yK0{Tnrxsf>+{J$(2uDk{px=HGvRaC37z zefo5FcemxaOy%d#pJ{1nE-o%~w~`QiK0hlfYh+}EifBw385ucu?%eFzv*~UzGCpI>AXlRJ8bc_rQ z4Q=1Po$e&2z~yoa3JU0r#+3a0e0zI)x>NZ-LZR^0t5bOZvyjJxN_wR-K|FB&Ye5fOD8%yn(p=z;qc+Z1RBdm{eic)_xtzn`FuXUEGH-D z?c28kfgm_Ic>VhI3l}aVU=E+o&d$2Jx~8TkwOZ}v<&~3@gRj@=bRi)jU%q@H-O1l@ zI2@QRk4B@J)K6Q!e0gbUsli~d{6a7o3}t0yQmK^9{`>5*+3b#v4&ZN_o10NPkZ4y> zLPEl|YuA9XXV0E(*sx*h|NHp(6c-n-S+mCY&xwf%xm@1f-rmyEqSb2q`udD-|2!T~ zAQ15Rd>C!oK<`{p{(9jScAOGXWkEsW*sHljJjs|)=di3a}OP3(pijJeB zqf)7aImtK24|SvnvfASo&7%$YMB4#%7(J3AZ3n?|E?c6KHd z0EqPTbQlL1hNY#YQQ`oX#Kc5MdlxTWgg-3osHH$4ICkt9q~ST5V`x z`a!0=c=3Xq!hAj-bv~=Pxf$nqOa&Ycr>?FJ$s~qhB9VxM+BzF%rATk-!(z>-BYYb?|yBkjv%0y}j^y;7lwaAf)bTOG}GRr-Rp1 zL{n1}yx7~@8(#nj$ji$Mz8JNGbKT1mx!C24768 z)xwLZ!(cGLi-CjdfPj$R%N-pZ;llud;uV!{{DXWVopv@D_5?B*HeMNzdzC- z2Ji|11cdrx6&o9iq$izSyLKVzdwP2C1%QA$J3Eo;#l*zWxFXlV!680A9_c5*1_A<_ zb`BET%rK~B@*xNzYD0h6t*tt8Ywb?Ov> z+8BoA<>kT8b`ploW}iKKmYBo*{CpA|`1|`4E01B=ty{O?mv9ik(b4h#{riONMMOl9 z)9H$eib6v}3ESXu zxiXoova-^`XpA5Yw}UAwE1O!Qyu5spOd^pe6bgK2ZEbC(rKJf839DDH2Hv4K9L|<4 zThi0htE#HBTCL@|7$JXhAvidAY8j+jE#+v z6^)U|$Vid{VzF3Ho;)#EZ_=W~B}3UHN*`;m zv9Wpn{Q0yd1_lPG+u=8F-aNwTs8lMeE?g3e#pbppP&ybyES=HOQK?i)ktMSP1_t)` z_fKo6rl!W#)s>2(1ky1W44XG^ros#usZ?t2>l{j@GB!4L+HXZz@&O6dbjHWW_wLGk^5)YO^&=`KE>pPijOJUndWfU2vjY1w5& z5O;U?!otF7|Kd)ZI6?TZ0|Nt74=9(*r`<#RFJS)s`MJ5drZd8wot>!D=cZ(2Wc+og ziHV7tnwm|UHc>kJM)|~^v1hn#+qSTSu3!oot}iW7X|;^L%IX;)X*-Me>D*H$o) z&&z5j3e~DXD?|l@R@{iO z8d0fuDGF^(@mec*iFVW0%Ob^+O45QAtuzTHP3Gk#5YGRcnaoT&m$S&TYvy~>=lhwN zCv!P71VvGbyqHxew*lOh2gIU0AQt5Tu_zCSMR`Ch$^&9i9uSN2fLKJxZ2;En?ChgQ zkNW%jM@B}%;czq>B}uZfva+_ecFUG6R;#tBs7O*n6gK+#^Ji;ot45>A;l$3OJbxB}PWuJ9_V3@{+1ZIMQmfS)Hf*q1ELyGh*RNkgLql)gyqTMugUSek z7#J8>w{D$?F2!C0S#;vW3Dl2LsdPG>fB*i4ibJ8$?%li5`Xx)2#9}e=Ov(c&nM~5s zF_}ysK77bBu)Djvw6qi&b@}pT@vO=Kh$(l>W^*tYWF7wM)hne^2@b5PsuItw)PR`i zm`o-vbkaI_@E|mNcz9SO!%_pH-Lte>Ep3O1L?RRlrBW&A^U29cg7}|vfD0Ech-6qg zKv{B!md>qPx5~@Q)4Sf_;2^Yg{rdIbmozpuiey_NKzwtjqoV^XGa8MFL;@^uI2>R> zeSN)1&ZPjvH+Q^VFIqe?F#*;bJ9Z3Qx@y%bk<3d0h*$3P_V%LZ>jr}%`xdlj%^H!+ zi+i~vl}epFdGg%3bI|7|lc}$-Z{^CB(C0UA-rTouUm}qJKO{-=?%lgkDu4d`8T>O8 z3Nb0cB_nt$#P1o7a(1GM<{>(|hcG^+fH3w-Sg+qq2iH|k)1ntqQ!c> zesXe>S5ra)5-)fBe!o_$MZetXbY}UH>gwu3(`wJ2J-nI{29Wf*LlA`5>t!|8*Vk8E zTnr9WDwShnW87O40FW%X!-Y;-mo8m`#=2ZC?%mB7kSw{=(b2)(VLTpRv}h4HeB;KA z+=tH>ko37jOXtUrA6BcCB+1pQSC5X4LQ9=aC-@EJ<>lNP%pDLux%2JYH?gWEDkWN}JUc3Nn0)YTjFg`xcvC~`t;gdVTV9;zf zqs4(h04)3Z^($1s{rYzr+;(0^je9lzhNR;y8466X#@ zQSI&RXz}^;=ee|;5hp-jzI=iH4O8w!B9X<57o&6}&K)K?YuB!wo15d(az>m0B@zj+ z?JW1v&z?PtZVuAt4lSLEiVEoF8W(ZRpto<|E?v4beTxn~*-0xNj~k6f$ky%Kx6w*b zbBC6WPN!qCon8EngW~acmZeXhK80*GH8r7S(&vs&r~CNvBd<;~67q^-TU#4wOQljx zO-(^H(&vuBV90ITNlQpTHk%D}hF*4&K6mCxM_@p8b#(O{1XNQ~16p)Co#0aRelfPW14>9hn>TL;U0SVH zaOv>yFnEIj=iC7$B%qd-7SO0#t)7{g0Sn%}duOxRHf`EOd#4IjhiQdd?tl__@C;2 z&CRsC6%M%rN|YI99*>8%b1uRmcR-0X1@Fw6Gda*Xbm$NcxdTeXS*|y3+&~{)Vv<^| zzJC2W?e|EZJD|j!NFEM{+uGW)KdoC@T57l3nda=sk~^TJm}wu4M(^LhZ@1fPYisp- zeQ|MdSy@?ib@i@YyYAh)H#V;0}4ApnCw{@ z;ROgcI(Pwcxm=j!4k%mz_4oJVlRKbr0o2gYfYqLrk(}@D$S4+zX*3$})f@(c;pNMh z)z#Iq`cmNXimr)?3FyTo-QC?d(7^=|^f-k=p?LrPy-Z#Z$-R&Nzf!3bdP3XY-Y&N< z3Zsy2YHC7P9uACf0W>x?R#H-eG7|OGH(Z1ZAc~>}2M2XJ9R#Sbuuxt`7y+UvYIJne zU@%~{ui*tK%?RC&6bgmMkw^qUW$?%Q-eCr5)VS;t8v{PjFpg-Ne8x zo5pz2Ny%#Gf~E(uHvZGSyYekg{t+n`^1M`^dm%Q%pYPn{9_{(NeU0w!j$UidJ)M81 zs$`hm_fM}huFH#;amP!kTdw}&AUD~^I(xE8`_%Kc{&vi7EI9w<0^KOUz`(@dz`(!( zbQ*&K0|N^~10KN(=e>Tn+}`u{@YbP0l+XkK D(Cmyg literal 0 HcmV?d00001 diff --git a/macos/Sources/Lithe/Resources/McpIcons/playwright.svg b/macos/Sources/Lithe/Resources/McpIcons/playwright.svg new file mode 100644 index 0000000000..7b3ca7d6c9 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/playwright.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Sources/Lithe/Theme/McpBrandIcon.swift b/macos/Sources/Lithe/Theme/McpBrandIcon.swift new file mode 100644 index 0000000000..15899a8bf5 --- /dev/null +++ b/macos/Sources/Lithe/Theme/McpBrandIcon.swift @@ -0,0 +1,62 @@ +import AppKit +import SwiftUI + +/// Only reviewed, bundled marks may identify a featured publisher. Registry +/// responses cannot cause image requests or choose arbitrary bundle paths. +@MainActor +enum McpBrandIconLoader { + private static var images: [String: NSImage] = [:] + + static func filename(for entry: McpRegistryEntry) -> String? { + guard entry.source == .featured else { return nil } + switch entry.id { + case "lithe.featured/context7": return "context7.png" + case "lithe.featured/playwright": return "playwright.svg" + case "lithe.featured/chrome-devtools": return "chrome-devtools.png" + case "lithe.featured/microsoft-learn": return "microsoft-learn.png" + case "lithe.featured/github": return "github.png" + case "lithe.featured/deepwiki": return "deepwiki.png" + case "lithe.featured/exa": return "exa.png" + case "lithe.featured/filesystem", "lithe.featured/fetch", "lithe.featured/memory": return "mcp.png" + default: return nil + } + } + + static func image(for entry: McpRegistryEntry) -> NSImage? { + guard let filename = filename(for: entry) else { return nil } + if let image = images[filename] { return image } + // Reuse the packaged/SwiftPM resolver's damaged-app fallback. Assets are + // immutable source inputs, never downloaded or rewritten at runtime. + guard let bundle = AgentBrandIconLoader.resolveResourceBundle(), + let url = bundle.url(forResource: filename, withExtension: nil, subdirectory: "McpIcons"), + let image = NSImage(contentsOf: url) else { return nil } + image.isTemplate = false + image.size = NSSize(width: 28, height: 28) + images[filename] = image + return image + } +} + +struct McpBrandIcon: View { + let entry: McpRegistryEntry + + var body: some View { + Group { + if let image = McpBrandIconLoader.image(for: entry) { + Image(nsImage: image).resizable().renderingMode(.original) + .aspectRatio(contentMode: .fit).padding(4) + // A neutral tile preserves dark publisher marks in dark mode. + .background(Color.white) + } else { + Image(systemName: entry.draft?.transport == .stdio ? "terminal" : "network") + .font(LitheTheme.uiFont(size: 17)).foregroundStyle(LitheTheme.accent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LitheTheme.accent.opacity(0.08)) + } + } + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(LitheTheme.settingsControlBorder, lineWidth: 0.5)) + .accessibilityHidden(true) + } +} diff --git a/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift b/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift index 3c6b8eb452..799a7ce436 100644 --- a/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift +++ b/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift @@ -4,7 +4,7 @@ import SwiftUI /// opens the normal editor and never starts a server or writes configuration. struct McpDiscoveryView: View { @ObservedObject var feature: McpRegistryFeatureModel - let add: (McpServerDefinition) -> Void + let add: (McpServerDraft) -> Void @State private var query = "" @State private var request = 0 @State private var loadMore = false @@ -80,7 +80,7 @@ struct McpDiscoveryView: View { struct McpRegistryCardsView: View { let entries: [McpRegistryEntry] - let add: (McpServerDefinition) -> Void + let add: (McpServerDraft) -> Void var body: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 12)], alignment: .leading, spacing: 12) { @@ -93,22 +93,30 @@ struct McpRegistryCardsView: View { /// Two lines let users compare servers without opening every documentation link. private struct McpRegistryCard: View { let entry: McpRegistryEntry - let add: (McpServerDefinition) -> Void + let add: (McpServerDraft) -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { - Image(systemName: entry.draft?.transport == .stdio ? "terminal" : "network") - .font(LitheTheme.uiFont(size: 17)) - .foregroundStyle(LitheTheme.accent) - .frame(width: 36, height: 36) - .background(LitheTheme.accent.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + McpBrandIcon(entry: entry) VStack(alignment: .leading, spacing: 3) { Text(verbatim: entry.title) .font(LitheTheme.uiFont(size: 13, weight: .semibold)).lineLimit(2) - Text(verbatim: entry.publisher ?? entry.id.components(separatedBy: "/").first ?? entry.id) - .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) + HStack(spacing: 6) { + Text(verbatim: entry.publisher ?? entry.id.components(separatedBy: "/").first ?? entry.id) + .lineLimit(1) + if let transportLabel { + Text(transportLabel) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(LitheTheme.settingsControlBorder.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + if let message = McpServerDraft(entry: entry)?.setupMessage { + Image(systemName: entry.setup == .bearerToken ? "key" : "folder") + .help(message) + } + } + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } Spacer(minLength: 0) } @@ -127,9 +135,11 @@ private struct McpRegistryCard: View { Spacer(minLength: 0) if let website = entry.website { Link("Documentation", destination: website).font(LitheTheme.smallFont) + .fixedSize() } - if let draft = entry.draft { + if let draft = McpServerDraft(entry: entry) { Button("Configure…") { add(draft) }.buttonStyle(LitheSecondaryButtonStyle()) + .fixedSize() } } } @@ -143,6 +153,16 @@ private struct McpRegistryCard: View { private var summary: Text { entry.source == .featured ? Text(LocalizedStringKey(entry.summary)) : Text(verbatim: entry.summary) } + + private var transportLabel: String? { + guard let draft = entry.draft else { return nil } + guard draft.transport == .stdio else { return String(localized: "Remote") } + switch draft.command { + case "npx": return "npx" + case "uvx": return "uvx" + default: return String(localized: "Command") + } + } } /// Pasting a standard mcpServers object previews each recognized definition. diff --git a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift index 9b50f5c6f1..8e778abdb2 100644 --- a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift +++ b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift @@ -38,9 +38,7 @@ struct McpServerLibraryView: View { if let backup = feature.lastBackupURL { backupLine(backup) } - McpDiscoveryView(feature: feature.discovery) { definition in - editing = McpServerDraft(definition: definition) - } + McpDiscoveryView(feature: feature.discovery) { editing = $0 } Divider() McpSettingsView(feature: access, library: feature, workspaceURL: workspaceURL) } @@ -338,6 +336,9 @@ private struct McpServerEditorView: View { Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { + if let message = draft.setupMessage { + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } serverSection if draft.transport == .stdio { environmentSection diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index f0155250b2..32bafb42b9 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -5,6 +5,41 @@ import Testing /// Exercise discovery -> draft -> feature save -> Core reparse using isolated real files. @MainActor struct McpArgumentRoundTripTests { + @Test(arguments: McpAgent.allCases, ["github", "deepwiki", "exa", "filesystem", "fetch", "memory"]) + func featuredSetupIsExplicitAndRoundTripsThroughNativeConfiguration(agent: McpAgent, name: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/\(name)" }) + var draft = try #require(McpServerDraft(entry: entry)) + #expect(draft.enabledAgents.isEmpty) + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.enabledAgents = [agent] + switch draft.setup { + case .bearerToken: + #expect(!feature.save(draft)) + draft.headers[0].value = "Bearer " + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.headers[0].value = "Bearer fixture-token" + case .allowedDirectories: + #expect(!feature.save(draft)) + draft.argumentsText += "\nrelative-folder" + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.argumentsText = try #require(entry.draft).args.joined(separator: "\n") + "\n" + fixture.allowedFolder() + case nil: break + } + try #require(feature.save(draft)) + let reparsed = try fixture.reparse(agent, name: name) + #expect(reparsed.args == draft.arguments) + #expect(reparsed.url == (draft.url.isEmpty ? nil : draft.url)) + #expect(reparsed.headers == draft.headers) + #expect(feature.rows.first?.enabledGlobalAgents == [agent]) + } + @Test( arguments: McpAgent.allCases, [ @@ -151,12 +186,22 @@ private final class ArgumentFixture: McpServerLibraryActions { try Data(content.utf8).write(to: file.url) } - func reparse(_ agent: McpAgent) throws -> McpServerDefinition { + func hasConfiguration(_ agent: McpAgent) throws -> Bool { + try store.read(file(for: agent)) != nil + } + + func allowedFolder() throws -> String { + let directory = root.appendingPathComponent("allowed folder", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.path + } + + func reparse(_ agent: McpAgent, name: String = "alpha") throws -> McpServerDefinition { let file = try file(for: agent) let content = try #require(try store.read(file)) let definitions = try store.parse(file, content: content) try #require(definitions.count == 1) - return try #require(definitions.first { $0.name == "alpha" }) + return try #require(definitions.first { $0.name == name }) } func cleanUp() { diff --git a/macos/Tests/LitheTests/McpRegistryTests.swift b/macos/Tests/LitheTests/McpRegistryTests.swift index 671bdfd7ed..14908261a2 100644 --- a/macos/Tests/LitheTests/McpRegistryTests.swift +++ b/macos/Tests/LitheTests/McpRegistryTests.swift @@ -40,14 +40,40 @@ struct McpRegistryTests { registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries registry.fail = true let feature = McpRegistryFeatureModel(registry: registry) - #expect(feature.entries.count == 4) + #expect(feature.entries.count == 10) #expect(feature.entries.allSatisfy { $0.source == .featured && $0.draft != nil }) await feature.search(" ") #expect(registry.requests.isEmpty) - #expect(feature.entries.count == 4 && feature.error == nil) + #expect(feature.entries.count == 10 && feature.error == nil) #expect(!feature.isLoading && feature.nextCursor == nil) } + @Test func featuredMarksLoadOfflineAndPublicEntriesUseTheFallback() throws { + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + for entry in entries { + let image = try #require(McpBrandIconLoader.image(for: entry), "Missing bundled mark for \(entry.id)") + #expect(!image.isTemplate) + #expect(image.size == NSSize(width: 28, height: 28)) + var publicCopy = entry + publicCopy.source = .registry + #expect(McpBrandIconLoader.filename(for: publicCopy) == nil) + #expect(McpBrandIconLoader.image(for: publicCopy) == nil) + publicCopy.setup = .bearerToken + #expect(McpServerDraft(entry: publicCopy)?.setup == nil) + } + } + + @Test func sharedSetupFixtureDecodesWithoutSelectingAgentsOrInventingInputs() throws { + let path = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/mcp/featured-setup-v1.json") + let page = try JSONDecoder().decode(McpRegistryPage.self, from: Data(contentsOf: path)) + #expect(page.entries.map(\.setup) == [.bearerToken, .allowedDirectories]) + let token = try #require(McpServerDraft(entry: page.entries[0])) + let folders = try #require(McpServerDraft(entry: page.entries[1])) + #expect(token.headers == [McpKeyValue(key: "Authorization", value: "")]) + #expect(folders.arguments == ["-y", "@example/filesystem@1.2.3"]) + #expect(token.enabledAgents.isEmpty && folders.enabledAgents.isEmpty) + } + @Test func connectionFailureKeepsMatchesAndDirectRetryMustBeExplicit() async throws { let registry = RegistryStub() registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries diff --git a/rust/lithe-core/src/mcp/featured.json b/rust/lithe-core/src/mcp/featured.json index 2389e0e38b..c697d34d1f 100644 --- a/rust/lithe-core/src/mcp/featured.json +++ b/rust/lithe-core/src/mcp/featured.json @@ -8,7 +8,12 @@ "publisher": "Upstash", "description": "Current library documentation and code examples for coding agents.", "websiteUrl": "https://github.com/upstash/context7", - "remotes": [{ "type": "streamable-http", "url": "https://mcp.context7.com/mcp" }] + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.context7.com/mcp" + } + ] } }, { @@ -19,7 +24,16 @@ "description": "Open pages, click, type and inspect a browser through its accessibility tree.", "version": "0.0.83", "websiteUrl": "https://github.com/microsoft/playwright-mcp", - "packages": [{ "registryType": "npm", "identifier": "@playwright/mcp", "version": "0.0.83", "transport": { "type": "stdio" } }] + "packages": [ + { + "registryType": "npm", + "identifier": "@playwright/mcp", + "version": "0.0.83", + "transport": { + "type": "stdio" + } + } + ] } }, { @@ -30,7 +44,16 @@ "description": "Inspect Chrome pages, console messages, network requests and performance.", "version": "1.11.0", "websiteUrl": "https://github.com/ChromeDevTools/chrome-devtools-mcp", - "packages": [{ "registryType": "npm", "identifier": "chrome-devtools-mcp", "version": "1.11.0", "transport": { "type": "stdio" } }] + "packages": [ + { + "registryType": "npm", + "identifier": "chrome-devtools-mcp", + "version": "1.11.0", + "transport": { + "type": "stdio" + } + } + ] } }, { @@ -40,7 +63,119 @@ "publisher": "Microsoft", "description": "Search official Microsoft and Azure documentation and code samples.", "websiteUrl": "https://github.com/MicrosoftDocs/mcp", - "remotes": [{ "type": "streamable-http", "url": "https://learn.microsoft.com/api/mcp" }] + "remotes": [ + { + "type": "streamable-http", + "url": "https://learn.microsoft.com/api/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/github", + "title": "GitHub", + "publisher": "GitHub", + "description": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "websiteUrl": "https://github.com/github/github-mcp-server", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + ] + }, + "setup": "bearer-token" + }, + { + "server": { + "name": "lithe.featured/deepwiki", + "title": "DeepWiki", + "publisher": "Cognition", + "description": "Ask questions about public GitHub repositories and read their generated documentation.", + "websiteUrl": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.deepwiki.com/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/exa", + "title": "Exa", + "publisher": "Exa", + "description": "Search the web and code, and retrieve page content for coding agents.", + "websiteUrl": "https://exa.ai/docs/get-started/exa-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.exa.ai/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/filesystem", + "title": "Filesystem", + "publisher": "Model Context Protocol", + "description": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-filesystem", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] + }, + "setup": "allowed-directories" + }, + { + "server": { + "name": "lithe.featured/fetch", + "title": "Fetch", + "publisher": "Model Context Protocol", + "description": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "version": "2026.10.19", + "packages": [ + { + "registryType": "pypi", + "identifier": "mcp-server-fetch", + "version": "2026.10.19", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/memory", + "title": "Memory", + "publisher": "Model Context Protocol", + "description": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-memory", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] } } ], diff --git a/rust/lithe-core/src/mcp/registry.rs b/rust/lithe-core/src/mcp/registry.rs index 11be5a0138..b68cae34be 100644 --- a/rust/lithe-core/src/mcp/registry.rs +++ b/rust/lithe-core/src/mcp/registry.rs @@ -7,10 +7,35 @@ use serde_json::{json, Value}; /// Returns the release-owned catalog without reading files or using the network. /// Entries pass through the same draft validation as public Registry responses. pub fn featured_registry() -> Result { - let mut page = parse_registry(json!({"content": include_str!("featured.json")}))?; + let content = include_str!("featured.json"); + let catalog: Value = serde_json::from_str(content) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid featured MCP catalog"))?; + let mut page = parse_registry(json!({"content": content}))?; if let Some(entries) = page.get_mut("entries").and_then(Value::as_array_mut) { for entry in entries { entry["source"] = json!("featured"); + // Only the release-owned catalog may declare an incomplete template. + // Public Registry input must still contain a complete launch recipe. + let setup = catalog["servers"] + .as_array() + .and_then(|servers| { + servers + .iter() + .find(|item| item["server"]["name"] == entry["id"]) + }) + .and_then(|item| item.get("setup")) + .and_then(Value::as_str); + if let Some(setup) = setup { + if !matches!(setup, "bearer-token" | "allowed-directories") + || entry["draft"].is_null() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid featured MCP setup", + )); + } + entry["setup"] = json!(setup); + } } } Ok(page) @@ -227,7 +252,7 @@ mod tests { fn featured_catalog_is_available_offline_with_pinned_reviewable_drafts() { let page = featured_registry().unwrap(); let entries = page["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 4); + assert_eq!(entries.len(), 10); assert!(page["nextCursor"].is_null()); assert!(entries .iter() @@ -241,6 +266,40 @@ mod tests { entries[2]["draft"]["args"], json!(["-y", "chrome-devtools-mcp@1.11.0"]) ); + let find = |name: &str| entries.iter().find(|e| e["draft"]["name"] == name).unwrap(); + assert_eq!(find("github")["setup"], "bearer-token"); + assert_eq!( + find("github")["draft"]["url"], + "https://api.githubcopilot.com/mcp/" + ); + assert_eq!( + find("deepwiki")["draft"]["url"], + "https://mcp.deepwiki.com/mcp" + ); + assert_eq!(find("exa")["draft"]["url"], "https://mcp.exa.ai/mcp"); + assert_eq!(find("filesystem")["setup"], "allowed-directories"); + assert_eq!( + find("filesystem")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-filesystem@2.0.0"]) + ); + assert_eq!( + find("memory")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-memory@2.0.0"]) + ); + assert_eq!( + find("fetch")["draft"]["args"], + json!(["mcp-server-fetch==2026.10.19"]) + ); + } + + #[test] + fn public_registry_cannot_declare_trusted_featured_setup() { + let page = parse_registry(json!({"content": json!({"servers": [{ + "setup": "bearer-token", "server": {"name": "lithe.featured/github", + "setup": "bearer-token", "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}]} + }]}).to_string()})).unwrap(); + assert_eq!(page["entries"][0]["source"], "registry"); + assert!(page["entries"][0].get("setup").is_none()); } #[test] diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 13675a935b..6366bb1621 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -388,7 +388,8 @@ async function main() { // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. - // Includes mcp-registry-metadata: fetched responses have no copy stage; featured.json follows Git source. + // Includes mcp-registry-metadata: responses have no copy stage; featured.json + // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 6f55f9e01a..bfec1834dc 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -237,6 +237,15 @@ try { assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); } }); + await test("featured catalog and bundled MCP marks follow Git source instead of resource reuse", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const row = registry.excludedResources.find(item => item.id === "mcp-registry-metadata"); + assert.ok(row.locations.includes("macos/Sources/Lithe/Resources/McpIcons")); + assert.ok(row.locations.includes("rust/lithe-core/src/mcp/featured.json")); + const refused = reuse(["--resource", row.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /mcp-registry-metadata.*cannot be reused/); + }); await test("local disk snapshots and approvals are rejected before any worktree access", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "plugin-disk-install-state"); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 1ad41779ee..9933c78172 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -36,7 +36,10 @@ { "id": "agent-edit-and-paste-state", "reusable": false, - "locations": ["/agent/pasted-images/pasted-.", "/.lithe-agent-recovery---"], + "locations": [ + "/agent/pasted-images/pasted-.", + "/.lithe-agent-recovery---" + ], "identity": "One user attachment or guarded revert operation owns these mutable bytes. No reusable version/platform/architecture/toolchain identity stamp exists.", "reason": "The Windows attachment adapter writes only to platform application cache; abandoned batches delete their own files, accepted references remain available to history. Revert stages a same-directory file until Windows recycling succeeds, restoring on failure without overwriting a recreated destination. A conflicting restoration reports the recovery location for manual action. Neither resource may enter any worktree copy stage or installation directory; signatures and update baselines remain unchanged." }, @@ -311,10 +314,12 @@ "id": "mcp-registry-metadata", "reusable": false, "locations": [ - "/MCPRegistry/search-results" + "/MCPRegistry/search-results", + "rust/lithe-core/src/mcp/featured.json", + "macos/Sources/Lithe/Resources/McpIcons" ], - "identity": "Fetched public directory responses are bounded to 1 MiB, normalized by Rust Core and owned by one cancellable search; there is no persistent cache or build identity stamp. The separate featured.json catalog is Git-owned compile-time source, not a reusable generated or downloaded resource.", - "reason": "The macOS ephemeral URLSessions keep fetched directory metadata in memory only. Never copy, bundle or share fetched search responses between worktrees; query, pagination and the explicitly selected system/direct route belong to the current page. The static rust/lithe-core/src/mcp/featured.json catalog follows the source checkout and is compiled read-only into Core; never copy it from a runtime cache. No server packages or images are downloaded, and no runtime writes touch the installed bundle or Sparkle delta baseline." + "identity": "Fetched public directory responses are bounded to 1 MiB, normalized by Rust Core and owned by one cancellable search; there is no persistent cache or build identity stamp. The separate featured.json catalog is Git-owned compile-time source, not a reusable generated or downloaded resource. Bundled McpIcons are Git source assets with reviewed source URLs and SHA-256 in NOTICE.json, not generated cache entries; they have no reusable version/platform/architecture/toolchain stamp.", + "reason": "The macOS ephemeral URLSessions keep fetched directory metadata in memory only. Never copy, bundle or share fetched search responses between worktrees; query, pagination and the explicitly selected system/direct route belong to the current page. The static rust/lithe-core/src/mcp/featured.json catalog follows the source checkout and is compiled read-only into Core; never copy it from a runtime cache. McpIcons are copied from the same checkout only during SwiftPM packaging, read-only at runtime, and never extracted from another worktree or installed app. No server packages or images are downloaded at runtime, and no runtime writes touch the installed bundle or Sparkle delta baseline." }, { "id": "agent-instruction-library-state", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9fdb1c9076..91125f5c7a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -626,6 +626,18 @@ package resolution or execution. Catalog package versions are pinned; remote requirements remain subject to the publisher's documentation. Hosts show this page without contacting the Registry, extend matching entries on explicit search, and retain available results when a directory request fails. +The release catalog includes Context7, Playwright, Chrome DevTools, Microsoft +Learn, GitHub, DeepWiki, Exa, Filesystem, Fetch and Memory. A featured entry may +add `setup: "bearer-token" | "allowed-directories"`: its draft is an incomplete +template, requiring an Authorization bearer value or at least one absolute +directory argument after the fixed Filesystem package argument. Hosts must +collect and validate these inputs before saving and must not select Agents by +default. Public Registry parsing never copies `setup`, even when input uses a +featured-looking ID. `shared/fixtures/mcp/featured-setup-v1.json` illustrates the +optional field; ordinary entries omit it. Brand marks are platform-owned, +read-only bundled assets, selected only for known featured IDs; public entries +keep the transport fallback. No response can select arbitrary resources or +cause remote image fetching. `mcp.parseConfiguration` accepts `{ source: "codex" | "claude", scope: "global" | "project", content: string, projectPath?: string }` and returns diff --git a/shared/fixtures/mcp/featured-setup-v1.json b/shared/fixtures/mcp/featured-setup-v1.json new file mode 100644 index 0000000000..a2b2df565a --- /dev/null +++ b/shared/fixtures/mcp/featured-setup-v1.json @@ -0,0 +1,48 @@ +{ + "entries": [ + { + "id": "lithe.featured/example-token", + "title": "Example token service", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "", + "website": "https://example.invalid/docs", + "setup": "bearer-token", + "draft": { + "name": "example-token", + "transport": "http", + "command": null, + "args": [], + "env": [], + "url": "https://example.invalid/mcp", + "headers": [], + "definitionHash": "" + } + }, + { + "id": "lithe.featured/example-folders", + "title": "Example allowed folders", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "1.2.3", + "website": null, + "setup": "allowed-directories", + "draft": { + "name": "example-folders", + "transport": "stdio", + "command": "npx", + "args": [ + "-y", + "@example/filesystem@1.2.3" + ], + "env": [], + "url": null, + "headers": [], + "definitionHash": "" + } + } + ], + "nextCursor": null +} diff --git a/shared/platform-feature-matrix/features/mcp-registry-discovery.json b/shared/platform-feature-matrix/features/mcp-registry-discovery.json index 7dd6c39047..187e72a735 100644 --- a/shared/platform-feature-matrix/features/mcp-registry-discovery.json +++ b/shared/platform-feature-matrix/features/mcp-registry-discovery.json @@ -2,7 +2,7 @@ "id": "mcp-registry-discovery", "area": "AI", "group": "IDE MCP", - "capability": "同页上方管理服务器资源库,下方默认显示离线可用的内置精选;搜索时查询官方 MCP Registry、分页并与精选去重,失败保留可用匹配并提供显式直连重试;静态远程或简单固定版本 npm/PyPI 入口进入平铺编辑弹窗,选择 Agent 并确认后保存", + "capability": "同页上方管理服务器资源库,下方默认显示离线可用的内置精选;搜索时查询官方 MCP Registry、分页并与精选去重,失败保留可用匹配并提供显式直连重试;静态远程或简单固定版本 npm/PyPI 入口进入平铺编辑弹窗,选择 Agent 并确认后保存;十个常用精选随应用打包品牌图标和运行方式标签,GitHub 令牌及 Filesystem 允许目录须补全后保存", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -15,7 +15,11 @@ "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", "macos/Tests/LitheTests/McpRegistryTests.swift", "rust/lithe-core/src/mcp/featured.json", - "macos/Tests/LitheTests/McpRegistryIntegrationTests.swift" + "macos/Tests/LitheTests/McpRegistryIntegrationTests.swift", + "macos/Sources/Lithe/Theme/McpBrandIcon.swift", + "macos/Sources/Lithe/Resources/McpIcons/NOTICE.json", + "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", + "shared/fixtures/mcp/featured-setup-v1.json" ] }, "windows": { @@ -26,5 +30,5 @@ ] }, "owner": "Agent", - "verification": "Core 验证精选目录、删除记录过滤、固定包版本、复杂输入不猜配置、链接和大小上限;模型覆盖离线首页不请求网络、失败保留精选、显式直连和路由分页隔离、在线连接去重、取消和旧响应。实机打开页面立即显示精选,标题右侧来源标记为灰色普通文字“精选,以及官方 MCP Registry”,辅助功能树不包含该标记的链接或按钮;系统代理失败时仍显示匹配,点击直连重试后经生产 adapter 查询官方目录并加载更多,再切回系统代理确认模式生效;添加草稿取消不改 Agent 配置。公开目录请求继续校验证书,系统代理、Agent 网络和发行目录保持不变。联网集成用 LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 显式开启,普通测试不依赖网络。Windows UI 尚未接入。" + "verification": "Core 验证精选目录、删除记录过滤、固定包版本、复杂输入不猜配置、链接和大小上限;模型覆盖离线首页不请求网络、失败保留精选、显式直连和路由分页隔离、在线连接去重、取消和旧响应。实机打开页面立即显示精选,标题右侧来源标记为灰色普通文字“精选,以及官方 MCP Registry”,辅助功能树不包含该标记的链接或按钮;系统代理失败时仍显示匹配,点击直连重试后经生产 adapter 查询官方目录并加载更多,再切回系统代理确认模式生效;添加草稿取消不改 Agent 配置。公开目录请求继续校验证书,系统代理、Agent 网络和发行目录保持不变。联网集成用 LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 显式开启,普通测试不依赖网络。Windows UI 尚未接入。 新增精选应检查十项本地图标完整且公开条目回退传输图标;深浅色宽窄卡片保持可读;GitHub 空令牌、Filesystem 缺少或相对目录不能保存,补全后经两 Agent 原生格式写入并回读,目录空格保持。未执行或联网调用服务器不计作实际服务验收。" } From 59f53d6b51c3411562c81b7c85eddf6c6ccd295e Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 23:34:19 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(mcp):=20=E5=A2=9E=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A3=80=E6=B5=8B=E4=B8=8E=E5=8E=9F=E7=94=9F=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/2026-10-17-mcp-server-library.md | 39 ++- docs/ci-builds.md | 7 + macos/Resources/en.lproj/Localizable.strings | 28 ++ .../zh-Hans.lproj/Localizable.strings | 27 ++ .../Application/Composition/AppServices.swift | 1 + .../McpServerLibraryFeatureModel.swift | 42 +++ .../Features/McpToolsFeatureModel.swift | 110 +++++++ macos/Sources/Lithe/Core/Ports/McpTools.swift | 40 +++ .../Lithe/Models/AppModel/AppModel+MCP.swift | 1 + .../Platform/MacOS/MCP/MacMcpTools.swift | 192 ++++++++++++ .../Platform/MacOS/MacServiceContainer.swift | 3 + .../Views/App/McpServerLibraryView.swift | 149 ++++----- .../Lithe/Views/App/McpToolsPanel.swift | 100 ++++++ .../McpArgumentRoundTripTests.swift | 137 +++++++++ .../LitheTests/McpToolsFeatureTests.swift | 96 ++++++ rust/Cargo.lock | 28 ++ rust/lithe-agent-host/Cargo.toml | 3 + rust/lithe-agent-host/src/lib.rs | 1 + rust/lithe-agent-host/src/mcp_probe.rs | 284 ++++++++++++++++++ .../src/mcp_probe/http_client.rs | 174 +++++++++++ rust/lithe-agent-host/src/mcp_probe/tests.rs | 236 +++++++++++++++ rust/lithe-core/Cargo.toml | 1 + rust/lithe-core/src/mcp/mod.rs | 43 +++ rust/lithe-core/src/mcp/tools.rs | 254 ++++++++++++++++ rust/lithe-core/src/mcp/tools/tests.rs | 114 +++++++ rust/lithe-core/src/protocol/command.rs | 9 + rust/lithe-core/src/runtime/dispatcher.rs | 9 + scripts/reuse-worktree-resources.mjs | 2 + scripts/test-reuse-worktree-resources.mjs | 2 +- scripts/worktree-resources.json | 11 + shared/contracts/rust-core-api.md | 38 +++ shared/fixtures/mcp/tools-v1.json | 52 ++++ .../features/mcp-server-library.json | 12 +- windows/tauri/src-tauri/Cargo.lock | 57 ++++ 34 files changed, 2228 insertions(+), 74 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Ports/McpTools.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift create mode 100644 macos/Sources/Lithe/Views/App/McpToolsPanel.swift create mode 100644 macos/Tests/LitheTests/McpToolsFeatureTests.swift create mode 100644 rust/lithe-agent-host/src/mcp_probe.rs create mode 100644 rust/lithe-agent-host/src/mcp_probe/http_client.rs create mode 100644 rust/lithe-agent-host/src/mcp_probe/tests.rs create mode 100644 rust/lithe-core/src/mcp/tools.rs create mode 100644 rust/lithe-core/src/mcp/tools/tests.rs create mode 100644 shared/fixtures/mcp/tools-v1.json diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index ebd2aa9ed4..aa313094b4 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -59,7 +59,41 @@ - env 和 header 值在编辑器中用 `SecureField` 遮蔽,但配置文件中仍是明文;页面明确写出这一点,不制造“已经加密”的错觉。列表摘要及其默认辅助功能文本不显示 stdio 参数,仅显示经过安全字符校验的可执行文件名;HTTP/SSE URL 会移除 userinfo、查询参数和 fragment,无效 URL 仅显示传输方式。例如 `--token=secret` 不能出现在摘要中,`https://user:secret@example.test/mcp?token=secret` 只能显示 `https://example.test/mcp`。 - 错误消息、日志和 Core 返回值都不回显配置内容。解析失败只报告文件路径和错误类型。 -- 首版不自动启动 MCP 服务器做健康检查。启动服务器会执行用户配置的命令,资源库只负责配置,不在用户没有要求时执行项目代码。 +- 打开页面或展开工具区只读;只有点击“检测工具”才连接,stdio 还需确认执行通知。 + 检测会运行配置的命令,可能下载包,因此不能在导入、保存或展开时自动触发。 + +### 紧凑编辑器与工具检测 + +配置弹窗参考 Magpie `ccbc7278180e513bf95461c62eeb15729fc7cae7` 的 +`internal/gui/assets/plugins/library/client.js`;工具排除机制核对其 +`internal/library/mcp_tools_off.go`,并核对 Codex 原生 `disabled_tools` 与 Claude +官方 permissions 文档。独立实现原生页面,不复制 Magpie 的网关和多 Agent 框架。 +命令与参数投影成一行,Rust Core 的 shell-words 1.1.1 只解析/引用 argv,保留空参数、 +内嵌换行和路径空格,不执行 Shell。Agent 用现有品牌图标的选择按钮,帮助仍放在 ⓘ。 + +检测复用仓库已有的官方 rmcp 3.5.0 SDK,初始化、会话、SSE 响应和分页相关语义留给 +SDK;Agent Host 管理执行、取消和进程清理。只增加 client/streamable HTTP 能力, +沿用 reqwest 0.12 的 TLS/网络栈;自定义字节 adapter 在 JSON、错误和 SSE 解码前 +限制 2 MiB,避免 SDK 默认 HTTP JSON 读取无上限。工具最多 1000 项、20 页,重复 +游标拒绝;请求期限 30 秒并有有界清理,stdio 子进程/进程组在结束、失败、取消后回收。 +不发送 tools/call,不处理 OAuth 登录、不读取其他 Agent 的登录令牌;不支持的旧式 +SSE 检测明确报错,仍允许用户编辑原有 SSE 配置。成功只代表这次检测,不能当成现有 +Agent 会话的连接保证。 + +工具区按已注册的 Agent 独立切换。Codex 修改自身条目的 disabled_tools;Claude 修改 +用户 settings.json 的 permissions.deny,只写精确 mcp__规则。外部精确/通配 deny、 +Codex enabled_tools 以及名称规范化冲突保持不可放宽,页面显示只读限制;只移除本库 +登记过的精确禁用项,文件过期时保留外部修改。规则与全局原生文件由现有写入 adapter +备份、条件替换,偏好只登记工具名所有权,不存令牌。Codex 更改规则会改变完整来源 +哈希,页面只推进此前匹配的服务器所有权,不能把一次工具操作当成接管外部服务器。 + +工具规则按原生路径/服务器名保留,即使暂时关闭服务器也保留限制。Claude 的规则引用 +服务器名称,原生用户配置存在 MCP 限制时拒绝在本页重命名,避免旧限制未迁移而让 +工具重新可用;用户需先在原生规则中处理。项目/受管规则仍由上游 Agent 决定,页面 +不会显示为已解除全局之外的限制。生命周期退出会取消探测、丢弃迟到结果并释放内存 +快照;这些状态登记在 mcp-tool-probe-state,没有工作树复用阶段或发行目录运行时写入。 + +检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消请求在操作注册前可能到达,因此仅在取消后重试传递取消信号,工作线程返回即停止计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 ### 发现市场与弹窗编辑 @@ -159,6 +193,9 @@ Windows 的 Agent 配置路径和 Tauri 侧文件访问模型不同,同步实 ## 验证 +- `LITHE_MCP_PROBE_INTEGRATION=1 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 45 -- --filter McpArgumentRoundTripTests`(显式联网验收 Context7 的真实工具列表、原生大栈线程、取消前启动保护、精确参数、独立工具规则和过期文件保护;普通测试不依赖公网) +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/mcp-tools-host.json`(本地 stdio、HTTP JSON/SSE 响应、分页、超大响应拒绝、虚拟超时及取消后进程回收) + - `./scripts/test-macos.sh --filter "McpRegistryTests|McpServerLibraryTests"`:离线首页零网络请求、失败保留精选、显式选择路由和路由间分页隔离、精选与在线连接去重,Registry 桥接、分页、取消、旧请求淘汰,JSON 预览不写文件也不取得所有权;原生卡片浅色/深色下在 420 与 780 点宽度重排,截图写入 `.artifacts/mcp-discovery/` 供人工检查。 - `LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 ./scripts/test-macos.sh --filter McpRegistryIntegrationTests`:可选联网集成,通过生产 adapter 显式直连官方目录并分页;普通单元测试不依赖公网。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core mcp::registry::`:精选目录无需网络且草稿固定版本,元数据夹具、失效记录、动态输入、危险链接和大小上限。 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 3cbb51a42f..74571cefdb 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -363,6 +363,13 @@ Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先 不从其他工作树或发行包导入。原生 Agent 指令文件同样保留用户归属;暂存文件随保存完成或失败清理, 空锁文件属于用户资料的生命周期。运行时不写 app bundle、安装目录或 Sparkle delta 基线。 +MCP 工具检测会话、工具描述、原生规则快照和工具排除所有权登记为 `mcp-tool-probe-state`。 +这些内容属于用户资料和一次页面操作,没有版本/平台/架构/工具链 identity stamp,所有复用阶段均拒绝复制。 +官方 SDK 沿用 Cargo 锁定版本构建,stdio/HTTP 检测只在用户主动操作时运行; +HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平台 adapter 条件替换并备份到 Application Support。 +工具名称所有权保存在平台偏好中,不存令牌;取消/离页释放进程与内存,不写 app bundle 或安装目录。 +用户明确运行包命令可能产生的 npm/uv 缓存归上游运行时管理,不属于可跨工作树复用的 Lithe 构建资源。 + MCP 发现页读取的官方 Registry 在线响应登记为 `mcp-registry-metadata`,仅在当前进程内存中保存。 响应最多 1 MiB,由 Rust Core 校验;按搜索、分页和取消请求管理生命周期,没有持久缓存、 平台/架构/工具链身份或允许复制的阶段。资源复用脚本明确拒绝该项,不下载服务器包或图片, diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index ea37b41ede..ab14e2b824 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2319,3 +2319,31 @@ "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; "Enter an Authorization header with Bearer and your GitHub token." = "Enter an Authorization header with Bearer and your GitHub token."; "Add at least one absolute allowed folder path after the Filesystem package argument." = "Add at least one absolute allowed folder path after the Filesystem package argument."; + +/* MCP native tools inspection and compact editor */ +"Server tools" = "Server tools"; +"Detecting…" = "Detecting…"; +"%d tools" = "%d tools"; +"Not detected" = "Not detected"; +"Detect again" = "Detect again"; +"Detect tools" = "Detect tools"; +"Reload tool settings" = "Reload tool settings"; +"This server reported no tools." = "This server reported no tools."; +"Run this MCP command?" = "Run this MCP command?"; +"Run and detect" = "Run and detect"; +"Remove from library" = "Remove from library"; +"Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process." = "Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process."; +"Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes." = "Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes."; +"This tool is restricted outside Lithe. Edit the native Agent policy to enable it." = "This tool is restricted outside Lithe. Edit the native Agent policy to enable it."; +"Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated."; +"Could not read the command line." = "Could not read the command line."; +"Check command-line quotes before saving." = "Check command-line quotes before saving."; +"Check the MCP command, URL and headers." = "Check the MCP command, URL and headers."; +"Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint." = "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint."; +"Could not start the MCP command. Check its executable and runtime." = "Could not start the MCP command. Check its executable and runtime."; +"MCP connection failed. Check the server, network and authentication." = "MCP connection failed. Check the server, network and authentication."; +"MCP tool metadata exceeded the inspection limit." = "MCP tool metadata exceeded the inspection limit."; +"MCP detection timed out after 30 seconds." = "MCP detection timed out after 30 seconds."; +"MCP detection was cancelled." = "MCP detection was cancelled."; +"Invalid MCP tool configuration" = "Invalid MCP tool configuration"; +"Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name." = "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index a44c7d1966..b568f39129 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3894,3 +3894,30 @@ "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; "Enter an Authorization header with Bearer and your GitHub token." = "请填写 Authorization 请求头,内容为 Bearer 加空格和 GitHub 令牌。"; "Add at least one absolute allowed folder path after the Filesystem package argument." = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; + +/* MCP native tools inspection and compact editor */ +"Server tools" = "服务器工具"; +"%d tools" = "%d 个工具"; +"Not detected" = "未检测"; +"Detect again" = "重新检测"; +"Detect tools" = "检测工具"; +"Reload tool settings" = "重新加载工具设置"; +"This server reported no tools." = "服务器未提供工具。"; +"Run this MCP command?" = "运行此 MCP 命令?"; +"Run and detect" = "运行并检测"; +"Remove from library" = "从资源库移除"; +"Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process." = "检测会运行配置的程序,并可能下载软件包。请仅对信任的服务器继续。Lithe 会读取工具列表,然后停止检测进程。"; +"Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes." = "检测会连接服务器并读取工具信息,不会调用工具。本地命令可能执行代码或下载软件包。结果仅代表此次检测,不代表已有 Agent 会话的状态。工具开关作用于所选 Agent 的用户配置,项目或受管规则仍可能限制访问。修改后请新建 Agent 会话。"; +"This tool is restricted outside Lithe. Edit the native Agent policy to enable it." = "该工具已被 Lithe 之外的规则限制,请编辑 Agent 原生规则后再启用。"; +"Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "按终端形式输入程序和参数,含空格的路径请加引号。Lithe 直接传递参数,不运行 Shell,也不解释管道或重定向。"; +"Could not read the command line." = "无法读取命令行。"; +"Check command-line quotes before saving." = "请检查命令行中的引号后再保存。"; +"Check the MCP command, URL and headers." = "请检查 MCP 命令、URL 和请求头。"; +"Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint." = "暂不支持检测旧式 SSE 连接,请使用发布者提供的 Streamable HTTP 地址。"; +"Could not start the MCP command. Check its executable and runtime." = "无法启动 MCP 命令,请检查程序及所需运行时。"; +"MCP connection failed. Check the server, network and authentication." = "MCP 连接失败,请检查服务器、网络及认证。"; +"MCP tool metadata exceeded the inspection limit." = "MCP 工具信息超过检测上限。"; +"MCP detection timed out after 30 seconds." = "MCP 检测已超时(30 秒)。"; +"MCP detection was cancelled." = "MCP 检测已取消。"; +"Invalid MCP tool configuration" = "MCP 工具配置无效"; +"Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name." = "Claude Code 存在 MCP 工具限制。重命名服务器前请先更新其原生规则,以免已禁用的工具在新名称下重新可用。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 660dcda936..d96fd7e853 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -15,6 +15,7 @@ final class AppServices { /// composition, which leaves the settings page read-only. var mcpConfigurationStore: (any McpConfigurationStore)? var mcpRegistry: (any McpRegistryProviding)? + var mcpTools: (any McpToolsManaging)? /// Confirms closing terminals with running programs; `nil` closes directly. var processExitCoordinator: ProcessExitCoordinator? /// Gives build providers that share a language server the language diff --git a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift index 0e74e019a3..2ecfd1ec0b 100644 --- a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift @@ -158,6 +158,12 @@ struct McpServerDraft: Equatable { headers = definition.headers } + mutating func setCommand(_ value: String, arguments: [String]) { + command = value + originalArguments = arguments + argumentsText = arguments.joined(separator: "\n") + } + init?(entry: McpRegistryEntry) { guard let definition = entry.draft else { return nil } self.init(definition: definition) @@ -209,6 +215,7 @@ enum McpLibraryError: LocalizedError { /// Application dependencies used by the MCP library. @MainActor protocol McpServerLibraryActions: AnyObject { + var mcpTools: (any McpToolsManaging)? { get } var mcpRegistry: (any McpRegistryProviding)? { get } var mcpConfigurationStore: (any McpConfigurationStore)? { get } var mcpWorkspaceURL: URL? { get } @@ -216,6 +223,7 @@ protocol McpServerLibraryActions: AnyObject { } extension McpServerLibraryActions { + var mcpTools: (any McpToolsManaging)? { nil } var mcpRegistry: (any McpRegistryProviding)? { nil } } @@ -227,6 +235,7 @@ extension McpServerLibraryActions { /// included in errors. @MainActor final class McpServerLibraryFeatureModel: ObservableObject { + let tools: McpToolsFeatureModel let discovery: McpRegistryFeatureModel @Published private(set) var rows: [McpServerRow] = [] @Published private(set) var isAvailable = false @@ -249,7 +258,11 @@ final class McpServerLibraryFeatureModel: ObservableObject { init(model: any McpServerLibraryActions) { self.model = model + tools = McpToolsFeatureModel(service: model.mcpTools) discovery = McpRegistryFeatureModel(registry: model.mcpRegistry) + tools.didChangePolicy = { [weak self] row, snapshot in + self?.recordToolPolicy(row: row, snapshot: snapshot) + } } // MARK: - Discovery @@ -321,6 +334,15 @@ final class McpServerLibraryFeatureModel: ObservableObject { return left.definition.definitionHash < right.definition.definitionHash } hasLoaded = true + tools.reconcile(rows) + } + + func catalogEntry(for definition: McpServerDefinition) -> McpRegistryEntry? { + discovery.entries.first { entry in + guard entry.source == .featured, let draft = entry.draft else { return false } + if definition.transport != .stdio { return draft.transport == definition.transport && draft.url == definition.url } + return draft.transport == .stdio && draft.command == definition.command && draft.args == Array(definition.args.prefix(draft.args.count)) + } } /// Files a save writes to, shown in the editor before committing. @@ -417,6 +439,12 @@ final class McpServerLibraryFeatureModel: ObservableObject { let targetFiles = files.filter { draft.enabledAgents.contains($0.agent) } let originalName = draft.originalName let originalSources = draft.originalSources + if let originalName, originalName != draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + let original = rows.first(where: { $0.id == draft.originalRowID }) { + do { + try model?.mcpTools?.validateRename(McpToolTarget(definition: original.definition, files: originalSources.map(\.file))) + } catch { self.error = error.localizedDescription; return false } + } var definition = McpServerDefinition( name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), @@ -922,6 +950,20 @@ final class McpServerLibraryFeatureModel: ObservableObject { ledgerSnapshot()[Self.identity(of: file)] ?? [] } + private func recordToolPolicy(row: McpServerRow, snapshot: McpToolPolicySnapshot) { + // Tool filters change Codex's full source hash, but not the connection + // identity. Advance only ownership that matched before our guarded edit. + if snapshot.agent == .codex, let previous = snapshot.previousSourceHash, + let next = snapshot.sourceHash, let source = row.globalSources.first(where: { $0.file.agent == .codex }) { + var entries = ledgerEntries(for: source.file) + if let index = entries.firstIndex(where: { $0.name == row.name && $0.sourceHash == previous }) { + entries[index] = McpOwnedEntry(name: row.name, definitionHash: row.definition.definitionHash, sourceHash: next) + setLedger(entries, for: source.file) + } + } + refresh() + } + /// Reads the shared ledger every time instead of caching it per window. /// Multiple windows share one store; a stale copy would rewrite the whole /// map and silently drop entries another window just recorded. diff --git a/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift new file mode 100644 index 0000000000..f24d548840 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift @@ -0,0 +1,110 @@ +import Combine +import Foundation + +/// One page owns all probes. Navigation cancels them and stale responses never +/// replace another connection's inventory or publish a success after failure. +@MainActor +final class McpToolsFeatureModel: ObservableObject { + struct State { + var isLoading = false + var isReady = false + var tools: [McpToolDescription] = [] + var policies: [McpAgent: McpToolPolicySnapshot] = [:] + var error: String? + } + @Published private(set) var states: [String: State] = [:] + var didChangePolicy: ((McpServerRow, McpToolPolicySnapshot) -> Void)? + private let service: (any McpToolsManaging)? + private var probes: [String: Task] = [:] + private var requests: [String: UUID] = [:] + + init(service: (any McpToolsManaging)?) { self.service = service } + + func state(for row: McpServerRow) -> State { states[row.id] ?? State() } + + @discardableResult + func detect(_ row: McpServerRow) -> Task? { + guard let service, !row.isProjectOnly else { return nil } + cancelAll() + let request = UUID(); requests[row.id] = request + var state = states[row.id] ?? State(); state.isLoading = true; state.isReady = false; state.error = nil + states[row.id] = state + probes[row.id] = Task { [weak self] in + do { + let result = try await service.probe(row.definition) + guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } + self.states[row.id]?.tools = result.tools + self.states[row.id]?.isReady = true + self.loadPolicies(row) + } catch { + guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } + self.states[row.id]?.isReady = false + // Probe errors cross a sanitized Core boundary; no raw server + // response, URL, argument or header is put in the message. + self.states[row.id]?.error = error.localizedDescription + } + guard let self, self.requests[row.id] == request else { return } + self.states[row.id]?.isLoading = false + self.probes[row.id] = nil + self.requests[row.id] = nil + } + return probes[row.id] + } + + func loadPolicies(_ row: McpServerRow) { + guard let service, let state = states[row.id], state.isReady else { return } + service.release(Array(state.policies.values)) + states[row.id]?.policies = [:] + states[row.id]?.error = nil + for agent in McpAgent.allCases where row.enabledGlobalAgents.contains(agent) { + do { + states[row.id]?.policies[agent] = try service.policy(for: target(row), agent: agent, tools: state.tools.map(\.name)) + } catch { states[row.id]?.error = error.localizedDescription } + } + } + + func setEnabled(_ enabled: Bool, tool: String, agent: McpAgent, row: McpServerRow) { + guard let service, let snapshot = states[row.id]?.policies[agent] else { return } + do { + let next = try service.setEnabled(enabled, tool: tool, target: target(row), snapshot: snapshot) + states[row.id]?.policies[agent] = next + states[row.id]?.error = nil + didChangePolicy?(row, next) + } catch { states[row.id]?.error = error.localizedDescription } + } + + func reconcile(_ rows: [McpServerRow]) { + let ids = Set(rows.map(\.id)) + for id in Array(states.keys) where !ids.contains(id) { + probes.removeValue(forKey: id)?.cancel(); requests[id] = nil + if let state = states.removeValue(forKey: id) { service?.release(Array(state.policies.values)) } + } + } + + func cancelAll() { + for task in probes.values { task.cancel() } + probes.removeAll(); requests.removeAll() + for id in Array(states.keys) { states[id]?.isLoading = false } + } + + func leavePage() { + cancelAll() + for state in states.values { service?.release(Array(state.policies.values)) } + states.removeAll() + } + + func commandLine(_ draft: McpServerDraft) throws -> String { + guard let service else { throw McpLibraryError.unavailable } + return try service.commandLine(command: draft.command, args: draft.arguments) + } + + func updateCommandLine(_ line: String, draft: inout McpServerDraft) throws { + guard let service else { throw McpLibraryError.unavailable } + let parsed = try service.parseCommandLine(line) + draft.setCommand(parsed.command, arguments: parsed.args) + } + + private func target(_ row: McpServerRow) -> McpToolTarget { + McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + } +} diff --git a/macos/Sources/Lithe/Core/Ports/McpTools.swift b/macos/Sources/Lithe/Core/Ports/McpTools.swift new file mode 100644 index 0000000000..851dff6886 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/McpTools.swift @@ -0,0 +1,40 @@ +import Foundation + +struct McpToolDescription: Decodable, Equatable, Identifiable, Sendable { + let name: String + let description: String + var id: String { name } +} + +struct McpProbeResult: Decodable, Sendable { let tools: [McpToolDescription] } +struct McpToolTarget: Sendable { + let definition: McpServerDefinition + let files: [McpConfigurationFile] +} +struct McpToolPermission: Decodable, Sendable { + let name: String + let enabled: Bool + let editable: Bool +} + +struct McpToolPolicySnapshot: Sendable { + let id: UUID + let agent: McpAgent + let tools: [McpToolPermission] + let previousSourceHash: String? + let sourceHash: String? +} + +/// Probe transport and native filesystem access remain behind a platform port. +@MainActor +protocol McpToolsManaging: AnyObject { + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot + func commandLine(command: String, args: [String]) throws -> String + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) + func release(_ snapshots: [McpToolPolicySnapshot]) + func validateRename(_ target: McpToolTarget) throws +} + +extension McpToolsManaging { func validateRename(_ target: McpToolTarget) throws { } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift index cde4ede9f2..cab6a59d3e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift @@ -7,6 +7,7 @@ import Foundation /// library can write the same server into the project's Agent configuration. extension AppModel: McpServerLibraryActions { var mcpRegistry: (any McpRegistryProviding)? { services.mcpRegistry } + var mcpTools: (any McpToolsManaging)? { services.mcpTools } var mcpConfigurationStore: (any McpConfigurationStore)? { services.mcpConfigurationStore } diff --git a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift new file mode 100644 index 0000000000..d8c97dc98e --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift @@ -0,0 +1,192 @@ +import Foundation + +/// Native policy snapshots use the same guarded writer and backups as server +/// settings. SDK inventory and argv parsing are shared Core/Host operations. +@MainActor +final class MacMcpTools: McpToolsManaging { + private let core: RustCoreBridge + private let store: any McpConfigurationStore + private let ledger: any KeyValueStore + private let claudeSettings: URL + private var snapshots: [UUID: Snapshot] = [:] + private struct Snapshot { + let file: McpConfigurationFile + let content: String? + let rowID: String + let server: String + let tools: [String] + let owned: [String] + let sourceHash: String? + } + private struct PolicyRequest: Encodable { + let source: String + let content: String + let server: String + let tools: [String] + let owned: [String] + var tool: String? = nil + var enabled: Bool? = nil + } + private struct PolicyResult: Decodable { + let content: String + let changed: Bool + let owned: [String] + let tools: [McpToolPermission] + let hasRestrictions: Bool + } + + init(core: RustCoreBridge, store: any McpConfigurationStore, ledger: any KeyValueStore, + storage: any FileStorage, environment: [String: String] = ProcessInfo.processInfo.environment) { + self.core = core; self.store = store; self.ledger = ledger + let directory = environment["CLAUDE_CONFIG_DIR"].flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) } + ?? storage.homeDirectory().appendingPathComponent(".claude") + claudeSettings = directory.appendingPathComponent("settings.json") + } + + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { + struct Payload: Encodable, Sendable { + let transport: McpTransport + let command: String? + let args: [String] + let env: [McpKeyValue] + let url: String? + let headers: [McpKeyValue] + } + let payload = Payload(transport: definition.transport, command: definition.command, args: definition.args, + env: definition.env, url: definition.url, headers: definition.headers) + let core = core; let operation = UUID().uuidString + let cancellation = McpProbeCancellation(core: core, operation: operation) + defer { cancellation.finish() } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + // Debug SDK/TLS futures exceed the Swift executor's small stack. + // This bounded blocking C ABI call owns a native thread instead. + let worker = Thread { + defer { cancellation.finish() } + if cancellation.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + let result: Result = core.executeResult( + command: "mcp.probe", payload: payload, operationID: operation) + continuation.resume(with: result.mapError { $0 as any Error }) + } + worker.name = "Lithe MCP inspection" + worker.qualityOfService = .userInitiated + worker.stackSize = 8 * 1024 * 1024 + worker.start() + } + } onCancel: { cancellation.cancel() } + } + + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { + guard let source = target.files.first(where: { $0.agent == agent }) else { throw McpConfigurationError.changedOnDisk } + let native = try store.read(source) + guard let definition = try store.parse(source, content: native ?? "").first(where: { $0.name == target.definition.name }), + definition.definitionHash == target.definition.definitionHash else { throw McpConfigurationError.changedOnDisk } + let file = agent == .codex ? source : McpConfigurationFile(agent: agent, scope: .global, url: claudeSettings, projectPath: nil) + let content = agent == .codex ? native : try store.read(file) + let owned = ledger.stringArray(forKey: key(file, target.definition.name)) ?? [] + let request = PolicyRequest(source: agent.sourceName, content: content ?? "", server: target.definition.name, tools: tools, owned: owned) + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: request).get() + return remember(Snapshot(file: file, content: content, rowID: target.definition.id, server: target.definition.name, tools: tools, owned: owned, + sourceHash: definition.sourceHash), result: result, previous: nil) + } + + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { + guard let old = snapshots[snapshot.id], old.rowID == target.definition.id, old.tools.contains(tool) else { throw McpConfigurationError.changedOnDisk } + guard (ledger.stringArray(forKey: key(old.file, old.server)) ?? []) == old.owned else { throw McpConfigurationError.changedOnDisk } + // Recheck the connection too: Claude's deny rules live in another file. + guard let source = target.files.first(where: { $0.agent == snapshot.agent }), + let current = try store.parse(source, content: store.read(source) ?? "").first(where: { $0.name == target.definition.name }), + current.definitionHash == target.definition.definitionHash else { throw McpConfigurationError.changedOnDisk } + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: PolicyRequest( + source: snapshot.agent.sourceName, content: old.content ?? "", server: old.server, + tools: old.tools, owned: old.owned, tool: tool, enabled: enabled)).get() + if result.changed { _ = try store.write(result.content, to: old.file, replacing: old.content) } + else if try store.read(old.file) != old.content { throw McpConfigurationError.changedOnDisk } + ledger.set(result.owned, forKey: key(old.file, old.server)) + let updated = snapshot.agent == .codex ? try store.parse(old.file, content: result.content).first(where: { $0.name == old.server })?.sourceHash : current.sourceHash + snapshots.removeValue(forKey: snapshot.id) + return remember(Snapshot(file: old.file, content: result.changed ? result.content : old.content, rowID: old.rowID, + server: old.server, tools: old.tools, owned: result.owned, sourceHash: updated), result: result, previous: current.sourceHash) + } + + func release(_ values: [McpToolPolicySnapshot]) { for value in values { snapshots.removeValue(forKey: value.id) } } + + func validateRename(_ target: McpToolTarget) throws { + guard target.files.contains(where: { $0.agent == .claudeCode }) else { return } + let file = McpConfigurationFile(agent: .claudeCode, scope: .global, url: claudeSettings, projectPath: nil) + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: PolicyRequest( + source: "claude", content: store.read(file) ?? "", server: target.definition.name, tools: [], owned: [])).get() + if result.hasRestrictions { throw RenameError.restricted } + } + + private enum RenameError: LocalizedError { + case restricted + var errorDescription: String? { + String(localized: "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name.") + } + } + + func commandLine(command: String, args: [String]) throws -> String { + struct Payload: Encodable { let action = "format"; let command: String; let args: [String] } + struct Result: Decodable { let line: String } + let result: Result = try core.executeResult(command: "mcp.commandLine", payload: Payload(command: command, args: args)).get() + return result.line + } + + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { + struct Payload: Encodable { let action = "parse"; let line: String } + struct Result: Decodable { let command: String; let args: [String] } + let result: Result = try core.executeResult(command: "mcp.commandLine", payload: Payload(line: line)).get() + return (result.command, result.args) + } + + private func remember(_ value: Snapshot, result: PolicyResult, previous: String?) -> McpToolPolicySnapshot { + let id = UUID(); snapshots[id] = value + return McpToolPolicySnapshot(id: id, agent: value.file.agent, tools: result.tools, + previousSourceHash: previous, sourceHash: value.sourceHash) + } + private func key(_ file: McpConfigurationFile, _ name: String) -> String { + "settings.mcpToolExclusions.\(file.agent.rawValue).\(file.url.path).\(name)" + } +} + +/// Cancellation can arrive before the C ABI registers its operation. Retry only +/// after cancellation, until the owned bounded worker completes, so that gap +/// cannot leave a probe running after navigation. No UI/cooperative thread waits. +private final class McpProbeCancellation: @unchecked Sendable { + private let lock = NSLock() + private let core: RustCoreBridge + private let operation: String + private var cancelled = false + private var finished = false + private var timer: DispatchSourceTimer? + + init(core: RustCoreBridge, operation: String) { self.core = core; self.operation = operation } + + var isCancelled: Bool { lock.withLock { cancelled } } + + func cancel() { + lock.withLock { + cancelled = true + guard !finished, timer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + let core = core, operation = operation + timer.setEventHandler { core.cancel(operationID: operation) } + timer.schedule(deadline: .now(), repeating: .milliseconds(50)) + self.timer = timer + timer.resume() + } + } + + func finish() { + lock.withLock { + finished = true + timer?.cancel() + timer = nil + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 2021e4009a..a9e4a5e024 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -778,6 +778,9 @@ final class MacServiceContainer { core: rustCore, storage: fileStorage ) services.mcpRegistry = MacMcpRegistry(core: rustCore) + if let configuration = services.mcpConfigurationStore { + services.mcpTools = MacMcpTools(core: rustCore, store: configuration, ledger: services.store, storage: fileStorage) + } services.connectBuildProviderLanguageSessions = { sessions in buildProviderSessions.languageSessions = sessions } diff --git a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift index 8e778abdb2..6260eaa8ef 100644 --- a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift +++ b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift @@ -57,12 +57,13 @@ struct McpServerLibraryView: View { } } .padding(20) - .frame(width: 640, height: 620) + .frame(width: 640, height: showsImporter ? 580 : 500) .background(LitheTheme.settingsSurface) .environment(\.locale, locale) } .onAppear { feature.refreshIfNeeded() } .onChange(of: workspaceURL) { _ in feature.refresh() } + .onDisappear { feature.tools.leavePage() } } private func closeEditor() { @@ -173,13 +174,15 @@ struct McpServerLibraryView: View { } /// One library row: identity, a one-line command summary and per-Agent chips. -private struct McpServerRowView: View { +struct McpServerRowView: View { @ObservedObject var feature: McpServerLibraryFeatureModel let row: McpServerRow let edit: () -> Void + @State private var expanded = false var body: some View { - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { Button(action: edit) { HStack(spacing: 10) { icon @@ -203,10 +206,14 @@ private struct McpServerRowView: View { } if !row.isProjectOnly { - Image(systemName: "chevron.right") - .font(LitheTheme.uiFont(size: 9, weight: .semibold)) - .foregroundStyle(LitheTheme.tertiaryText) + Button { expanded.toggle() } label: { + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(LitheTheme.uiFont(size: 10, weight: .semibold)) + }.buttonStyle(.plain).foregroundStyle(LitheTheme.secondaryText) + .accessibilityLabel(Text("Server tools")) } + } + if expanded { McpToolsPanel(feature: feature.tools, row: row) } } .padding(.horizontal, 10) .padding(.vertical, 8) @@ -220,14 +227,8 @@ private struct McpServerRowView: View { } private var icon: some View { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(LitheTheme.accent.opacity(0.10)) - .frame(width: 26, height: 26) - .overlay { - Image(systemName: row.definition.transport == .stdio ? "terminal" : "network") - .font(LitheTheme.uiFont(size: 11, weight: .medium)) - .foregroundStyle(LitheTheme.accent) - } + McpBrandIcon(entry: feature.catalogEntry(for: row.definition) ?? McpRegistryEntry( + id: row.id, title: row.name, summary: "", version: "", website: nil, draft: row.definition)) } private var identity: some View { @@ -236,6 +237,7 @@ private struct McpServerRowView: View { Text(row.name) .font(LitheTheme.uiFont(size: 12.5, weight: .medium)) .foregroundStyle(LitheTheme.primaryText) + McpToolStatusBadge(feature: feature.tools, row: row) if row.isLitheRegistration { badge("Lithe", color: LitheTheme.accent) } else if row.isProjectOnly { @@ -286,6 +288,7 @@ private struct McpAgentChip: View { var body: some View { Button(action: action) { HStack(spacing: 3) { + AgentBrandIcon(name: agent.displayName, size: 13, style: .brand) Image(systemName: isOn ? "checkmark" : "plus") .font(LitheTheme.uiFont(size: 8.5, weight: .bold)) Text(agent.displayName) @@ -314,12 +317,14 @@ private struct McpAgentChip: View { } /// Detail editor for one server definition. -private struct McpServerEditorView: View { +struct McpServerEditorView: View { @ObservedObject var feature: McpServerLibraryFeatureModel @Binding var draft: McpServerDraft let onClose: () -> Void @State private var didAttemptSave = false + @State private var commandLine = "" + @State private var commandError: String? private var original: McpServerRow? { guard let id = draft.originalRowID else { return nil } @@ -327,15 +332,15 @@ private struct McpServerEditorView: View { } private var validationMessage: String? { - feature.validationMessage(for: draft) + (draft.transport == .stdio ? commandError : nil) ?? feature.validationMessage(for: draft) } var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { heading Divider() ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 14) { if let message = draft.setupMessage { Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } @@ -346,8 +351,7 @@ private struct McpServerEditorView: View { headersSection } agentsSection - destinationSection - if didAttemptSave, let message = validationMessage { + if let message = validationMessage, didAttemptSave || commandError != nil { LitheBanner(status: .warning) { Text(message).font(LitheTheme.smallFont) } } if let error = feature.error { @@ -361,32 +365,33 @@ private struct McpServerEditorView: View { Divider() footer } + .onAppear { + guard draft.transport == .stdio else { return } + do { commandLine = try feature.tools.commandLine(draft) } + catch { commandError = String(localized: "Could not read the command line.") } + } + .onChange(of: draft.transport) { value in + if value == .stdio { + do { commandLine = try feature.tools.commandLine(draft); commandError = nil } + catch { commandError = String(localized: "Could not read the command line.") } + } + } } private var heading: some View { HStack(spacing: 8) { - Text(draft.isNew ? "New MCP server" : "Edit MCP server") + McpBrandIcon(entry: feature.catalogEntry(for: definition) ?? McpRegistryEntry( + id: draft.name, title: draft.name, summary: "", version: "", website: nil, draft: definition)) + Text(draft.name.isEmpty ? String(localized: "New MCP server") : draft.name) .font(LitheTheme.uiFont(size: 16, weight: .semibold)) Spacer(minLength: 8) - if original != nil { - Button(role: .destructive) { - guard let original, feature.delete(original) else { return } - onClose() - } label: { - Label("Delete", systemImage: "trash") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundStyle(LitheTheme.error) - .lithePointer() - } } } private var serverSection: some View { VStack(alignment: .leading, spacing: 12) { - McpSettingsRow("Name") { + McpSettingsRow("Name", info: "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit.") { TextField("Name", text: $draft.name) .litheSettingsTextField() } @@ -399,14 +404,14 @@ private struct McpServerEditorView: View { ) } if draft.transport == .stdio { - McpSettingsRow("Command") { - TextField("Command", text: $draft.command) - .litheSettingsTextField() - } - McpSettingsRow("Arguments", info: "One argument per line. Arguments keep spaces without quoting.") { - TextEditor(text: $draft.argumentsText) + McpSettingsRow("Command", info: "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated.") { + TextField("npx -y @playwright/mcp", text: $commandLine, axis: .vertical) .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) - .litheSettingsTextEditor(height: 64) + .lineLimit(1...3).litheSettingsTextField() + .onChange(of: commandLine) { line in + do { try feature.tools.updateCommandLine(line, draft: &draft); commandError = nil } + catch { commandError = String(localized: "Check command-line quotes before saving.") } + } } } else { McpSettingsRow("URL") { @@ -442,6 +447,8 @@ private struct McpServerEditorView: View { private var agentsSection: some View { McpSettingsRow("Available to Agents", info: "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.") { HStack(alignment: .top, spacing: 16) { + Button("All") { draft.enabledAgents = Set(McpAgent.allCases) } + .buttonStyle(LitheSecondaryButtonStyle()) ForEach(McpAgent.allCases) { agent in agentRow(agent) } @@ -453,12 +460,10 @@ private struct McpServerEditorView: View { let isOn = binding(for: agent) let owned = original?.ownedGlobalAgents.contains(agent) == true return VStack(alignment: .leading, spacing: 2) { - LitheSettingsCheckbox(isOn: isOn, title: LocalizedStringKey(agent.displayName)) + McpAgentSelectionChip(agent: agent, selected: isOn.wrappedValue) { isOn.wrappedValue.toggle() } if isOn.wrappedValue, original != nil, !owned { HStack(spacing: 4) { - Text("Created outside Lithe.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Created outside Lithe.") Button("Take ownership") { if let original { feature.takeOwnership(of: original, agent: agent) @@ -469,7 +474,6 @@ private struct McpServerEditorView: View { .foregroundStyle(LitheTheme.accent) .lithePointer() } - .padding(.leading, 20) } } } @@ -487,30 +491,13 @@ private struct McpServerEditorView: View { ) } - private var destinationSection: some View { - McpSettingsRow("Saving writes to") { - VStack(alignment: .leading, spacing: 3) { - let urls = feature.destinationURLs(for: draft) - if urls.isEmpty { - Text("No Agent is selected.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } else { - ForEach(urls, id: \.self) { url in - Text(url.path) - .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - } - } - private var footer: some View { HStack(spacing: 8) { + if let original { + Button("Remove from library", role: .destructive) { + guard feature.delete(original) else { return }; onClose() + }.buttonStyle(.plain).foregroundStyle(LitheTheme.secondaryText) + } Spacer(minLength: 12) Button("Cancel", action: onClose) .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 12, height: 28, fontSize: 12)) @@ -525,6 +512,30 @@ private struct McpServerEditorView: View { } .padding(.top, 2) } + + private var definition: McpServerDefinition { + McpServerDefinition(name: draft.name, transport: draft.transport, command: draft.command, + args: draft.arguments, env: draft.env, url: draft.url, headers: draft.headers, definitionHash: "") + } +} + +struct McpAgentSelectionChip: View { + let agent: McpAgent + let selected: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + AgentBrandIcon(name: agent.displayName, size: 16, style: .brand) + Text(agent.displayName).font(LitheTheme.settingsFont) + if selected { Image(systemName: "checkmark").font(LitheTheme.smallFont) } + } + .padding(.horizontal, 9).padding(.vertical, 6) + .background(selected ? LitheTheme.accent.opacity(0.10) : LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(selected ? LitheTheme.accent.opacity(0.5) : LitheTheme.settingsControlBorder)) + }.buttonStyle(.plain).accessibilityLabel(Text(agent.displayName)).accessibilityValue(Text(selected ? "Selected" : "Not selected")) + } } /// Key/value rows for environment variables and HTTP headers. Values stay @@ -586,7 +597,7 @@ private struct McpSettingsRow: View { var body: some View { HStack(alignment: .center, spacing: 12) { LitheSettingsLabel(title, info: info) - .frame(width: 110, alignment: .leading) + .frame(width: 155, alignment: .leading) content .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/macos/Sources/Lithe/Views/App/McpToolsPanel.swift b/macos/Sources/Lithe/Views/App/McpToolsPanel.swift new file mode 100644 index 0000000000..bd97bcfbd4 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/McpToolsPanel.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct McpToolStatusBadge: View { + @ObservedObject var feature: McpToolsFeatureModel + let row: McpServerRow + var body: some View { + let state = feature.state(for: row) + if state.isReady || state.isLoading { + HStack(spacing: 4) { + Circle().fill(state.isReady ? LitheTheme.success : LitheTheme.secondaryText).frame(width: 5, height: 5) + Text(state.isLoading ? String(localized: "Detecting…") : String(format: String(localized: "%d tools"), state.tools.count)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + } + } +} + +/// Expanding a row is read-only. Starting a local server always requires the +/// explicit Detect action and its execution notice; tool calls are never sent. +struct McpToolsPanel: View { + @ObservedObject var feature: McpToolsFeatureModel + let row: McpServerRow + @State private var agent: McpAgent = .codex + @State private var confirmsExecution = false + + private var state: McpToolsFeatureModel.State { feature.state(for: row) } + private var policy: McpToolPolicySnapshot? { state.policies[agent] } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Circle().fill(state.isReady ? LitheTheme.success : LitheTheme.secondaryText.opacity(0.5)).frame(width: 6, height: 6) + Text(state.isLoading ? String(localized: "Detecting…") : (state.isReady ? String(format: String(localized: "%d tools"), state.tools.count) : String(localized: "Not detected"))) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes.") + Spacer() + if state.isLoading { + Button("Cancel") { feature.cancelAll() }.buttonStyle(.plain) + } else { + Button(state.isReady ? "Detect again" : "Detect tools") { + if row.definition.transport == .stdio { confirmsExecution = true } + else { feature.detect(row) } + }.buttonStyle(LitheSecondaryButtonStyle()) + } + } + if let error = state.error { + Text(error).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error).textSelection(.enabled) + if state.isReady { + Button("Reload tool settings") { feature.loadPolicies(row) }.buttonStyle(.plain).foregroundStyle(LitheTheme.accent) + } + } + if state.isReady && !state.tools.isEmpty { + HStack(spacing: 8) { + ForEach(McpAgent.allCases.filter { row.enabledGlobalAgents.contains($0) }) { value in + McpAgentSelectionChip(agent: value, selected: agent == value) { agent = value } + } + Spacer() + } + VStack(alignment: .leading, spacing: 0) { + ForEach(state.tools) { tool in + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: tool.name).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + if !tool.description.isEmpty { + Text(verbatim: tool.description).font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText).lineLimit(4).help(tool.description) + } + } + Spacer(minLength: 8) + let permission = policy?.tools.first { $0.name == tool.name } + Toggle(isOn: Binding(get: { permission?.enabled == true }, set: { + feature.setEnabled($0, tool: tool.name, agent: agent, row: row) + })) { Text(verbatim: tool.name) } + .labelsHidden().toggleStyle(.switch).controlSize(.small) + .disabled(permission?.editable != true || state.isLoading) + .accessibilityLabel(Text("\(tool.name) in \(agent.displayName)")) + if permission?.editable == false { + LitheInfoTip("This tool is restricted outside Lithe. Edit the native Agent policy to enable it.") + } + } + .padding(.vertical, 9) + if tool.id != state.tools.last?.id { Divider() } + } + } + } else if state.isReady { + Text("This server reported no tools.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + } + .padding(12) + .background(LitheTheme.settingsSurface) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .onAppear { if !row.enabledGlobalAgents.contains(agent) { agent = row.enabledGlobalAgents.first ?? .codex } } + .alert("Run this MCP command?", isPresented: $confirmsExecution) { + Button("Cancel", role: .cancel) { } + Button("Run and detect") { feature.detect(row) } + } message: { + Text("Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process.") + } + } +} diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index 32bafb42b9..f2b5cfb257 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -1,10 +1,131 @@ +import AppKit import Foundation +import SwiftUI import Testing @testable import Lithe /// Exercise discovery -> draft -> feature save -> Core reparse using isolated real files. @MainActor struct McpArgumentRoundTripTests { + @Test func cancellingBeforeTheNativeWorkerStartsDoesNotConnect() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "cancelled", transport: .http, command: nil, args: [], env: [], + url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + // Both tasks inherit MainActor: cancellation precedes the first probe + // instruction, without a scheduling delay or a public-network fixture. + let task = Task { try await service.probe(definition) } + task.cancel() + do { + _ = try await task.value + Issue.record("A cancelled probe must not publish a result") + } catch { + #expect(error is CancellationError) + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_MCP_PROBE_INTEGRATION"] == "1")) + func publicContext7ProbeRunsOnTheNativeWorker() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "context7", transport: .http, command: nil, args: [], env: [], + url: "https://mcp.context7.com/mcp", headers: [], definitionHash: "integration") + // Explicit integration opt-in: verifies the real linked Swift/SDK path, + // including proxy/TLS stack depth, without calling any remote tool. + let result = try await service.probe(definition) + #expect(result.tools.contains { $0.name == "resolve-library-id" }) + #expect(result.tools.contains { $0.name == "query-docs" }) + } + + @Test func renamingDoesNotBypassClaudeToolRestrictions() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(.claudeCode, arguments: []) + try fixture.externalPolicyChange(.claudeCode) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + var draft = feature.draft(for: row); draft.name = "renamed" + #expect(!feature.save(draft)) + #expect(feature.error != nil) + #expect(try fixture.reparse(.claudeCode).name == "alpha") + } + + @Test func compactEditorFitsBothNativeAppearances() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/playwright" }) + var draft = try #require(McpServerDraft(entry: entry)); draft.enabledAgents = Set(McpAgent.allCases) + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/mcp-tools") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for dark in [false, true] { + let view = McpServerEditorView(feature: feature, draft: .constant(draft), onClose: {}) + .padding(20).frame(width: 640, height: 500) + .environment(\.colorScheme, dark ? .dark : .light) + .background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + #expect(host.fittingSize == NSSize(width: 640, height: 500)) + host.frame = NSRect(x: 0, y: 0, width: 640, height: 500); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("editor-\(dark ? "dark" : "light").png")) + } + } + + @Test(arguments: McpAgent.allCases) + func toolExclusionsRoundTripAndKeepOwnedServerRemovalWorking(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft(); draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [agent] + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let policy = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + #expect(off.tools.first { $0.name == "write" }?.enabled == false) + let reopened = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + #expect(reopened.tools.first { $0.name == "write" }?.enabled == false) + let on = try service.setEnabled(true, tool: "write", target: target, snapshot: reopened) + #expect(on.tools.allSatisfy { $0.enabled }) + service.release([off, on]) + feature.refresh() + // Direct adapter changes intentionally do not adopt server ownership. + // The page model advances the matching source hash after its own edit. + let current = try #require(feature.rows.first) + let page = feature.tools + page.didChangePolicy?(row, off) + page.didChangePolicy?(current, on) + #expect(feature.rows.first?.definition.name == "alpha") + try #require(feature.setAgent(agent, enabled: false, for: try #require(feature.rows.first))) + #expect(feature.rows.isEmpty) + } + + @Test(arguments: McpAgent.allCases) + func staleToolPolicySnapshotPreservesExternalNativeEdits(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(agent, arguments: []) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let snapshot = try service.policy(for: target, agent: agent, tools: ["read"]) + try fixture.externalPolicyChange(agent) + #expect(throws: (any Error).self) { + try service.setEnabled(false, tool: "read", target: target, snapshot: snapshot) + } + let latest = try service.policy(for: target, agent: agent, tools: ["read"]) + #expect(latest.tools.first?.enabled == false) + #expect(latest.tools.first?.editable == false) + service.release([snapshot, latest]) + } + @Test(arguments: McpAgent.allCases, ["github", "deepwiki", "exa", "filesystem", "fetch", "memory"]) func featuredSetupIsExplicitAndRoundTripsThroughNativeConfiguration(agent: McpAgent, name: String) throws { let fixture = try ArgumentFixture() @@ -130,7 +251,11 @@ private final class ArgumentFixture: McpServerLibraryActions { private let root: URL private let store: MacAgentMcpConfigurationStore private let ledger = ArgumentKeyValueStore() + private lazy var toolService = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: ArgumentFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { toolService } + var mcpRegistry: (any McpRegistryProviding)? { MacMcpRegistry(core: RustCoreBridge()) } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpWorkspaceURL: URL? { nil } var mcpLedgerStore: any KeyValueStore { ledger } @@ -196,6 +321,18 @@ private final class ArgumentFixture: McpServerLibraryActions { return directory.path } + func externalPolicyChange(_ agent: McpAgent) throws { + if agent == .codex { + let file = try file(for: agent) + let original = try #require(try store.read(file)) + try Data((original + "\ndisabled_tools = ['read']\n").utf8).write(to: file.url) + } else { + let path = root.appendingPathComponent("claude/settings.json") + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"keep":"external","permissions":{"deny":["mcp__alpha__read"]}}"#.utf8).write(to: path) + } + } + func reparse(_ agent: McpAgent, name: String = "alpha") throws -> McpServerDefinition { let file = try file(for: agent) let content = try #require(try store.read(file)) diff --git a/macos/Tests/LitheTests/McpToolsFeatureTests.swift b/macos/Tests/LitheTests/McpToolsFeatureTests.swift new file mode 100644 index 0000000000..00e5ae8c0f --- /dev/null +++ b/macos/Tests/LitheTests/McpToolsFeatureTests.swift @@ -0,0 +1,96 @@ +import AppKit +import Combine +import Foundation +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct McpToolsFeatureTests { + @Test func explicitDetectionPublishesRealInventoryAndKeepsAgentsIndependent() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + #expect(service.probes == 0) + let finished = TestGate() + let observer = feature.$states.sink { state in if state[row.id]?.isReady == true { finished.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)) + #expect(await finished.waitUntilOpen()) + await task.value + #expect(service.probes == 1) + #expect(feature.state(for: row).tools.map(\.name) == ["read"]) + feature.setEnabled(false, tool: "read", agent: .codex, row: row) + #expect(feature.state(for: row).policies[.codex]?.tools.first?.enabled == false) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + service.fails = true + feature.setEnabled(false, tool: "read", agent: .claudeCode, row: row) + #expect(feature.state(for: row).error != nil) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + } + + @Test func leavingPageCancelsTheProbeAndRejectsItsLateInventory() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + let task = try #require(feature.detect(row)) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + feature.leavePage() + service.release.open() + await task.value + #expect(feature.states.isEmpty) + #expect(service.policies == 0) + } + + @Test func nativeToolPanelFitsWideAndNarrowDarkAndLightLayouts() async throws { + let service = ToolStub(); let feature = McpToolsFeatureModel(service: service); let row = ToolStub.row() + let ready = TestGate() + let observer = feature.$states.sink { states in if states[row.id]?.isReady == true { ready.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)); #expect(await ready.waitUntilOpen()); await task.value + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/mcp-tools") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for width: CGFloat in [420, 760] { + for dark in [false, true] { + let view = McpToolsPanel(feature: feature, row: row).padding(16).frame(width: width) + .environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view); host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let size = host.fittingSize; #expect(abs(size.width - width) < 1) + host.frame = NSRect(origin: .zero, size: size); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("tools-\(dark ? "dark" : "light")-\(Int(width)).png")) + } + } + } +} + +@MainActor +private final class ToolStub: McpToolsManaging { + let started = TestGate(), release = TestGate() + var waits = false, fails = false + var probes = 0, policies = 0 + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { + probes += 1; started.open() + if waits { _ = await release.waitUntilOpen() } + return McpProbeResult(tools: [McpToolDescription(name: "read", description: "Read the current library documentation.")]) + } + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { + policies += 1 + return McpToolPolicySnapshot(id: UUID(), agent: agent, tools: tools.map { McpToolPermission(name: $0, enabled: true, editable: true) }, previousSourceHash: nil, sourceHash: nil) + } + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { + if fails { throw CocoaError(.fileWriteUnknown) } + return McpToolPolicySnapshot(id: UUID(), agent: snapshot.agent, tools: [McpToolPermission(name: tool, enabled: enabled, editable: true)], previousSourceHash: nil, sourceHash: nil) + } + func release(_ snapshots: [McpToolPolicySnapshot]) { } + func commandLine(command: String, args: [String]) throws -> String { command } + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { (line, []) } + static func row() -> McpServerRow { + let server = McpServerDefinition(name: "docs", transport: .http, command: nil, args: [], env: [], url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + let sources = McpAgent.allCases.map { agent in McpServerSource(file: McpConfigurationFile(agent: agent, scope: .global, + url: URL(fileURLWithPath: "/fixture/\(agent.rawValue)"), projectPath: nil), definitionHash: "fixture", isOwnedByLithe: false) } + return McpServerRow(id: server.id, definition: server, sources: sources, hasDefinitionConflict: false) + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0a5f60ae22..9ff4b76517 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -346,6 +346,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -2000,17 +2006,20 @@ dependencies = [ "flate2", "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest", + "rmcp", "semver", "serde", "serde_json", "serde_yaml_ng", "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", @@ -2039,6 +2048,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml", "toml_edit", "tree-sitter", @@ -3075,16 +3085,21 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" dependencies = [ + "base64 0.23.1", + "bytes", "chrono", "futures", + "http", "indexmap 2.14.0", "pastey", "pin-project-lite", "schemars 1.2.2", "serde", "serde_json", + "sse-stream", "thiserror 2.0.20", "tokio", + "tokio-stream", "tokio-util", "tracing", "uuid", @@ -3870,6 +3885,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index 530f1fa008..d40c32f1ee 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -17,6 +17,9 @@ hyper-util = { version = "0.1", features = ["tokio"] } kill_tree = { version = "0.2", features = ["tokio"] } serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +rmcp = { version = "=3.5.0", default-features = false, features = ["client", "transport-async-rw", "transport-streamable-http-client"] } +http = "1" +sse-stream = "=0.2.6" serde_json = "1" sha2 = "0.10" serde_yaml_ng = "0.10.0" diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 17bace8588..bc94fb2b17 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -14,6 +14,7 @@ mod codex_retry; pub mod environment; pub mod install; pub mod instructions; +pub mod mcp_probe; mod model_calls; mod model_proxy; mod prompt; diff --git a/rust/lithe-agent-host/src/mcp_probe.rs b/rust/lithe-agent-host/src/mcp_probe.rs new file mode 100644 index 0000000000..8652534e98 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe.rs @@ -0,0 +1,284 @@ +//! Explicit, short-lived MCP inventory probes using the official MCP SDK. +//! +//! Probes list metadata only. They never call tools, persist credentials or +//! start automatically while the settings page is opening. + +use rmcp::{model::PaginatedRequestParams, ServiceExt}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::HashSet, process::Stdio, time::Duration}; +use tokio::{io::AsyncReadExt, process::Command}; +mod http_client; + +/// User-reviewed connection, supplied by a platform's native configuration store. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + /// `stdio`, `http`, or the legacy `sse` configuration type. + pub transport: String, + /// Executable, without shell evaluation. + pub command: Option, + /// Exact argument boundaries, including empty and space-containing values. + #[serde(default)] + pub args: Vec, + /// Environment overrides for this child only. + #[serde(default)] + pub env: Vec, + /// Remote endpoint; HTTPS or loopback HTTP only. + pub url: Option, + /// Explicit request headers. Values are never returned in errors. + #[serde(default)] + pub headers: Vec, +} + +/// One environment or HTTP header entry; never logged. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// Environment or header name. + pub key: String, + /// User-supplied value; never included in diagnostics. + pub value: String, +} + +/// Stable diagnostic category, deliberately independent of upstream error text. +#[derive(Debug, PartialEq)] +pub enum Error { + Invalid, + Unsupported, + Start, + Connection, + Limit, + Timeout, + Cancelled, +} + +const DEADLINE: Duration = Duration::from_secs(30); +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_TOOLS: usize = 1000; + +/// Runs one bounded probe on the calling worker thread. The caller owns cancel. +pub fn execute(mut request: Request, cancelled: &dyn Fn() -> bool) -> Result { + validate(&request)?; + if cancelled() { + return Err(Error::Cancelled); + } + if request.transport == "stdio" && !request.env.iter().any(|e| e.key == "PATH") { + if let Some(path) = crate::environment::search_path_with_cancel(cancelled) { + request.env.push(Entry { + key: "PATH".into(), + value: path.to_string_lossy().into_owned(), + }); + } + } + if cancelled() { + return Err(Error::Cancelled); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Start)?; + runtime.block_on(run(request, cancelled)) +} + +fn validate(r: &Request) -> Result<(), Error> { + let bytes = r.args.iter().map(String::len).sum::() + + r.env + .iter() + .chain(&r.headers) + .map(|e| e.key.len() + e.value.len()) + .sum::(); + if bytes > 1024 * 1024 || r.args.len() > 256 || r.env.len() > 128 || r.headers.len() > 128 { + return Err(Error::Invalid); + } + match r.transport.as_str() { + "stdio" + if r.command + .as_ref() + .is_some_and(|s| !s.is_empty() && !s.contains('\0')) => + { + Ok(()) + } + "http" => { + let u = url::Url::parse(r.url.as_deref().unwrap_or("")).map_err(|_| Error::Invalid)?; + let loopback = u.host_str().is_some_and(|s| { + s == "localhost" || s == "127.0.0.1" || s == "[::1]" || s == "::1" + }); + if (u.scheme() != "https" && !(u.scheme() == "http" && loopback)) + || u.host_str().is_none() + || !u.username().is_empty() + || u.password().is_some() + || u.fragment().is_some() + { + return Err(Error::Invalid); + } + Ok(()) + } + "sse" => Err(Error::Unsupported), + _ => Err(Error::Invalid), + } +} + +async fn run(r: Request, cancelled: &dyn Fn() -> bool) -> Result { + let mut child = None; + let transport = r.transport.clone(); + let mut command = if transport == "stdio" { + let path = r + .env + .iter() + .find(|e| e.key == "PATH") + .map(|e| std::ffi::OsStr::new(&e.value)); + let executable = + crate::environment::find_executable(r.command.as_deref().ok_or(Error::Invalid)?, path) + .ok_or(Error::Start)?; + let mut cmd = Command::new(executable); + cmd.args(&r.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for e in &r.env { + cmd.env(&e.key, &e.value); + } + #[cfg(unix)] + cmd.process_group(0); + let mut spawned = cmd.spawn().map_err(|_| Error::Start)?; + let output = spawned.stdout.take().ok_or(Error::Start)?; + let input = spawned.stdin.take().ok_or(Error::Start)?; + child = Some(spawned); + // Bound the complete stdio metadata exchange, including an unterminated + // JSON line; SDK parsing cannot grow forever on a malicious child. + Some((output.take(MAX_BYTES), input)) + } else { + None + }; + let result = async { + if let Some(streams) = command.take() { + inventory(streams, cancelled).await + } else { + use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }; + let mut headers = http::HeaderMap::new(); + for e in &r.headers { + let key = http::header::HeaderName::from_bytes(e.key.as_bytes()) + .map_err(|_| Error::Invalid)?; + // Session and content framing belong to the SDK, not user headers. + if matches!( + key.as_str(), + "host" + | "content-length" + | "content-type" + | "accept" + | "mcp-session-id" + | "mcp-protocol-version" + ) { + return Err(Error::Invalid); + } + let mut value = + http::header::HeaderValue::from_str(&e.value).map_err(|_| Error::Invalid)?; + value.set_sensitive(true); + headers.insert(key, value); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(8)) + .timeout(DEADLINE) + .build() + .map_err(|_| Error::Start)?; + let mut config = + StreamableHttpClientTransportConfig::with_uri(r.url.ok_or(Error::Invalid)?); + config.max_sse_event_size = MAX_BYTES as usize; + let http = StreamableHttpClientTransport::with_client( + http_client::BoundedClient(client), + config, + ); + inventory(http, cancelled).await + } + } + .await; + if let Some(mut child) = child { + if let Some(pid) = child.id() { + crate::force_kill_tree(pid); + } + let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; + } + result +} + +#[cfg(test)] +mod tests; + +async fn inventory(transport: T, cancelled: &dyn Fn() -> bool) -> Result +where + T: rmcp::transport::IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + let deadline = tokio::time::Instant::now() + DEADLINE; + let cancel = async { + let mut ticks = tokio::time::interval(Duration::from_millis(50)); + loop { + ticks.tick().await; + if cancelled() { + return; + } + } + }; + tokio::pin!(cancel); + let service = tokio::select! { + _ = &mut cancel => return Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => return Err(Error::Timeout), + value = ().serve(transport) => value.map_err(|_| Error::Connection)?, + }; + let listing = async { + let mut tools = Vec::new(); + let mut names = HashSet::new(); + let mut cursors = HashSet::new(); + let mut cursor = None; + let mut size = 0; + for _ in 0..20 { + let mut params = PaginatedRequestParams::default(); + params.cursor = cursor; + let page = service + .list_tools(Some(params)) + .await + .map_err(|_| Error::Connection)?; + for tool in page.tools { + let name = tool.name.to_string(); + let description = tool.description.unwrap_or_default().to_string(); + size += name.len() + description.len(); + if name.is_empty() + || name.len() > 256 + || description.len() > 16384 + || size > MAX_BYTES as usize + || tools.len() >= MAX_TOOLS + { + return Err(Error::Limit); + } + if names.insert(name.clone()) { + tools.push(json!({"name": name, "description": description})); + } + } + cursor = page.next_cursor; + if cursor.is_none() { + tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + return Ok(json!({"tools": tools})); + } + if cursor.as_ref().is_some_and(|c| c.len() > 2048) { + return Err(Error::Limit); + } + if !cursors.insert(cursor.clone()) { + return Err(Error::Limit); + } + } + Err(Error::Limit) + }; + let result = tokio::select! { + _ = &mut cancel => Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => Err(Error::Timeout), + value = listing => value, + }; + let _ = tokio::time::timeout(Duration::from_secs(2), service.cancel()).await; + result +} diff --git a/rust/lithe-agent-host/src/mcp_probe/http_client.rs b/rust/lithe-agent-host/src/mcp_probe/http_client.rs new file mode 100644 index 0000000000..8fd6bf35d8 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/http_client.rs @@ -0,0 +1,174 @@ +//! Bounded HTTP byte adapter for the official SDK's session/SSE transport. +//! Keeps the existing reqwest/TLS stack and caps JSON, errors and SSE bodies +//! before decoding; upstream's default JSON adapter reads an unbounded body. + +use super::MAX_BYTES; +use futures::{stream::BoxStream, StreamExt}; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::{Error as SseError, Sse, SseStream}; +use std::{collections::HashMap, io, sync::Arc}; + +#[derive(Clone)] +pub(super) struct BoundedClient(pub reqwest::Client); +type Error = StreamableHttpError; +fn failed() -> Error { + StreamableHttpError::Client(io::Error::other("MCP HTTP request failed")) +} + +fn stream(response: reqwest::Response) -> BoxStream<'static, Result> { + response + .bytes_stream() + .scan(0_u64, |size, chunk| { + let value = chunk.map_err(|_| io::Error::other("MCP response failed")); + if let Ok(bytes) = &value { + *size += bytes.len() as u64; + } + let value = if *size > MAX_BYTES { + Err(io::Error::other("MCP response limit")) + } else { + value + }; + futures::future::ready(Some(value)) + }) + .boxed() +} + +fn request( + client: &reqwest::Client, + method: reqwest::Method, + uri: &str, + session: Option>, + auth: Option, + headers: HashMap, +) -> reqwest::RequestBuilder { + let mut r = client + .request(method, uri) + .header("accept", "application/json, text/event-stream"); + if let Some(session) = session { + r = r.header("mcp-session-id", session.as_ref()); + } + if let Some(auth) = auth { + r = r.bearer_auth(auth); + } + for (key, value) in headers { + r = r.header(key, value); + } + r +} + +impl StreamableHttpClient for BoundedClient { + type Error = io::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session: Option>, + auth: Option, + headers: HashMap, + ) -> Result { + let encoded = serde_json::to_vec(&message).map_err(|_| failed())?; + let response = request( + &self.0, + reqwest::Method::POST, + &uri, + session.clone(), + auth, + headers, + ) + .header("content-type", "application/json") + .body(encoded) + .send() + .await + .map_err(|_| failed())?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND && session.is_some() { + return Err(StreamableHttpError::SessionExpired); + } + if matches!(status.as_u16(), 202 | 204) { + return Ok(StreamableHttpPostResponse::Accepted); + } + let session = response + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let sse = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| s.starts_with("text/event-stream")); + if response + .content_length() + .is_some_and(|size| size > MAX_BYTES) + { + return Err(failed()); + } + if status.is_success() && sse { + return Ok(StreamableHttpPostResponse::Sse( + SseStream::from_bytes_stream(stream(response)).boxed(), + session, + )); + } + let mut bytes = Vec::new(); + let mut input = stream(response); + while let Some(chunk) = input.next().await { + bytes.extend_from_slice(&chunk.map_err(|_| failed())?); + } + if bytes.is_empty() && status.is_success() { + return Ok(StreamableHttpPostResponse::Accepted); + } + // Keep valid JSON-RPC errors for SDK negotiation/fallback, but never + // return an arbitrary HTML/auth body in a user-visible diagnostic. + let parsed: ServerJsonRpcMessage = serde_json::from_slice(&bytes).map_err(|_| failed())?; + Ok(StreamableHttpPostResponse::Json(parsed, session)) + } + + async fn delete_session( + &self, + uri: Arc, + session: Arc, + auth: Option, + headers: HashMap, + ) -> Result<(), Error> { + request( + &self.0, + reqwest::Method::DELETE, + &uri, + Some(session), + auth, + headers, + ) + .send() + .await + .map_err(|_| failed())?; + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session: Option>, + last: Option, + auth: Option, + headers: HashMap, + ) -> Result>, Error> { + let mut request = request(&self.0, reqwest::Method::GET, &uri, session, auth, headers); + if let Some(last) = last { + request = request.header("last-event-id", last); + } + let response = request + .send() + .await + .map_err(|_| failed())? + .error_for_status() + .map_err(|_| failed())?; + Ok(SseStream::from_bytes_stream(stream(response)).boxed()) + } +} diff --git a/rust/lithe-agent-host/src/mcp_probe/tests.rs b/rust/lithe-agent-host/src/mcp_probe/tests.rs new file mode 100644 index 0000000000..5870359ab2 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/tests.rs @@ -0,0 +1,236 @@ +//! Local transport fixtures never call a public server or an installed runtime. +use super::*; +use std::io::{BufRead, Write}; + +fn fixture_request() -> Request { + Request { + transport: "stdio".into(), + command: Some( + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + ), + args: vec![ + "--exact".into(), + "mcp_probe::tests::fixture_server".into(), + "--nocapture".into(), + ], + env: vec![ + Entry { + key: "LITHE_MCP_PROBE_FIXTURE".into(), + value: "1".into(), + }, + Entry { + key: "PATH".into(), + value: std::env::var("PATH").unwrap_or_default(), + }, + ], + url: None, + headers: vec![], + } +} + +fn response(message: &Value) -> Option { + let id = message.get("id")?; + let result = match message["method"].as_str()? { + "server/discover" => { + return Some( + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Use initialize"}}), + ) + } + "initialize" => { + json!({"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}}) + } + "tools/list" if message["params"]["cursor"] == "next" => { + json!({"tools":[{"name":"second","description":"Fixture page two","inputSchema":{"type":"object"}}]}) + } + "tools/list" => { + json!({"tools":[{"name":"first","description":std::process::id().to_string(),"inputSchema":{"type":"object"}}],"nextCursor":"next"}) + } + _ => panic!("Probe attempted a method outside metadata listing"), + }; + Some(json!({"jsonrpc":"2.0","id":id,"result":result})) +} + +#[test] +fn fixture_server() { + if std::env::var("LITHE_MCP_PROBE_FIXTURE").as_deref() != Ok("1") { + return; + } + // Child-only blocking stdin is owned by the parent probe's 30-second + // deadline and mandatory process-tree kill, never the test executor. + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { + break; + }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value["method"] == "tools/list" { + if let Ok(address) = std::env::var("LITHE_MCP_FIXTURE_SIGNAL") { + let mut signal = std::net::TcpStream::connect(address).unwrap(); + write!(signal, "{}", std::process::id()).unwrap(); + continue; + } + } + if let Some(value) = response(&value) { + println!("{value}"); + std::io::stdout().flush().unwrap(); + } + } +} + +#[test] +fn real_stdio_probe_lists_pages_and_reaps_its_child() { + let value = execute(fixture_request(), &|| false).unwrap(); + assert_eq!(value["tools"][0]["name"], "first"); + assert_eq!(value["tools"][1]["name"], "second"); + #[cfg(unix)] + { + let pid: i32 = value["tools"][0]["description"] + .as_str() + .unwrap() + .parse() + .unwrap(); + // SAFETY: signal zero only checks whether the already-reaped child exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } +} + +#[test] +fn cancellation_and_invalid_remote_inputs_do_not_launch_anything() { + assert_eq!( + execute(fixture_request(), &|| true).unwrap_err(), + Error::Cancelled + ); + for url in [ + "http://example.invalid/mcp", + "https://user:token@example.invalid/mcp", + "file:///tmp/mcp", + ] { + let mut r = fixture_request(); + r.transport = "http".into(); + r.url = Some(url.into()); + assert_eq!(execute(r, &|| false).unwrap_err(), Error::Invalid); + } +} + +#[tokio::test(start_paused = true)] +async fn silent_server_hits_virtual_deadline_and_cancellation_is_observed() { + let (client, _server) = tokio::io::duplex(4096); + let start = tokio::time::Instant::now(); + let result = inventory(client, &|| false).await; + assert_eq!(result.unwrap_err(), Error::Timeout); + assert!(start.elapsed() <= DEADLINE + Duration::from_secs(2)); + let (client, _server) = tokio::io::duplex(4096); + assert_eq!( + inventory(client, &|| true).await.unwrap_err(), + Error::Cancelled + ); +} + +#[tokio::test] +async fn cancellation_after_stdio_handshake_reaps_the_live_child() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut request = fixture_request(); + request.env.push(Entry { + key: "LITHE_MCP_FIXTURE_SIGNAL".into(), + value: listener.local_addr().unwrap().to_string(), + }); + let cancelled = std::cell::Cell::new(false); + let cancel = || cancelled.get(); + let signal = async { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut pid = String::new(); + stream.read_to_string(&mut pid).await.unwrap(); + cancelled.set(true); + pid + }; + let (result, pid) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(run(request, &cancel), signal) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err(), Error::Cancelled); + #[cfg(unix)] + { + let pid: i32 = pid.parse().unwrap(); + // SAFETY: signal zero only checks for the reaped fixture process. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } + #[cfg(not(unix))] + let _ = pid; +} + +#[tokio::test] +async fn real_loopback_http_probe_uses_the_same_inventory_protocol() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + for mode in ["json", "sse", "oversized-header", "oversized-body"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut first = String::new(); + reader.read_line(&mut first).await.unwrap(); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + assert!(length < 16384); + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await.unwrap(); + let body = if first.starts_with("POST") { + response(&serde_json::from_slice(&bytes).unwrap()) + } else { + None + }; + let mut text = body.map(|v| v.to_string()).unwrap_or_default(); + let content_type = if mode == "sse" { + "text/event-stream" + } else { + "application/json" + }; + if mode == "sse" && !text.is_empty() { + text = format!("event: message\ndata: {text}\n\n"); + } + let reply = if mode == "oversized-header" { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + MAX_BYTES + 1 + ) + } else if mode == "oversized-body" { + // No length header: enforce the cap while streaming, before JSON decode. + format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", "x".repeat(MAX_BYTES as usize + 1)) + } else { + format!("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", if text.is_empty() { "202 Accepted" } else { "200 OK" }, content_type, text.len(), text) + }; + // The client may reject the oversized response before all bytes are sent. + if reader.get_mut().write_all(reply.as_bytes()).await.is_err() { + break; + } + } + }); + let mut request = fixture_request(); + request.transport = "http".into(); + request.url = Some(format!("http://{address}/mcp")); + let result = tokio::time::timeout(Duration::from_secs(5), run(request, &|| false)).await; + server.abort(); + let _ = server.await; + let result = result.unwrap(); + if mode.starts_with("oversized") { + assert_eq!(result.unwrap_err(), Error::Connection); + } else { + assert_eq!(result.unwrap()["tools"].as_array().unwrap().len(), 2); + } + } +} diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 70a34ebdea..f2b1e4cfce 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -25,6 +25,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" +shell-words = "=1.1.1" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } toml_edit = "0.25" tree-sitter = "0.25" diff --git a/rust/lithe-core/src/mcp/mod.rs b/rust/lithe-core/src/mcp/mod.rs index 23d6d53873..557e219a30 100644 --- a/rust/lithe-core/src/mcp/mod.rs +++ b/rust/lithe-core/src/mcp/mod.rs @@ -11,11 +11,54 @@ mod plan; mod registry; mod source_hash; mod toml_edit; +mod tools; pub use model::{McpKeyValue, McpServer, McpTransport}; pub use parse::parse_configuration; pub use plan::plan_write; pub use registry::{featured_registry, parse_registry}; +pub use tools::{command_line, tool_policy}; + +/// Bridges a user-requested probe to the process/network owner in Agent Host. +pub(crate) fn probe( + request: lithe_agent_host::mcp_probe::Request, +) -> Result { + use crate::protocol::{cancellation, CoreError, ErrorCode}; + cancellation::check()?; + let result = lithe_agent_host::mcp_probe::execute(request, &|| cancellation::check().is_err()); + cancellation::check()?; + result.map_err(|error| { + use lithe_agent_host::mcp_probe::Error; + let (code, message) = match error { + Error::Invalid => ( + ErrorCode::InvalidRequest, + "Check the MCP command, URL and headers.", + ), + Error::Unsupported => ( + ErrorCode::NotSupported, + "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + ), + Error::Start => ( + ErrorCode::RuntimeMissing, + "Could not start the MCP command. Check its executable and runtime.", + ), + Error::Connection => ( + ErrorCode::ProcessFailed, + "MCP connection failed. Check the server, network and authentication.", + ), + Error::Limit => ( + ErrorCode::InvalidRequest, + "MCP tool metadata exceeded the inspection limit.", + ), + Error::Timeout => ( + ErrorCode::TimedOut, + "MCP detection timed out after 30 seconds.", + ), + Error::Cancelled => (ErrorCode::Cancelled, "MCP detection was cancelled."), + }; + CoreError::new(code, message) + }) +} #[cfg(test)] mod tests; diff --git a/rust/lithe-core/src/mcp/tools.rs b/rust/lithe-core/src/mcp/tools.rs new file mode 100644 index 0000000000..b239797cc9 --- /dev/null +++ b/rust/lithe-core/src/mcp/tools.rs @@ -0,0 +1,254 @@ +//! Deterministic command-line editing and native per-tool exclusion planning. +//! Hosts own file snapshots and remember only the exact exclusions they added. + +use super::{json_edit::JsonDocument, parse::MAX_CONFIGURATION_BYTES}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +fn invalid() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP tool configuration") +} + +/// Parses or formats one terminal-like argv projection without invoking a shell. +pub fn command_line(payload: Value) -> Result { + let line = if payload["action"] == "format" { + let command = payload["command"].as_str().ok_or_else(invalid)?; + let args = strings(payload.get("args"))?; + if command.is_empty() && args.is_empty() { + return Ok(json!({"line":""})); + } + if command.len() + args.iter().map(String::len).sum::() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let all = std::iter::once(command).chain(args.iter().map(String::as_str)); + return Ok(json!({"line": shell_words::join(all)})); + } else if payload["action"] == "parse" { + payload["line"].as_str().ok_or_else(invalid)? + } else { + return Err(invalid()); + }; + if line.len() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let parts = shell_words::split(line).map_err(|_| invalid())?; + Ok( + json!({"command": parts.first().cloned().unwrap_or_default(), "args": parts.into_iter().skip(1).collect::>() }), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PolicyRequest { + /// Native owner, `codex` or `claude`. + source: String, + /// Current bounded UTF-8 text from the platform snapshot. + content: String, + /// Native MCP entry name. + server: String, + /// Names reported by this server, never a wildcard expression. + tools: Vec, + /// Exclusions previously added by this Lithe library for this native path. + #[serde(default)] + owned: Vec, + /// Optional explicit one-tool mutation; absence reads policy only. + tool: Option, + enabled: Option, +} + +/// Returns effective local exclusions and an optional format-preserving edit. +/// External exact/wildcard denies and allow-lists are never relaxed here. +pub fn tool_policy(payload: Value) -> Result { + let r: PolicyRequest = serde_json::from_value(payload).map_err(|_| invalid())?; + if r.content.len() > MAX_CONFIGURATION_BYTES + || r.tools.len() > 1000 + || r.owned.len() > 1000 + || !valid_name(&r.server) + || r.tools.iter().chain(&r.owned).any(|s| !valid_name(s)) + || r.tool.as_ref().is_some_and(|s| !r.tools.contains(s)) + || r.tool.is_some() != r.enabled.is_some() + { + return Err(invalid()); + } + let (mut denied, allow) = match r.source.as_str() { + "codex" => { + let doc: toml::Value = toml::from_str(&r.content).map_err(|_| invalid())?; + let entry = doc + .get("mcp_servers") + .and_then(|v| v.get(&r.server)) + .ok_or_else(invalid)?; + let list = |key: &str| -> Result, CoreError> { + match entry.get(key) { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } + }; + ( + list("disabled_tools")?, + if entry.get("enabled_tools").is_some() { + Some(list("enabled_tools")?) + } else { + None + }, + ) + } + "claude" => { + let doc: Value = serde_json::from_str(if r.content.is_empty() { + "{}" + } else { + &r.content + }) + .map_err(|_| invalid())?; + if !doc.is_object() || doc.get("permissions").is_some_and(|v| !v.is_object()) { + return Err(invalid()); + } + (strings(doc.pointer("/permissions/deny"))?, None) + } + _ => return Err(invalid()), + }; + let rule = |tool: &str| -> String { + if r.source == "claude" { + format!("mcp__{}__{}", normalize(&r.server), normalize(tool)) + } else { + tool.to_owned() + } + }; + if r.source == "claude" + && r.tool.as_ref().is_some_and(|tool| { + r.tools + .iter() + .any(|other| other != tool && normalize(other) == normalize(tool)) + }) + { + return Err(invalid()); + } + let is_blocked = |tool: &str, list: &[String]| { + if r.source == "codex" { + list.iter().any(|s| s == tool) + } else { + let exact = rule(tool); + list.iter() + .any(|s| wildcard(s, &exact) || s == &format!("mcp__{}", normalize(&r.server))) + } + }; + let mut owned = r.owned.clone(); + let mut changed = false; + if let (Some(tool), Some(enabled)) = (&r.tool, r.enabled) { + let exact = rule(tool); + if enabled { + if allow.as_ref().is_some_and(|a| !a.contains(tool)) + || (is_blocked(tool, &denied) && !owned.contains(tool)) + { + return Err(CoreError::new(ErrorCode::PermissionDenied, "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.")); + } + let length = denied.len(); + denied.retain(|s| s != &exact); + changed = length != denied.len(); + owned.retain(|s| s != tool); + } else if !is_blocked(tool, &denied) { + denied.push(exact); + if !owned.contains(tool) { + owned.push(tool.clone()); + } + changed = true; + } + } + let content = if changed { + if r.source == "codex" { + let mut doc: toml_edit::DocumentMut = r.content.parse().map_err(|_| invalid())?; + let entry = doc + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&r.server)) + .and_then(toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid)?; + let mut array = toml_edit::Array::new(); + for name in &denied { + array.push(name.as_str()); + } + entry.insert("disabled_tools", toml_edit::value(array)); + doc.to_string() + } else { + let mut doc = JsonDocument::parse(if r.content.is_empty() { + "{}" + } else { + &r.content + })?; + let object = doc.ensure_object(&["permissions"])?; + doc.set_member( + object, + "deny", + &serde_json::to_string(&denied).map_err(|_| invalid())?, + )?; + doc.into_string()? + } + } else { + r.content.clone() + }; + let tools: Vec<_> = r.tools.iter().map(|tool| { + let blocked = is_blocked(tool, &denied); + let outside_allow = allow.as_ref().is_some_and(|a| !a.contains(tool)); + // An owned exact rule does not authorize removing a broader user deny. + let other_deny = denied.iter().any(|s| s != &rule(tool) && is_blocked(tool, std::slice::from_ref(s))); + let ambiguous = r.source == "claude" && r.tools.iter().any(|other| other != tool && normalize(other) == normalize(tool)); + json!({"name":tool,"enabled":!blocked && !outside_allow,"editable":!ambiguous && !outside_allow && !other_deny && (!blocked || owned.contains(tool))}) + }).collect(); + let has_restrictions = if r.source == "claude" { + denied + .iter() + .any(|s| s.starts_with("mcp") || s.starts_with('*')) + } else { + !denied.is_empty() || allow.is_some() + }; + Ok( + json!({"content":content,"changed":changed,"owned":owned,"tools":tools,"hasRestrictions":has_restrictions}), + ) +} + +fn strings(value: Option<&Value>) -> Result, CoreError> { + match value { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 256 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)) +} + +fn normalize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "_-".contains(c) { + c + } else { + '_' + } + }) + .collect() +} + +fn wildcard(pattern: &str, value: &str) -> bool { + let pattern = pattern + .split('*') + .map(regex::escape) + .collect::>() + .join(".*"); + regex::Regex::new(&format!("^{pattern}$")).is_ok_and(|re| re.is_match(value)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/mcp/tools/tests.rs b/rust/lithe-core/src/mcp/tools/tests.rs new file mode 100644 index 0000000000..e336b90ffb --- /dev/null +++ b/rust/lithe-core/src/mcp/tools/tests.rs @@ -0,0 +1,114 @@ +//! Per-tool policy and command projections protect native ownership boundaries. +use super::*; + +fn request(source: &str, content: &str) -> Value { + json!({"source":source,"content":content,"server":"docs","tools":["read","write"],"owned":[]}) +} + +#[test] +fn quoted_command_line_preserves_every_argument_without_shell_execution() { + let args = vec![ + "", + "a b", + "it's quoted", + "line\nnext", + "$(touch forbidden)", + "a;rm -f file", + "é", + ]; + let formatted = + command_line(json!({"action":"format","command":"/fake tools/server","args":args})) + .unwrap(); + let parsed = command_line(json!({"action":"parse","line":formatted["line"]})).unwrap(); + assert_eq!(parsed["command"], "/fake tools/server"); + assert_eq!(parsed["args"], json!(args)); + assert!(command_line(json!({"action":"parse","line":"npx 'unterminated"})).is_err()); +} + +#[test] +fn codex_only_removes_own_deny_and_retains_unknown_settings() { + let content = "# user formatting\n[mcp_servers.docs]\ncommand = 'node'\nargs = ['server']\ncustom = 9\ndisabled_tools = ['write']\n"; + let mut r = request("codex", content); + let read = tool_policy(r.clone()).unwrap(); + assert_eq!( + read["tools"][1], + json!({"name":"write","enabled":false,"editable":false}) + ); + r["tool"] = json!("write"); + r["enabled"] = json!(true); + assert!(tool_policy(r.clone()).is_err()); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"].as_str().unwrap().contains("custom = 9")); + assert!(off["content"] + .as_str() + .unwrap() + .starts_with("# user formatting\n")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + let doc: toml::Value = toml::from_str(on["content"].as_str().unwrap()).unwrap(); + assert_eq!( + doc["mcp_servers"]["docs"]["disabled_tools"] + .as_array() + .unwrap()[0] + .as_str(), + Some("write") + ); + assert_eq!(on["tools"][0]["enabled"], true); +} + +#[test] +fn claude_exact_and_wildcard_denies_are_not_relaxed() { + let content = "{\n \"user\": {\"keep\": [1, 2]},\n \"permissions\": {\"deny\": [\"Bash(rm *)\", \"mcp__docs__write\"],\"allow\": [\"Read\"]}\n}"; + let mut r = request("claude", content); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"] + .as_str() + .unwrap() + .contains("\"user\": {\"keep\": [1, 2]}")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + assert_eq!( + serde_json::from_str::(on["content"].as_str().unwrap()).unwrap()["permissions"] + ["deny"], + json!(["Bash(rm *)", "mcp__docs__write"]) + ); + let mut broad = request( + "claude", + r#"{"permissions":{"deny":["mcp__d*__*write", "mcp__docs__read"]}}"#, + ); + broad["owned"] = json!(["write"]); + let state = tool_policy(broad).unwrap(); + assert_eq!(state["tools"][1]["editable"], false); + assert_eq!(state["tools"][1]["enabled"], false); +} + +#[test] +fn allow_lists_ambiguous_names_and_malformed_policy_fail_closed() { + let r = request( + "codex", + "[mcp_servers.docs]\ncommand='node'\nenabled_tools=['write']\n", + ); + assert_eq!(tool_policy(r).unwrap()["tools"][0]["editable"], false); + let mut r = request("claude", "{}"); + r["tools"] = json!(["read.one", "read_one"]); + assert!(tool_policy(r).unwrap()["tools"] + .as_array() + .unwrap() + .iter() + .all(|t| t["editable"] == false)); + for content in [r#"{"permissions":[]}"#, r#"{"permissions":{"deny":[1]}}"#] { + assert!(tool_policy(request("claude", content)).is_err()); + } + let mut r = request("claude", "{}"); + r["tool"] = json!("*"); + r["enabled"] = json!(false); + assert!(tool_policy(r).is_err()); +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 176a18e3f5..094f9d984e 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -62,6 +62,12 @@ pub enum CoreCommand { McpFeaturedRegistry, /// Plans a format-preserving MCP entry write or removal (`mcp.planWrite`). McpPlanWrite, + /// Probes an explicitly selected server without calling its tools. + McpProbe, + /// Reads or plans native per-tool exclusion lists. + McpToolPolicy, + /// Round-trips a quoted command line into exact argv boundaries. + McpCommandLine, /// Validates and normalizes a declarative plugin manifest (`plugins.declarative.validate`). PluginsDeclarativeValidate, /// Expands a declarative plugin's run template (`plugins.declarative.runPlan`). @@ -444,6 +450,9 @@ impl CoreCommand { "mcp.parseRegistry" => Some(Self::McpParseRegistry), "mcp.featuredRegistry" => Some(Self::McpFeaturedRegistry), "mcp.planWrite" => Some(Self::McpPlanWrite), + "mcp.probe" => Some(Self::McpProbe), + "mcp.toolPolicy" => Some(Self::McpToolPolicy), + "mcp.commandLine" => Some(Self::McpCommandLine), "plugins.declarative.validate" => Some(Self::PluginsDeclarativeValidate), "plugins.declarative.runPlan" => Some(Self::PluginsDeclarativeRunPlan), "plugins.declarative.discoverTests" => Some(Self::PluginsDeclarativeDiscoverTests), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index aeb419d256..cfe8f743c4 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -169,6 +169,15 @@ fn execute(request: &str) -> CoreResponse { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::McpProbe => agent_response(id, parsed.payload, crate::mcp::probe), + CoreCommand::McpToolPolicy => match crate::mcp::tool_policy(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpCommandLine => match crate::mcp::command_line(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, CoreCommand::McpPlanWrite => match crate::mcp::plan_write(parsed.payload) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 6366bb1621..dd04ef3347 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -388,6 +388,8 @@ async function main() { // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. + // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and + // in-memory snapshots belong to one user profile/page, never build reuse. // Includes mcp-registry-metadata: responses have no copy stage; featured.json // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index bfec1834dc..e88e264d29 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -230,7 +230,7 @@ try { await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "agent-instruction-library-state"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 9933c78172..172fcd12bd 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -332,6 +332,17 @@ ], "identity": "Mutable user-authored instruction text, native snapshots and nonblocking synchronization locks; no version/platform/architecture/toolchain build identity.", "reason": "The platform storage adapter supplies the writable application-support root. Agent Host owns bounded UTF-8 reads and guarded atomic saves; only explicit saves and switches write. Temporary siblings are removed after success/failure, the empty lock stays with the user library, and native files retain upstream ownership outside the Lithe block. Never copy, bundle, share or validate these as worktree build resources; installed packages and Sparkle baselines remain read-only." + }, + { + "id": "mcp-tool-probe-state", + "reusable": false, + "locations": [ + "/MCPTools/{connections,tool-descriptions,policy-snapshots}", + "/settings.mcpToolExclusions..", + "/{config.toml,settings.json}" + ], + "identity": "User-requested, bounded SDK connections and mutable native exclusion rules. No generated build identity, version/platform/architecture/toolchain stamp or reusable artifact.", + "reason": "Probes run only after an explicit action, list metadata without tool calls, and cancel on page exit. Process trees and HTTP sessions are cleaned up; temporary policy snapshots stay in memory. Native rules use the existing platform atomic writer and Application Support backups. User preferences remember only exclusions Lithe added and never credentials. No copy stage, runtime bundle writes, or cross-worktree transfer is allowed. Upstream package caches created by an explicitly run command remain owned by that runtime, not Lithe build resources." } ] } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 91125f5c7a..c71237875c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -603,6 +603,44 @@ stable error code and a user-facing message: ### MCP configuration +`mcp.probe` accepts a user-reviewed connection `{transport, command?, args?, env?, +url?, headers?}` with only those fields. Agent Host uses the pinned official +rmcp SDK for initialization, session handling and paginated `tools/list`; it +never sends `tools/call`. Results are `{tools:[{name,description}]}`, sorted by +name, deduplicated, bounded to 1000 tools/20 pages/2 MiB of metadata. stdio input +and each HTTP response body are bounded before decoding. A probe has a +30-second request budget plus bounded cleanup, cancellation is cooperative, +and stdio children and their process groups are reaped on completion or cancel. +HTTP accepts HTTPS and loopback HTTP, rejects credential-bearing URLs and +redirects, preserves TLS verification and explicit headers, and uses no cookie +store. It does not inherit another Agent's OAuth session or unmapped native +configuration options. Legacy `sse` remains a configurable transport but this +SDK probe returns `not_supported` rather than inventing a healthy status. +The host must require an explicit detection action; launching local code or +package runners also requires the product's execution confirmation. + +`mcp.toolPolicy` is a pure text operation with `{source,content,server,tools, +owned?,tool?,enabled?}`. `source` is `codex` or `claude`; omission of `tool` and +`enabled` reads current policy. It returns `{content,changed,owned,tools, +hasRestrictions}`. The returned tools contain `{name,enabled,editable}`. +Codex uses entry-local `disabled_tools`, preserving `enabled_tools`; Claude's +input is its user `settings.json`, editing only `permissions.deny` with exact +`mcp____` rules. Unknown settings/formatting +outside the targeted value survive. Existing unowned denies, broad wildcard +rules, allow-list exclusions and ambiguous normalized names are not relaxed. +Hosts retain the exact added tool names by native path/server, validate full +file snapshots before atomic writes, and never treat a read as ownership. +Policies describe the user configuration; project/managed settings and live +Agent sessions may differ. Claude server renames are refused while user MCP +restrictions exist, to avoid re-enabling tools under a new name. + +`mcp.commandLine` accepts `{action:"format",command,args}` to return `{line}`, +or `{action:"parse",line}` to return `{command,args}`. POSIX shell-style quoting +is only a lossless text projection: hosts pass argv directly, never evaluate a +shell, substitutions, pipes or redirects. Blank and embedded-newline arguments +are preserved; malformed quoting is rejected. These operations and probe +results use `shared/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. + `mcp.parseRegistry` accepts `{ content: string }` (at most 1 MiB UTF-8) and normalizes an official MCP Registry v0.1 response into `{ entries, nextCursor }`. Each entry has `id`, `title`, `summary`, `version`, nullable `publisher`, diff --git a/shared/fixtures/mcp/tools-v1.json b/shared/fixtures/mcp/tools-v1.json new file mode 100644 index 0000000000..5384e9f4e1 --- /dev/null +++ b/shared/fixtures/mcp/tools-v1.json @@ -0,0 +1,52 @@ +{ + "probeRequest": { + "transport": "http", + "url": "https://example.invalid/mcp", + "command": null, + "args": [], + "env": [], + "headers": [] + }, + "probeResult": { + "tools": [ + { + "name": "read", + "description": "Synthetic read-only tool metadata" + } + ] + }, + "policyRequest": { + "source": "claude", + "content": "{}", + "server": "docs", + "tools": [ + "read" + ], + "owned": [], + "tool": "read", + "enabled": false + }, + "policyResult": { + "content": "{\n \"permissions\": {\n \"deny\": [\"mcp__docs__read\"]\n}\n}", + "changed": true, + "owned": [ + "read" + ], + "tools": [ + { + "name": "read", + "enabled": false, + "editable": true + } + ], + "hasRestrictions": true + }, + "commandLine": { + "command": "/fixture/my tools/server", + "args": [ + "", + "folder with spaces", + "a'b" + ] + } +} diff --git a/shared/platform-feature-matrix/features/mcp-server-library.json b/shared/platform-feature-matrix/features/mcp-server-library.json index b85bb45af1..a7f06c902e 100644 --- a/shared/platform-feature-matrix/features/mcp-server-library.json +++ b/shared/platform-feature-matrix/features/mcp-server-library.json @@ -2,7 +2,7 @@ "id": "mcp-server-library", "area": "AI", "group": "IDE MCP", - "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入", + "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入;紧凑图标配置弹窗及精确命令行参数投影,主动 stdio/Streamable HTTP 工具检测,按 Codex/Claude 原生规则独立管理工具开关并保留外部禁用项", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -21,7 +21,13 @@ "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", "macos/Tests/LitheTests/McpSymlinkConfigurationTests.swift", "macos/Tests/LitheTests/McpWriteProtectionTests.swift", - "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift" + "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", + "macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift", + "macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift", + "macos/Sources/Lithe/Views/App/McpToolsPanel.swift", + "rust/lithe-agent-host/src/mcp_probe.rs", + "rust/lithe-core/src/mcp/tools.rs", + "macos/Tests/LitheTests/McpToolsFeatureTests.swift" ] }, "windows": { @@ -33,5 +39,5 @@ }, "owner": "Agent", "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。", - "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。" + "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 261febaf04..42eed997dc 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -369,6 +369,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -2857,17 +2863,20 @@ dependencies = [ "flate2", "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest 0.12.28", + "rmcp", "semver", "serde", "serde_json", "serde_yaml_ng", "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", @@ -2896,6 +2905,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml 1.1.4+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", "tree-sitter", @@ -4496,6 +4506,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap 2.14.0", + "pin-project-lite", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5233,6 +5266,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6151,6 +6197,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" From 5a16cbdfb2097f1a18b97e4ddc867cdb99bd0ccd Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 11:36:36 +0800 Subject: [PATCH 08/16] fix(agent): preserve cancellation and native MCP tool filters --- .../feature/2026-10-17-mcp-server-library.md | 10 +- .../2026-10-21-agent-global-instructions.md | 7 ++ docs/ci-builds.md | 10 +- .../McpServerLibraryFeatureModel.swift | 24 +++- .../Core/Ports/McpConfigurationStore.swift | 17 ++- .../Rust/RustAgentInstructionsService.swift | 11 +- .../Core/Rust/RustCoreCancellation.swift | 30 +++++ .../Platform/MacOS/MCP/MacMcpTools.swift | 43 +------ .../AgentInstructionsAppearanceTests.swift | 8 +- .../AgentInstructionsCancellationTests.swift | 79 ++++++++++++ .../McpArgumentRoundTripTests.swift | 119 +++++++++++++++++- .../LitheTests/McpToolsFeatureTests.swift | 2 +- rust/lithe-agent-host/src/skills/tests.rs | 6 +- rust/lithe-core/src/mcp/plan.rs | 84 ++++++++++++- rust/lithe-core/src/mcp/tests.rs | 55 ++++++++ scripts/reuse-worktree-resources.mjs | 3 +- scripts/test-reuse-worktree-resources.mjs | 12 ++ scripts/worktree-resources.json | 4 +- shared/contracts/rust-core-api.md | 16 ++- .../features/agent-global-instructions.json | 8 +- .../features/mcp-server-library.json | 6 +- 21 files changed, 477 insertions(+), 77 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index aa313094b4..b9b1d327cf 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -87,13 +87,19 @@ Codex enabled_tools 以及名称规范化冲突保持不可放宽,页面显示 备份、条件替换,偏好只登记工具名所有权,不存令牌。Codex 更改规则会改变完整来源 哈希,页面只推进此前匹配的服务器所有权,不能把一次工具操作当成接管外部服务器。 -工具规则按原生路径/服务器名保留,即使暂时关闭服务器也保留限制。Claude 的规则引用 +工具规则按原生路径/服务器名保留,即使暂时关闭服务器也保留限制。Codex 删除条目前, +Core 提取原生允许/禁用名单,整批条件写入成功后才把这些工具名保存在原服务器账本的 +`toolFilters` 中;保留记录没有 `sourceHash`,不能授权修改外部重新创建的条目。重新添加 +缺失条目时恢复名单,并根据恢复后的完整内容登记新所有权。现存条目和重命名沿用原生 +规则,不能被旧保留值覆盖;空允许名单仍表示不允许任何工具,格式错误则拒绝删除。 +这份状态随用户资料保留,不保存命令、env、header 或令牌;新页面和应用重启都能恢复。 +Claude 的规则引用 服务器名称,原生用户配置存在 MCP 限制时拒绝在本页重命名,避免旧限制未迁移而让 工具重新可用;用户需先在原生规则中处理。项目/受管规则仍由上游 Agent 决定,页面 不会显示为已解除全局之外的限制。生命周期退出会取消探测、丢弃迟到结果并释放内存 快照;这些状态登记在 mcp-tool-probe-state,没有工作树复用阶段或发行目录运行时写入。 -检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消请求在操作注册前可能到达,因此仅在取消后重试传递取消信号,工作线程返回即停止计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 +检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消与指令保存共用 `RustCoreCancellation`:Core 登记操作后、执行命令前同步回调,补传注册前到达的取消,不需要重试计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 ### 发现市场与弹窗编辑 diff --git a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md index 75dfa185e6..2f8fd0deb1 100644 --- a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md +++ b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md @@ -42,6 +42,12 @@ Agent Host 负责原子文件操作,Core 负责严格请求分派,功能模 只锁定待处理行,其他行不因全局禁用变灰;取消拥有队列的页面任务后不启动后续写入。 重新进入页面只回读实际状态,不自动同步,也不丢弃未保存草稿。 +Swift 适配器在启动工作任务前检查取消,并通过 `RustCoreCancellation` 把注册前到达的 +取消保存到同步状态。Core 在登记操作后、分派命令前同步发出 `requestStarted`;回调在这个 +边界补传取消,因此快速保存也不会在注册空隙中漏掉取消。不要只调用一次尚未登记的 +`cancel`,也不要用定时重试保证短写入:写入可能在首次重试前完成。已完成的原子替换 +不会因稍后取消而撤销,跨文件保存仍只在每次写入之间观察取消。 + 正确做法:用户在 Codex 文件写了自有规则,开启共享后两者并存;关闭后自有规则逐字节保留。 不要把关闭共享解释成关闭 Codex 或删除其整份 AGENTS.md,也不要在启动时自动覆盖外部修改。 @@ -69,6 +75,7 @@ Agent Host 负责原子文件操作,Core 负责严格请求分派,功能模 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 - `SharedInstructionsFeatureTests` 覆盖共享草稿、切换排队、逐项失败与页面取消;Host shared 测试用临时目录验证真实读写、关闭字节还原、override、损坏标记和跨文件部分成功。 +- `AgentInstructionsCancellationTests` 使用真实 Core 和隔离共享源,确定性控制进入适配器前取消、编码后注册前取消,确认文件及锁未创建、已有字节不变,下一次有效保存仍能完成;不写开发者原生指令文件。 - 典型共享流程前后比较 app bundle 文件清单与哈希,用户目录不参与发行资源写入;`node --test scripts/test-reuse-worktree-resources.mjs` 确认共享指令用户状态不能复制。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentInstructionsFeatureModelTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests'`。 - Agent Host 计时测试覆盖原生优先级、读取不创建文件、保存和清空、外部修改与路径变化、UTF-8/大小限制、符号链接拒绝、权限保留和临时文件清理。 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 74571cefdb..f2ab69a57a 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -367,9 +367,17 @@ MCP 工具检测会话、工具描述、原生规则快照和工具排除所有 这些内容属于用户资料和一次页面操作,没有版本/平台/架构/工具链 identity stamp,所有复用阶段均拒绝复制。 官方 SDK 沿用 Cargo 锁定版本构建,stdio/HTTP 检测只在用户主动操作时运行; HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平台 adapter 条件替换并备份到 Application Support。 -工具名称所有权保存在平台偏好中,不存令牌;取消/离页释放进程与内存,不写 app bundle 或安装目录。 +工具名称所有权保存在平台偏好中,不存令牌;Codex 移除服务器后,允许/禁用工具名保留在 +同一用户资料的服务器账本 `toolFilters`,重新添加时恢复,不作为活动文件的所有权证明。 +这些保留值随资料持久化,不能复制到其他工作树;取消/离页释放进程与内存,不写 app bundle 或安装目录。 用户明确运行包命令可能产生的 npm/uv 缓存归上游运行时管理,不属于可跨工作树复用的 Lithe 构建资源。 +MCP 编辑器、工具面板和指令页的原生验收截图分别输出到 +`.artifacts/test-stability/mcp-ui/` 与 `.artifacts/test-stability/instructions-ui/`,随 +`test-stability-macos` artifact 提供给评审下载。截图只渲染隔离 fixture 的实际产品视图, +不读取桌面或真实用户配置;按 `agent-ui-test-evidence` 排除于所有工作树资源复制阶段, +本地随报告保留,CI 按 artifact retention 回收,不进入发行包。 + MCP 发现页读取的官方 Registry 在线响应登记为 `mcp-registry-metadata`,仅在当前进程内存中保存。 响应最多 1 MiB,由 Rust Core 校验;按搜索、分页和取消请求管理生命周期,没有持久缓存、 平台/架构/工具链身份或允许复制的阶段。资源复用脚本明确拒绝该项,不下载服务器包或图片, diff --git a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift index 2ecfd1ec0b..3c47b1615b 100644 --- a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift @@ -604,9 +604,10 @@ final class McpServerLibraryFeatureModel: ObservableObject { !source.isOwnedByLithe else { return } error = nil var entries = ledgerEntries(for: source.file) + let filters = entries.first { $0.name == row.name }?.toolFilters entries.removeAll { $0.name == row.name } entries.append(McpOwnedEntry( - name: row.name, definitionHash: source.definitionHash, sourceHash: source.sourceHash + name: row.name, definitionHash: source.definitionHash, sourceHash: source.sourceHash, toolFilters: filters )) setLedger(entries, for: source.file) refresh() @@ -816,7 +817,8 @@ final class McpServerLibraryFeatureModel: ObservableObject { name: name, previousName: previousName, owned: owned, - expectedHash: expectedHash + expectedHash: expectedHash, + toolFilters: server.flatMap { server in owned.first(where: { $0.name == server.name })?.toolFilters?[file.url.path] } ) } @@ -866,9 +868,10 @@ final class McpServerLibraryFeatureModel: ObservableObject { completedRenames[previousName] = expectedHash } if let hash = plan.definitionHash { + let filters = entries.first { $0.name == server.name }?.toolFilters entries.removeAll { $0.name == server.name } entries.append(McpOwnedEntry( - name: server.name, definitionHash: hash, sourceHash: plan.sourceHash + name: server.name, definitionHash: hash, sourceHash: plan.sourceHash, toolFilters: filters )) } case .remove(let name, let expectedHash): @@ -896,7 +899,17 @@ final class McpServerLibraryFeatureModel: ObservableObject { } content = plan.content changed = changed || plan.changed - entries.removeAll { $0.name == name } + // Rename already carries the native fields to the new name. + // Ordinary removal retains only non-secret restrictions, + // without a source hash that could authorize a future edit. + if plan.action == "removed" { + var filters = entries.first { $0.name == name }?.toolFilters ?? [:] + filters[file.url.path] = plan.toolFilters + entries.removeAll { $0.name == name } + if !filters.isEmpty, let hash = plan.definitionHash { + entries.append(McpOwnedEntry(name: name, definitionHash: hash, toolFilters: filters)) + } + } } } planned.append((file, source, content, entries, changed)) @@ -957,7 +970,8 @@ final class McpServerLibraryFeatureModel: ObservableObject { let next = snapshot.sourceHash, let source = row.globalSources.first(where: { $0.file.agent == .codex }) { var entries = ledgerEntries(for: source.file) if let index = entries.firstIndex(where: { $0.name == row.name && $0.sourceHash == previous }) { - entries[index] = McpOwnedEntry(name: row.name, definitionHash: row.definition.definitionHash, sourceHash: next) + entries[index] = McpOwnedEntry(name: row.name, definitionHash: row.definition.definitionHash, + sourceHash: next, toolFilters: entries[index].toolFilters) setLedger(entries, for: source.file) } } diff --git a/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift b/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift index d6e9642935..976141dc3f 100644 --- a/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift +++ b/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift @@ -111,17 +111,26 @@ struct McpServerDefinition: Codable, Hashable, Identifiable, Sendable { } } -/// Ledger entry recording that Lithe wrote a complete source entry. +/// Non-secret native filters retained across server removal and re-registration. +struct McpToolFilters: Codable, Hashable, Sendable { + let enabledTools: [String]? + let disabledTools: [String]? +} + +/// Ledger entry recording ownership, or retained filters with no live source hash. struct McpOwnedEntry: Codable, Hashable, Sendable { let name: String let definitionHash: String /// Missing in legacy records, which must be adopted again before automatic writes. let sourceHash: String? + /// Native paths stay distinct when the user switches Agent profile roots. + let toolFilters: [String: McpToolFilters]? - init(name: String, definitionHash: String, sourceHash: String? = nil) { + init(name: String, definitionHash: String, sourceHash: String? = nil, toolFilters: [String: McpToolFilters]? = nil) { self.name = name self.definitionHash = definitionHash self.sourceHash = sourceHash + self.toolFilters = toolFilters } } @@ -140,6 +149,8 @@ struct McpWriteRequest: Encodable, Sendable { let owned: [McpOwnedEntry] /// Full source version shown when editing; independent of the latest ledger. var expectedHash: String? = nil + /// Applied only to a new Codex entry; existing native policy always wins. + var toolFilters: McpToolFilters? = nil } /// Core's plan: the next file text plus whether anything changed. @@ -154,6 +165,8 @@ struct McpWritePlan: Decodable, Sendable { let definitionHash: String? /// Complete source version derived from the planned output, not the draft. let sourceHash: String? + /// Returned only after a successful removal, for the host's durable ledger. + let toolFilters: McpToolFilters? } /// One compare-and-replace operation in an all-or-rollback batch. diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift index 0ec6d7302d..a2315890dc 100644 --- a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift +++ b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift @@ -64,10 +64,15 @@ struct RustAgentInstructionsService: AgentInstructionsService { private func run(_ command: String, _ payload: Payload) async throws -> Result { let core = core let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) return try await withTaskCancellationHandler { - try await Task.detached(priority: .userInitiated) { - try core.executeResult(command: command, payload: payload, operationID: operationID).get() + try Task.checkCancellation() + return try await Task.detached(priority: .userInitiated) { + if cancellation.isCancelled { throw CancellationError() } + let result: Swift.Result = core.executeResult( + command: command, payload: payload, operationID: operationID, onEvent: { cancellation.receive($0) }) + return try result.get() }.value - } onCancel: { core.cancel(operationID: operationID) } + } onCancel: { cancellation.cancel() } } } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift b/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift new file mode 100644 index 0000000000..b54a6a6b77 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Replays an early cancellation at Core's synchronous registration callback. +/// The callback precedes dispatch, so even a short file mutation cannot outrun +/// a timer retry. No registration or worker outlives the enclosing C ABI call. +final class RustCoreCancellation: @unchecked Sendable { + private let lock = NSLock() + private let core: RustCoreBridge + private let operationID: String + private var cancelled = false + + init(core: RustCoreBridge, operationID: String) { + self.core = core + self.operationID = operationID + } + + var isCancelled: Bool { lock.withLock { cancelled } } + + func cancel() { + lock.withLock { cancelled = true } + core.cancel(operationID: operationID) + } + + func receive(_ event: String) { + struct Registration: Decodable { let type: String } + guard let registration = try? JSONDecoder().decode(Registration.self, from: Data(event.utf8)), + registration.type == "requestStarted", isCancelled else { return } + core.cancel(operationID: operationID) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift index d8c97dc98e..168c33db3d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift @@ -55,21 +55,19 @@ final class MacMcpTools: McpToolsManaging { let payload = Payload(transport: definition.transport, command: definition.command, args: definition.args, env: definition.env, url: definition.url, headers: definition.headers) let core = core; let operation = UUID().uuidString - let cancellation = McpProbeCancellation(core: core, operation: operation) - defer { cancellation.finish() } + let cancellation = RustCoreCancellation(core: core, operationID: operation) return try await withTaskCancellationHandler { try Task.checkCancellation() return try await withCheckedThrowingContinuation { continuation in // Debug SDK/TLS futures exceed the Swift executor's small stack. // This bounded blocking C ABI call owns a native thread instead. let worker = Thread { - defer { cancellation.finish() } if cancellation.isCancelled { continuation.resume(throwing: CancellationError()) return } let result: Result = core.executeResult( - command: "mcp.probe", payload: payload, operationID: operation) + command: "mcp.probe", payload: payload, operationID: operation, onEvent: { cancellation.receive($0) }) continuation.resume(with: result.mapError { $0 as any Error }) } worker.name = "Lithe MCP inspection" @@ -153,40 +151,3 @@ final class MacMcpTools: McpToolsManaging { "settings.mcpToolExclusions.\(file.agent.rawValue).\(file.url.path).\(name)" } } - -/// Cancellation can arrive before the C ABI registers its operation. Retry only -/// after cancellation, until the owned bounded worker completes, so that gap -/// cannot leave a probe running after navigation. No UI/cooperative thread waits. -private final class McpProbeCancellation: @unchecked Sendable { - private let lock = NSLock() - private let core: RustCoreBridge - private let operation: String - private var cancelled = false - private var finished = false - private var timer: DispatchSourceTimer? - - init(core: RustCoreBridge, operation: String) { self.core = core; self.operation = operation } - - var isCancelled: Bool { lock.withLock { cancelled } } - - func cancel() { - lock.withLock { - cancelled = true - guard !finished, timer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - let core = core, operation = operation - timer.setEventHandler { core.cancel(operationID: operation) } - timer.schedule(deadline: .now(), repeating: .milliseconds(50)) - self.timer = timer - timer.resume() - } - } - - func finish() { - lock.withLock { - finished = true - timer?.cancel() - timer = nil - } - } -} diff --git a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift index f9c176ac21..bebe4133af 100644 --- a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift +++ b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift @@ -43,11 +43,15 @@ struct AgentInstructionsAppearanceTests { #expect(rect.minX >= 0 && rect.maxX <= width + 1) #expect(editor.isEditable) #expect(feature.file?.path == "/fixture/user/.codex/AGENTS.md") - if let folder = ProcessInfo.processInfo.environment["LITHE_INSTRUCTIONS_SCREENSHOTS"] { + let folder = ProcessInfo.processInfo.environment["LITHE_INSTRUCTIONS_SCREENSHOTS"] + .map { URL(fileURLWithPath: $0) } + ?? DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/instructions-ui") + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) host.cacheDisplay(in: host.bounds, to: bitmap) let png = try #require(bitmap.representation(using: .png, properties: [:])) - let path = URL(fileURLWithPath: folder).appendingPathComponent("instructions-\(dark ? "dark" : "light")-\(language)-\(Int(width)).png") + let path = folder.appendingPathComponent("instructions-\(dark ? "dark" : "light")-\(language)-\(Int(width)).png") try png.write(to: path) } // Exercise native typing through SwiftUI's binding, not just model.edit. diff --git a/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift b/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift new file mode 100644 index 0000000000..f430821482 --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift @@ -0,0 +1,79 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct AgentInstructionsCancellationTests { + @Test func cancelledBeforeServiceEntryDoesNotSaveSharedInstructions() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let path = root.appendingPathComponent("Shared.md") + try Data("original shared instructions".utf8).write(to: path) + let service = RustAgentInstructionsService(core: RustCoreBridge(), sharedDirectory: root) + let library = try await service.readShared() + // No Agent targets: a non-cancelled request would write only our real + // isolated source file, never the developer's native instruction files. + let sourceOnly = SharedInstructionLibrary(file: library.file, agents: []) + let task = Task { try await service.saveShared(content: "cancelled edit", library: sourceOnly) } + task.cancel() // MainActor does not yield until cancellation is recorded. + do { + _ = try await task.value + Issue.record("A cancelled save must not publish a result") + } catch { #expect(error is CancellationError) } + #expect(try String(contentsOf: path, encoding: .utf8) == library.file.content) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["Shared.md"]) + } + + @Test func cancellationDuringEncodingIsReplayedBeforeCoreDispatch() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let service = RustAgentInstructionsService(core: RustCoreBridge(), sharedDirectory: root) + let library = try await service.readShared() + let core = RustCoreBridge() + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + // Encoding runs after the worker's entry check but before Core registers + // the operation. The real registration callback must replay this cancel. + let payload = CancelDuringEncoding(value: SharedSave(dataDirectory: root.path, + content: "must not be written", expectedRevision: library.file.revision), cancellation: cancellation) + let result: Result = core.executeResult( + command: "agent.instructions.shared", payload: payload, operationID: operationID, + onEvent: { cancellation.receive($0) }) + switch result { + case .success: Issue.record("Cancellation before registration must prevent dispatch") + case .failure(let error): #expect(error.code == "cancelled") + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + #expect(!core.cancel(operationID: operationID)) // Registration was released. + + // Prove the same valid snapshot can save, and an old cancellation does + // not poison the next unique operation or any native Agent files. + _ = try await service.saveShared(content: "allowed edit", library: SharedInstructionLibrary(file: library.file, agents: [])) + #expect(try String(contentsOf: root.appendingPathComponent("Shared.md"), encoding: .utf8) == "allowed edit") + } + + private func remove(_ root: URL) { + do { try FileManager.default.removeItem(at: root) } + catch { Issue.record("Instruction fixture cleanup failed: \(error)") } + } +} + +private struct SharedSave: Encodable { + let dataDirectory: String + let action = "save" + let content: String + let expectedRevision: String + let files: [String] = [] +} + +private struct CancelDuringEncoding: Encodable { + let value: SharedSave + let cancellation: RustCoreCancellation + func encode(to encoder: any Encoder) throws { + try value.encode(to: encoder) + cancellation.cancel() + } +} diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index f2b5cfb257..8a03a3858f 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -59,7 +59,7 @@ struct McpArgumentRoundTripTests { let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries let entry = try #require(entries.first { $0.id == "lithe.featured/playwright" }) var draft = try #require(McpServerDraft(entry: entry)); draft.enabledAgents = Set(McpAgent.allCases) - let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/mcp-tools") + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) for dark in [false, true] { let view = McpServerEditorView(feature: feature, draft: .constant(draft), onClose: {}) @@ -126,6 +126,102 @@ struct McpArgumentRoundTripTests { service.release([snapshot, latest]) } + @Test(arguments: ["switch", "editor", "delete"]) + func disabledToolsSurviveServerRemovalAndReopening(action: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo" + draft.env = [McpKeyValue(key: "TOKEN", value: "fixture-secret-must-not-be-archived")] + draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let names = ["read", "write", "outside-allowlist"] + let policy = try service.policy(for: target, agent: .codex, tools: names) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let claudeBefore = try fixture.nativeText(.claudeCode) + let current = try #require(feature.rows.first) + switch action { + case "switch": try #require(feature.setAgent(.codex, enabled: false, for: current)) + case "editor": + var edit = feature.draft(for: current); edit.enabledAgents.remove(.codex) + try #require(feature.save(edit)) + default: try #require(feature.delete(current)) + } + #expect(try !fixture.nativeText(.codex).contains("mcp_servers.alpha")) + #expect(!fixture.ledgerText().contains("fixture-secret-must-not-be-archived")) + + // A new page reads durable state, rather than the old policy snapshot. + let reopened = fixture.feature(); reopened.refresh() + if action == "switch" { + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + } else { + var add = McpServerDraft(definition: row.definition) + add.enabledAgents = Set(McpAgent.allCases) + try #require(reopened.save(add)) + } + let restored = try #require(reopened.rows.first) + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let restoredPolicy = try service.policy(for: restoredTarget, agent: .codex, tools: names) + defer { service.release([restoredPolicy]) } + #expect(restoredPolicy.tools.allSatisfy { !$0.enabled }) + #expect(restoredPolicy.tools.first { $0.name == "read" }?.editable == false) + #expect(restoredPolicy.tools.first { $0.name == "write" }?.editable == true) + #expect(restoredPolicy.tools.first { $0.name == "outside-allowlist" }?.editable == false) + let claude = try service.policy(for: restoredTarget, agent: .claudeCode, tools: names) + service.release([claude]) + #expect(claude.tools.allSatisfy { $0.enabled }) + if action != "delete" { #expect(try fixture.nativeText(.claudeCode) == claudeBefore) } + + let on = try service.setEnabled(true, tool: "write", target: restoredTarget, snapshot: restoredPolicy) + service.release([on]); reopened.tools.didChangePolicy?(restored, on) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + try #require(reopened.setAgent(.codex, enabled: false, for: try #require(reopened.rows.first))) + } + + @Test func retainedFiltersStayWithTheirNativeProfilePath() throws { + let ledger = ArgumentKeyValueStore() + let first = try ArgumentFixture(ledger: ledger) + defer { first.cleanUp() } + let second = try ArgumentFixture(ledger: ledger) + defer { second.cleanUp() } + var draft = McpServerDraft(); draft.name = "alpha"; draft.command = "/bin/echo" + draft.enabledAgents = Set(McpAgent.allCases) + let original = first.feature() + try #require(original.save(draft)) + try first.codexFilters(allow: [], deny: ["read"]) + original.refresh() + original.takeOwnership(of: try #require(original.rows.first), agent: .codex) + try #require(original.setAgent(.codex, enabled: false, for: try #require(original.rows.first))) + + let otherProfile = second.feature() + draft.enabledAgents = [.codex] + try #require(otherProfile.save(draft)) + #expect(try !second.nativeText(.codex).contains("enabled_tools")) + #expect(try !second.nativeText(.codex).contains("disabled_tools")) + try #require(otherProfile.delete(try #require(otherProfile.rows.first))) + + // Returning to the original native path restores its empty allow-list, + // even after a different profile reused the same name and preferences. + let reopened = first.feature(); reopened.refresh() + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + let row = try #require(reopened.rows.first) + let service = try #require(first.mcpTools) + let policy = try service.policy(for: McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)), + agent: .codex, tools: ["read", "write"]) + defer { service.release([policy]) } + #expect(policy.tools.allSatisfy { !$0.enabled && !$0.editable }) + } + @Test(arguments: McpAgent.allCases, ["github", "deepwiki", "exa", "filesystem", "fetch", "memory"]) func featuredSetupIsExplicitAndRoundTripsThroughNativeConfiguration(agent: McpAgent, name: String) throws { let fixture = try ArgumentFixture() @@ -250,7 +346,7 @@ struct McpArgumentRoundTripTests { private final class ArgumentFixture: McpServerLibraryActions { private let root: URL private let store: MacAgentMcpConfigurationStore - private let ledger = ArgumentKeyValueStore() + private let ledger: ArgumentKeyValueStore private lazy var toolService = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, storage: ArgumentFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) @@ -260,7 +356,8 @@ private final class ArgumentFixture: McpServerLibraryActions { var mcpWorkspaceURL: URL? { nil } var mcpLedgerStore: any KeyValueStore { ledger } - init() throws { + init(ledger: ArgumentKeyValueStore = ArgumentKeyValueStore()) throws { + self.ledger = ledger let directory = FileManager.default.temporaryDirectory .appendingPathComponent("mcp-arguments-\(UUID().uuidString)", isDirectory: true) root = directory @@ -315,6 +412,22 @@ private final class ArgumentFixture: McpServerLibraryActions { try store.read(file(for: agent)) != nil } + func nativeText(_ agent: McpAgent) throws -> String { try store.read(file(for: agent)) ?? "" } + + func codexFilters(allow: [String], deny: [String]) throws { + let file = try file(for: .codex) + let encode = { (values: [String]) in String(decoding: try JSONEncoder().encode(values), as: UTF8.self) } + let before = try nativeText(.codex) + let header = "[mcp_servers.alpha]\n" + try #require(before.contains(header)) + let text = before.replacingOccurrences(of: header, with: header + "enabled_tools = \(try encode(allow))\ndisabled_tools = \(try encode(deny))\n") + try Data(text.utf8).write(to: file.url) + } + + func ledgerText() -> String { + ledger.data(forKey: "settings.mcpServerRegistrations").map { String(decoding: $0, as: UTF8.self) } ?? "" + } + func allowedFolder() throws -> String { let directory = root.appendingPathComponent("allowed folder", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/macos/Tests/LitheTests/McpToolsFeatureTests.swift b/macos/Tests/LitheTests/McpToolsFeatureTests.swift index 00e5ae8c0f..1d7bb43ff3 100644 --- a/macos/Tests/LitheTests/McpToolsFeatureTests.swift +++ b/macos/Tests/LitheTests/McpToolsFeatureTests.swift @@ -49,7 +49,7 @@ struct McpToolsFeatureTests { let observer = feature.$states.sink { states in if states[row.id]?.isReady == true { ready.open() } } defer { observer.cancel(); feature.leavePage() } let task = try #require(feature.detect(row)); #expect(await ready.waitUntilOpen()); await task.value - let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/mcp-tools") + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) for width: CGFloat in [420, 760] { for dark in [false, true] { diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs index 1aa76d6158..c5561f78d8 100644 --- a/rust/lithe-agent-host/src/skills/tests.rs +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -290,10 +290,8 @@ fn directory_inventory_reports_configured_visibility_and_malformed_issues() { .iter() .all(|row| row["enabled"] == true)); assert_eq!(result["issues"].as_array().unwrap().len(), 1); - assert!(result["directories"]["repo"] - .as_str() - .unwrap() - .ends_with(".claude/skills")); + let directory = PathBuf::from(result["directories"]["repo"].as_str().unwrap()); + assert!(directory.ends_with(PathBuf::from(".claude").join("skills"))); } #[test] diff --git a/rust/lithe-core/src/mcp/plan.rs b/rust/lithe-core/src/mcp/plan.rs index 759d460527..d610a1634c 100644 --- a/rust/lithe-core/src/mcp/plan.rs +++ b/rust/lithe-core/src/mcp/plan.rs @@ -11,7 +11,7 @@ use super::parse::{ use super::toml_edit; use super::{McpKeyValue, McpServer}; use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; #[derive(Deserialize)] @@ -55,6 +55,21 @@ struct WriteRequest { /// Entries the host recorded as written by Lithe. #[serde(default)] owned: Vec, + /// Non-secret native filters retained when this server was removed. + #[serde(default)] + tool_filters: Option, +} + +/// Native Codex restrictions, kept separately from credentials and connection fields. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolFilters { + /// Absence differs from an empty allow-list, which allows no tools. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Preserve external exclusions as well as those owned by Lithe. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, } fn invalid_request() -> CoreError { @@ -167,7 +182,16 @@ fn plan_set( }; let next = match request.source.as_str() { "claude" => write_claude(content, project_path, &server.name, server, previous)?, - "codex" => write_codex(content, server, previous)?, + "codex" => write_codex( + content, + server, + previous, + if action == "inserted" { + request.tool_filters.as_ref() + } else { + None + }, + )?, _ => return Err(invalid_request()), }; // Unknown fields survive edits, so hash the actual output rather than the @@ -228,14 +252,50 @@ fn plan_remove( if next == content { return Ok(with_hashes(unchanged(content, "absent", None), current)); } + // Capture before deletion; malformed native restrictions must fail closed + // rather than disappearing when an Agent is switched back on. + let tool_filters = if request.source == "codex" { + codex_tool_filters(content, name)? + } else { + None + }; Ok(json!({ "content": next, "changed": true, "action": "removed", "conflict": Value::Null, + "definitionHash": current.definition_hash, + "toolFilters": tool_filters, })) } +/// Extract only the native tool lists; never archive env, headers or auth values. +fn codex_tool_filters(content: &str, name: &str) -> Result, CoreError> { + let document: toml::Value = toml::from_str(content).map_err(|_| invalid_request())?; + let entry = document + .get("mcp_servers") + .and_then(|v| v.get(name)) + .ok_or_else(invalid_request)?; + let list = |key: &str| -> Result>, CoreError> { + entry + .get(key) + .map(|value| { + value + .as_array() + .ok_or_else(invalid_request)? + .iter() + .map(|v| v.as_str().map(str::to_owned).ok_or_else(invalid_request)) + .collect() + }) + .transpose() + }; + let filters = ToolFilters { + enabled_tools: list("enabled_tools")?, + disabled_tools: list("disabled_tools")?, + }; + Ok((filters.enabled_tools.is_some() || filters.disabled_tools.is_some()).then_some(filters)) +} + fn existing_servers( request: &WriteRequest, content: &str, @@ -407,9 +467,29 @@ fn write_codex( content: &str, server: &McpServer, previous: Option<&str>, + filters: Option<&ToolFilters>, ) -> Result { let mut document = toml_edit::document(content)?; toml_edit::set_server(&mut document, server, previous)?; + if let Some(filters) = filters { + let entry = document + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&server.name)) + .and_then(::toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid_request)?; + for (key, values) in [ + ("enabled_tools", &filters.enabled_tools), + ("disabled_tools", &filters.disabled_tools), + ] { + if let Some(values) = values { + let mut array = ::toml_edit::Array::new(); + for value in values { + array.push(value.as_str()); + } + entry.insert(key, ::toml_edit::value(array)); + } + } + } Ok(document.to_string()) } diff --git a/rust/lithe-core/src/mcp/tests.rs b/rust/lithe-core/src/mcp/tests.rs index 5dd12651b1..7aaa7089f9 100644 --- a/rust/lithe-core/src/mcp/tests.rs +++ b/rust/lithe-core/src/mcp/tests.rs @@ -46,6 +46,61 @@ fn owned_entry(parsed: &Value, name: &str) -> Value { }) } +#[test] +fn removed_codex_filters_restore_without_credentials_or_allowlist_widening() { + for allowed in ["[]", "['read', 'write']"] { + let content = format!("model = 'keep'\n[mcp_servers.alpha]\ncommand = 'echo'\nenabled_tools = {allowed}\ndisabled_tools = ['external', 'write']\nenv = {{ TOKEN = 'secret' }}\n"); + let parsed = parse("codex", "global", &content, None); + let removed = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert_eq!(removed["action"], "removed"); + let filters = &removed["toolFilters"]; + assert_eq!(filters["disabledTools"], json!(["external", "write"])); + assert!(!filters.to_string().contains("secret")); + let restored = plan( + json!({"source":"codex","scope":"global","content":removed["content"], + "operation":"set","server":parsed["servers"][0],"owned":[],"toolFilters":filters}), + ); + let next: toml::Value = toml::from_str(restored["content"].as_str().unwrap()).unwrap(); + let original: toml::Value = toml::from_str(&content).unwrap(); + assert_eq!( + next["mcp_servers"]["alpha"], + original["mcp_servers"]["alpha"] + ); + assert_eq!(next["model"].as_str(), Some("keep")); + let reparsed = parse( + "codex", + "global", + restored["content"].as_str().unwrap(), + None, + ); + assert_eq!(restored["sourceHash"], source_hash(&reparsed, "alpha")); + } +} + +#[test] +fn retained_codex_filters_never_replace_live_native_policy() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['external']\nenabled_tools = []\n"; + let parsed = parse("codex", "global", content, None); + let restored = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"set","server":parsed["servers"][0],"owned":[owned_entry(&parsed,"alpha")], + "toolFilters":{"disabledTools":[],"enabledTools":["write"]}})); + assert_eq!(restored["content"], content); + assert_eq!(restored["changed"], false); +} + +#[test] +fn malformed_native_tool_lists_cannot_be_deleted_and_forgotten() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['read', 1]\n"; + let parsed = parse("codex", "global", content, None); + let result = plan_write(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert!(matches!( + result.unwrap_err().code, + ErrorCode::InvalidRequest + )); +} + fn plan(request: Value) -> Value { plan_write(request).expect("write plan should succeed") } diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index dd04ef3347..a68291dd88 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -389,7 +389,8 @@ async function main() { // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and - // in-memory snapshots belong to one user profile/page, never build reuse. + // retained Codex tool lists and in-memory snapshots belong to one user + // profile/page, never build reuse (including after server removal). // Includes mcp-registry-metadata: responses have no copy stage; featured.json // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index e88e264d29..2ff2ac5b5e 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -237,6 +237,18 @@ try { assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); } }); + await test("retained MCP filters and native review screenshots remain user/run state", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const policies = registry.excludedResources.find(item => item.id === "mcp-tool-probe-state"); + assert.ok(policies.locations.some(location => location.includes("settings.mcpServerRegistrations") && location.endsWith(".toolFilters"))); + const evidence = registry.excludedResources.find(item => item.id === "agent-ui-test-evidence"); + assert.ok(evidence.locations.includes(".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png")); + for (const resource of [policies, evidence]) { + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${resource.id}.*cannot be reused`)); + } + }); await test("featured catalog and bundled MCP marks follow Git source instead of resource reuse", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const row = registry.excludedResources.find(item => item.id === "mcp-registry-metadata"); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 172fcd12bd..982183b2c7 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -291,6 +291,7 @@ "reusable": false, "locations": [ ".artifacts/test-stability/*.png", + ".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png", ".artifacts//screenshots/*.png", "/lithe-localized-logging.*/Lithe Logging UI.app/" ], @@ -339,10 +340,11 @@ "locations": [ "/MCPTools/{connections,tool-descriptions,policy-snapshots}", "/settings.mcpToolExclusions..", + "/settings.mcpServerRegistrations...toolFilters", "/{config.toml,settings.json}" ], "identity": "User-requested, bounded SDK connections and mutable native exclusion rules. No generated build identity, version/platform/architecture/toolchain stamp or reusable artifact.", - "reason": "Probes run only after an explicit action, list metadata without tool calls, and cancel on page exit. Process trees and HTTP sessions are cleaned up; temporary policy snapshots stay in memory. Native rules use the existing platform atomic writer and Application Support backups. User preferences remember only exclusions Lithe added and never credentials. No copy stage, runtime bundle writes, or cross-worktree transfer is allowed. Upstream package caches created by an explicitly run command remain owned by that runtime, not Lithe build resources." + "reason": "Probes run only after an explicit action, list metadata without tool calls, and cancel on page exit. Process trees and HTTP sessions are cleaned up; temporary policy snapshots stay in memory. Native rules use the existing platform atomic writer and Application Support backups. User preferences remember added exclusion ownership and retained Codex allow/deny tool names after removal, never credentials. Retained filters survive page/process exit until re-registration and do not prove live source ownership. No copy stage, runtime bundle writes, or cross-worktree transfer is allowed. Upstream package caches created by an explicitly run command remain owned by that runtime, not Lithe build resources." } ] } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 98c6ddc5b0..3511788c03 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -702,12 +702,22 @@ lowercase SHA-256 hashes with separate purposes: `mcp.planWrite` accepts `{ source, scope, projectPath?, content?: string | null, operation: "set" | "remove", server?: McpServer, name?: string, -previousName?: string, expectedHash?: string, owned?: OwnedEntry[] }`. `set` +previousName?: string, expectedHash?: string, owned?: OwnedEntry[], +toolFilters?: {enabledTools?: string[], disabledTools?: string[]} }`. `set` requires `server`, `remove` requires `name`, and project scope requires `projectPath`. `previousName` identifies the original of a rename. Each owned entry is `{ name, definitionHash, sourceHash?: string }`; hosts must record the hashes returned from the actual write plan rather than hashes from the draft. +Successful Codex removals also return `toolFilters` when native `enabled_tools` +or `disabled_tools` exists. These lists contain tool names only; an empty +allow-list stays empty and malformed lists reject removal. Hosts retain them +by native path/server only after the complete write batch succeeds, separately +from live source ownership. A later `set` applies retained filters only when +inserting a new Codex entry; existing/renamed native policy always takes +precedence. Returned hashes cover the restored policy. Claude's user deny +rules remain in its separate settings file during server removal. + Automatic replacement/removal, including unchanged set plans used for repeated registration, requires an owned record whose name, `definitionHash` and `sourceHash` match the current entry. An existing raw entry omitted from discovery @@ -728,11 +738,11 @@ it is not owned. For rename, it refers to `previousName`, and cannot authorize an unrelated existing destination; destination ownership checks still apply. The response is `{ content, changed, action, conflict, definitionHash?, -sourceHash? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, +sourceHash?, toolFilters? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, or `conflict`; conflicts leave the input unchanged. Successful set plans return both hashes computed by parsing the actual output entry, including preserved unknown fields. Unchanged set plans also return the current entry's hashes; -removal/absence normally has no entry hashes. Hashes sent on the draft server +removal can return the old definition hash for filter retention; absence has no entry hashes. Hashes sent on the draft server are never trusted as write output or persisted into Agent configuration. ### Agent adapters diff --git a/shared/platform-feature-matrix/features/agent-global-instructions.json b/shared/platform-feature-matrix/features/agent-global-instructions.json index 020f262143..61bbe2f0e0 100644 --- a/shared/platform-feature-matrix/features/agent-global-instructions.json +++ b/shared/platform-feature-matrix/features/agent-global-instructions.json @@ -2,7 +2,7 @@ "id": "agent-global-instructions", "area": "AI", "group": "Agent 对话", - "capability": "全局指令卡片:一份共享正文按 Agent 显式启停同步到原生用户文件的 Lithe 区块,保留自有内容、实际路径、独立草稿及逐项错误;支持展开编辑、外部修改校验、原子保存和显式重新加载", + "capability": "全局指令卡片:一份共享正文按 Agent 显式启停同步到原生用户文件的 Lithe 区块,保留自有内容、实际路径、独立草稿及逐项错误;支持展开编辑、外部修改校验、原子保存和显式重新加载;取消在 Core 登记边界同步交付,已取消保存不因后台任务启动竞态写入", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -17,7 +17,9 @@ "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift", "macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift", "rust/lithe-agent-host/src/instructions/shared.rs", - "rust/lithe-agent-host/src/instructions/shared/tests.rs" + "rust/lithe-agent-host/src/instructions/shared/tests.rs", + "macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift", + "macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift" ] }, "windows": { @@ -33,6 +35,6 @@ ] }, "owner": "Agent", - "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。 新增共享卡片、Agent 图标/路径/自有行数与展开编辑;隔离用户目录真实保存、启用两 Agent、更新、关闭其中一个,逐字节确认私有内容不变,重新打开从原生区块回读开关;外部修改、损坏标记、只读目标与取消后不覆盖,单项失败继续其他项并保留错误。SharedInstructionsFeatureTests 覆盖连续开关排队、取消、失败和草稿;深浅色中英文宽窄布局需实机检查。", + "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。 新增共享卡片、Agent 图标/路径/自有行数与展开编辑;隔离用户目录真实保存、启用两 Agent、更新、关闭其中一个,逐字节确认私有内容不变,重新打开从原生区块回读开关;外部修改、损坏标记、只读目标与取消后不覆盖,单项失败继续其他项并保留错误。SharedInstructionsFeatureTests 覆盖连续开关排队、取消、失败和草稿;深浅色中英文宽窄布局需实机检查。 注册前取消回归:确定性控制调用前取消、请求编码时取消,真实 Core 返回 cancelled,已有共享源不变、缺失源和锁不创建,后续有效操作仍成功。CI test-stability-macos 产物包含 instructions-ui 的中英文、深浅主题、宽窄原生截图。", "notes": "共享源保存在平台 storage adapter 的 Application Support/Lithe/AgentInstructions/Shared.md,不额外拼接提示词。开关只控制 Lithe 区块,新会话由上游加载;按文件原子替换,跨文件部分失败可见。源、锁、暂存文件及原生指令均为用户状态,不可工作树复用,不写发行资源。" } diff --git a/shared/platform-feature-matrix/features/mcp-server-library.json b/shared/platform-feature-matrix/features/mcp-server-library.json index a7f06c902e..6b48a886c3 100644 --- a/shared/platform-feature-matrix/features/mcp-server-library.json +++ b/shared/platform-feature-matrix/features/mcp-server-library.json @@ -2,7 +2,7 @@ "id": "mcp-server-library", "area": "AI", "group": "IDE MCP", - "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入;紧凑图标配置弹窗及精确命令行参数投影,主动 stdio/Streamable HTTP 工具检测,按 Codex/Claude 原生规则独立管理工具开关并保留外部禁用项", + "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入;紧凑图标配置弹窗及精确命令行参数投影,主动 stdio/Streamable HTTP 工具检测,按 Codex/Claude 原生规则独立管理工具开关并保留外部禁用项;关闭及删除后的 Codex 原生工具名单按路径/名称保留,再添加时恢复", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -38,6 +38,6 @@ ] }, "owner": "Agent", - "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。", - "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。" + "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。 Codex 移除成功后只保留允许/禁用工具名到无 sourceHash 的账本条目,重新插入时恢复;已有原生条目和重命名不被旧保留值覆盖,格式错误拒绝移除,空允许名单仍拒绝全部工具。保留名单不构成现存文件所有权,不保存认证信息。取消探测复用 Core 注册回调,无重试计时器。", + "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。 McpArgumentRoundTripTests.disabledToolsSurviveServerRemovalAndReopening 对 Agent 开关、编辑器保存、删除重加三条真实隔离文件路径验证:工具仍禁用,外部 deny/allow-list 不放宽、另一 Agent 不变、新页面读回且仅 Lithe 自有规则可重新开启。Core 回归覆盖空 allow-list、非法类型、现存原生规则优先及恢复后完整哈希。CI test-stability-macos 产物包含 mcp-ui 的真实产品编辑器及工具面板深浅主题、宽窄截图。" } From b05fdfc3d1487f9dd30e1ba94d7401d85be980e4 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 14:57:43 +0800 Subject: [PATCH 09/16] fix(skills): publish Windows skill packages without replacement --- .../2026-10-20-agent-skill-management.md | 12 ++++ rust/Cargo.lock | 1 + rust/lithe-agent-host/Cargo.toml | 3 + rust/lithe-agent-host/src/skills/files.rs | 27 ++++++-- rust/lithe-agent-host/src/skills/tests.rs | 68 ++++++++++++++++++- .../features/agent-skill-management.json | 8 ++- windows/tauri/src-tauri/Cargo.lock | 1 + 7 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md index 6bb60fc844..4c6662672b 100644 --- a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -46,6 +46,17 @@ Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有 导入保留完整目录中的脚本、模板和文件权限,源目录只读。名称、YAML 文件头、UTF-8、目录深度、文件数和大小都有边界;拒绝导入中的符号链接和特殊文件,以及通过链接的目标容器。先写随机隐藏暂存目录,再以不覆盖模式发布;已有空目录也算冲突。失败或取消回收暂存目录,不改已有技能。查看正文仅返回有界文本,不执行文件中的命令或指令。 +最终发布必须由操作系统原子地拒绝已有目标,不能只在准备暂存前检查目录是否存在。 +macOS 使用 `RENAME_EXCL`,Linux 使用 `RENAME_NOREPLACE`;Windows 使用 +`windows-sys` 的 `MoveFileExW`,只启用 `MOVEFILE_WRITE_THROUGH`,不启用替换和跨卷复制。 +UTF-16 路径保留 Unicode、空格和长路径前缀,内嵌 NUL 拒绝传入原生 API。 +不能把 Windows 的 `std::fs::rename` 当成不覆盖操作:[Rust 官方文档](https://doc.rust-lang.org/std/fs/fn.rename.html) +说明支持 `FileRenameInfoEx` 的文件系统可替换已有空目录,而 +[MoveFileExW 文档](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw) +提供显式控制替换的标志。Windows CI 曾在这个边界实际覆盖空目标,故回归必须保留失败 +断言;正确做法是让最后一次移动失败并保留源包和已有目标,不是增加另一次易竞争的存在检查。 +未实现排他发布的平台直接拒绝,不退回可能覆盖目标的普通重命名。 + 刷新独立查询两个 Agent,单方不可用不遮住另一方结果;同一项目刷新失败保留旧列表,切项目先清除旧数据。请求与写入捕获项目,迟到结果须核对版本;页面离开时取消自有任务。原生探测的取消必须进入已有交换清理路径,初始化卡住时也先终止并回收子进程,再销毁短生命周期的 Tokio 运行时;不能只丢弃读取 future,把子进程回收留给已经退出的事件循环。保存后刷新列表,是否被运行中的会话发现继续由原生 Agent 决定,不停止会话,也不承诺一次导入会让所有 Agent 都可用。 这些文件是用户明确创建或导入的工作资料,不是构建产物、下载缓存或可跨工作树复用资源。它们的生命周期由用户和原生 Agent 管理,写入位置仅为原生用户或所选项目目录;不写应用包、已安装适配器、签名资源或发行目录,不改变代码签名和 Sparkle 增量更新基线。没有新增可复用的构建资源,因此工作树资源清单不登记用户技能。 @@ -67,6 +78,7 @@ Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windo - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentSkillsFeatureModelTests|AgentSkillsRustBridgeTests|AgentSkillsAppearanceTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests'` - `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/agent-skills-rust.json` +- Windows 在原生 CI 中运行相同 Host 测试,覆盖暂存后出现的空目录、普通文件和非空目录都不覆盖,缺失的 Unicode 目标完整发布,以及 NUL 路径不按截断名称发布。这些文件系统用例不等于 Windows 设置页或真实 Agent 会话验收。 - `./scripts/verify-shared-contracts.sh` - `./scripts/verify-service-boundaries.sh` - `./scripts/verify-runtime-bundle-immutability.sh` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9ff4b76517..4d08b2057f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2025,6 +2025,7 @@ dependencies = [ "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index d40c32f1ee..bfef35936f 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -33,6 +33,9 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] tokio = { version = "1", features = ["macros", "test-util"] } tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } diff --git a/rust/lithe-agent-host/src/skills/files.rs b/rust/lithe-agent-host/src/skills/files.rs index b4f56065c3..5c2d15875e 100644 --- a/rust/lithe-agent-host/src/skills/files.rs +++ b/rust/lithe-agent-host/src/skills/files.rs @@ -456,13 +456,30 @@ pub(super) fn publish(source: &Path, target: &Path) -> Result<(), Error> { } Ok(()) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] { - // Windows MoveFile semantics reject an existing destination directory. - fs::rename(source, target).map_err(|_| { - failure("The skill could not be published. Check permissions and existing skill names.") - }) + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + let from: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + if from[..from.len() - 1].contains(&0) || to[..to.len() - 1].contains(&0) { + return Err(failure("Invalid skill path.")); + } + // std::fs::rename can replace empty directories via FileRenameInfoEx. + // Omit REPLACE_EXISTING and COPY_ALLOWED: publication must stay an + // exclusive same-volume move, including when another writer races us. + // SAFETY: both terminated UTF-16 buffers stay alive throughout the call. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + Err(failure( + "Atomic skill publication is unavailable on this platform.", + )) } /// Native destinations are returned as display metadata; discovery creates no directories. diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs index c5561f78d8..aea2664378 100644 --- a/rust/lithe-agent-host/src/skills/tests.rs +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -389,13 +389,79 @@ fn publication_cannot_replace_an_existing_empty_directory() { let source = fixture.0.join("stage"); let target = fixture.0.join("target"); fs::create_dir(&source).unwrap(); - fs::create_dir(&target).unwrap(); fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + // The final native move must reject a destination created after staging, + // even when the competing directory is empty. + fs::create_dir(&target).unwrap(); assert!(files::publish(&source, &target).is_err()); assert_eq!(fs::read_dir(target).unwrap().count(), 0); assert!(source.join("SKILL.md").is_file()); } +#[test] +fn publication_preserves_existing_files_and_directory_contents() { + for directory in [false, true] { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + let existing = if directory { + fs::create_dir(&target).unwrap(); + target.join("keep.txt") + } else { + target.clone() + }; + fs::write(&existing, "keep existing bytes").unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_to_string(existing).unwrap(), "keep existing bytes"); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); + } +} + +#[test] +fn publication_moves_a_complete_package_to_a_missing_unicode_destination() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage with spaces"); + let target = fixture.0.join("skill 中文"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/example.txt"), "resource bytes").unwrap(); + files::publish(&source, &target).unwrap(); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(target.join("SKILL.md")).unwrap(), + DOCUMENT + ); + assert_eq!( + fs::read_to_string(target.join("scripts/example.txt")).unwrap(), + "resource bytes" + ); +} + +#[cfg(windows)] +#[test] +fn publication_rejects_nul_paths_without_publishing_the_truncated_name() { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let prefix = fixture.0.join("target"); + let mut name: Vec = prefix.as_os_str().encode_wide().collect(); + name.extend([0, b'x' as u16]); + let target = PathBuf::from(std::ffi::OsString::from_wide(&name)); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert!(!prefix.exists()); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); +} + #[test] fn native_response_keeps_disabled_scope_and_rejects_foreign_workspace() { let fixture = Sandbox::new(); diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json index f5a6eb8cd8..40e6aa321b 100644 --- a/shared/platform-feature-matrix/features/agent-skill-management.json +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -2,7 +2,7 @@ "id": "agent-skill-management", "area": "AI", "group": "Agent 对话", - "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表、仅待确认行禁用、其他行保持颜色并按序保存);操作菜单保留所属窗口焦点和开启开关的颜色", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表、仅待确认行禁用、其他行保持颜色并按序保存);操作菜单保留所属窗口焦点和开启开关的颜色;技能目录最终发布在 macOS/Linux/Windows 均原子拒绝已有目标(包含空目录)", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", @@ -23,11 +23,13 @@ "evidence": [ "rust/lithe-core/src/agent/mod.rs", "rust/lithe-agent-host/src/skills.rs", - "shared/fixtures/agent/skills-management-v1.json" + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/files.rs", + "rust/lithe-agent-host/src/skills/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。" + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。 Windows 原生 CI 回归 publication_cannot_replace_an_existing_empty_directory,覆盖暂存后目标出现、普通文件与非空目录字节保留、Unicode/空格目标完整发布,以及 NUL 路径拒绝。Windows 使用 MoveFileExW 且不启用覆盖或跨卷复制,不以 std::fs::rename 的平台行为推断排他性;文件系统用例通过不代表设置页或会话已验收。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 42eed997dc..a8ef65b2dc 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2882,6 +2882,7 @@ dependencies = [ "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] From 89e0fef8a061740cf49e3426bc5d7e56a0d489a0 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 15:40:29 +0800 Subject: [PATCH 10/16] fix(ci): bound multi-repository tests and join search fixture teardown --- .../2026-09-13-module-runtime-boundaries-and-lifecycle.md | 7 +++++++ .../feature/2026-09-27-workspace-git-commit-plans.md | 1 + .../write-stable-tests/references/windows-and-rust.md | 8 +++++++- .../write-stable-tests/scripts/test-stability-windows.ps1 | 1 + .github/workflows/ci-windows.yml | 1 + .../Tests/LitheTests/SearchEverywhereBehaviorTests.swift | 8 ++++++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md index 1502f95f28..c0a4201b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md @@ -89,6 +89,13 @@ MacServiceContainer 错误,不能把残留资源伪装成成功禁用。上述任务仍在主执行器上使用异步等待, 不阻塞线程、不新增插件代码加载能力。 +使用真实 AppModel 的测试也必须遵守项目打开的清理顺序。`openProjectDirectly` 会启动 +模块图关闭;项目文件快照已经发布不代表关闭完成。Safe Mode 测试要手动注入能力时, +先通过有截止的 `WorkbenchTestScope.run` 等待 `awaitModuleRuntimeShutdown()`,再放入 +测试能力。否则迟到的 shutdown 事件可能移除刚注入的能力,让前几次搜索通过、随后 +查询全部为空。不要用睡眠或反复重新注入能力掩盖这个顺序;搜索排除回归与插件页面 +生命周期测试都使用同一个明确的完成边界。 + ### 数据库视图在模块释放后的刷新 退出应用或休眠数据库模块时,SwiftUI(macOS 的声明式界面框架)可能先刷新 diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md index 69bded6517..a46549b4ed 100644 --- a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -59,6 +59,7 @@ Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后 - `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 - `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 - 使用 `write-stable-tests` 的 Rust 逐例计时入口生成 HTML/JUnit;macOS 使用该 Skill 的 macOS 计时入口。 +- Windows CI 39914734587 中,逐仓库推送确认用例在未出现断言失败时达到 15.233 秒进程截止;该用例创建两份分叉仓库及远端,执行三轮准备校验和独立提交/推送。SharedRust 与真实 JDT 两个入口只给这一用例 30 秒预算,默认用例和整套截止不变,真实 Git 进程仍按原有界规则清理,不跳过确认或推送断言。 - 运行共享契约、服务边界、功能矩阵、发行包只读边界和 Agent Notes 检查。 - 当前实现环境是 Linux;本地执行共享 Core 和 Windows 前端测试,原生构建及测试交给对应 CI,macOS/Windows 界面实测仍需平台验收,不将逻辑测试通过记作界面已验证。 diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index effcefd94c..a5dd93e398 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -79,7 +79,13 @@ Each patch request still has its own 15-second deadline, and the enclosing suite deadline remains unchanged. Both SharedRust and the real-JDT lane use these narrowly scoped budgets. Core rebase requests use a nested 20-second deadline. The workspace commit submodule publication test also gets 30 seconds -because it creates two local remotes and runs multiple real Git pushes. Other +because it creates two local remotes and runs multiple real Git pushes. +`tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately` +also gets 30 seconds in both lanes after run 39914734587 killed it at 15.233 +seconds without an assertion failure. It prepares two divergent local +repositories/remotes, checks independent confirmations in three preparation +passes, and commits/pushes each repository. The default case and suite deadlines +remain unchanged; bounded native subprocess calls and assertions still run. Other multi-repository coverage includes `tests::git::git_write_updates_a_noncurrent_branch_without_switching_head`, which also gets 30 seconds in both SharedRust and real-JDT lanes: it builds a diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index cfa7b41875..c72417a9f4 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -75,6 +75,7 @@ function Invoke-TimedRustTests { "tests::git_history_rewrite::", "tests::git_remote_checkout::", "tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote", + "tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately", "tests::git::git_write_squashes_", "tests::git::git_write_deletes_a_local_commit_", "tests::git::git_write_edits_a_local_commit_message_", diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 99db6ed2db..a966d88027 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -784,6 +784,7 @@ jobs: --test-budget tests::git_history_rewrite::native_rebase_skip_omits_conflicted_message_from_later_squash=45000 ` --test-budget tests::git_remote_checkout::=30000 ` --test-budget tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote=30000 ` + --test-budget tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately=30000 ` --test-budget tests::git::git_write_squashes_=30000 ` --test-budget tests::git::git_write_deletes_a_local_commit_=30000 ` --test-budget tests::git::git_write_edits_a_local_commit_message_=30000 ` diff --git a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift index 8caa8d942f..029c9ecb29 100644 --- a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift +++ b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift @@ -268,7 +268,15 @@ struct SearchEverywhereBehaviorTests { try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { scope in model.openProjectDirectly(root) try #require(await loaded.waitUntilOpen()) + // Project inventory can arrive before the module teardown started + // by opening the workspace finishes. Join that boundary before + // injecting the Safe Mode capability, or a late shutdown removes + // it between queries and all later results become empty. + try await scope.run("initial workspace module teardown", timeout: .seconds(5)) { + await model.awaitModuleRuntimeShutdown() + } model.cacheModuleCapability(SearchModuleCapability(feature: feature), id: .searchWorkspace, moduleID: .search) + try #require(model.searchFeatureIfActive === feature) var bare: [AgentFileMention] = [], named: [AgentFileMention] = [] try await scope.run("bare and named agent mentions", timeout: .seconds(5)) { bare = await model.agentFileMentions(matching: "") From 3c42838a22e48e9fa28adf942aeace1251cc2d6d Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 16:25:08 +0800 Subject: [PATCH 11/16] test(windows): await owned process exit signals and clean fixtures --- ...-10-14-child-processes-end-with-the-ide.md | 1 + .../features/run-processes-end-with-ide.json | 2 +- windows/tauri/src-tauri/src/run.rs | 170 +++++++++++++----- 3 files changed, 129 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md index ca2fcc3054..2a9e29bb71 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md +++ b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md @@ -100,6 +100,7 @@ Windows 原来有五个漏洞,叠在一起导致“关闭 Lithe 后服务还 - `./scripts/test-macos.sh --filter '(MacProcessRunnerTests|ExecutionModule)'`。 - `rust/lithe-git-host/tests/process.rs`:`process_tree_terminate_ends_the_root_and_its_descendants` 启动一个会再启动后代的进程,确认 `terminate` 返回时两者都已结束;Windows 上 `dropping_a_process_tree_ends_it_like_a_crashed_host` 只丢弃句柄,确认内核结束整组。 - `windows/tauri/src-tauri/src/run.rs` 测试(仅 Windows):关闭一个窗口只结束它自己的进程树、丢弃会话等同 Lithe 崩溃、原生列表按启动顺序包含全部会话。 + - Windows CI 39919376382 在关闭窗口和丢弃会话两例中,根进程已退出,但立即读取的 `sysinfo` 快照仍包含后代 PID。测试改为停止前打开后代的只等待句柄,停止后分别在两秒内等待根进程和后代的退出信号;另一窗口的后代用零等待确认仍在运行。依据 [Windows 进程终止语义](https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-process),退出进程的对象可以因尚未关闭的句柄而继续存在,进程退出信号才是结束依据。保留原来的窗口隔离、重复关闭和崩溃清理断言,不改产品停止流程。夹具的首字节就绪信号有两秒期限,失败路径和断言展开时也按所属会话清理进程树、等待退出并释放管道和句柄;不依赖任意睡眠或 PID 重查。 - `rust/lithe-core/src/lsp/interface/process.rs` 测试(仅 Windows):结束批处理启动的语言服务器时,脚本启动的子进程一起结束。 - `shared/fixtures/process-lifecycle/exit-plan-v1.json`、`settle-v1.json`:运行服务在退出、关闭项目、直接停止设置和崩溃恢复下的动作;macOS `ProcessLifecycleFixtureTests` 用 Swift 类型解码同一批 fixture,并覆盖提问顺序、取消和规则失败时仍询问。 - `windows/tauri/src/features/window/services/run-services-exit.test.ts`:询问、取消、停止失败后重试、直接停止、后台任务不询问、Core 不可用时询问。 diff --git a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json index 2626af5b02..f25185c1fc 100644 --- a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json +++ b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json @@ -43,6 +43,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。", + "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。 Windows Run 原生测试在停止前持有后代进程句柄,以两秒退出信号确认根进程和后代结束,以零等待确认另一窗口仍在运行;首字节就绪等待有界,夹具在断言失败时也清理所属进程树。", "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用。两端在关闭项目或窗口、退出、安装更新前按 Rust Core 的 runServicesOnExit 规则列出运行服务并询问(可设为直接停止)。Windows 的运行会话、调试适配器和语言服务器放在 Job Object 中,停止和 IDE 崩溃时都按整组结束;菜单退出走窗口关闭守卫,更新安装前先停止运行服务。Windows 终端和 Agent 进程树尚未放入 Job Object,也没有监护进程和启动清扫,IDE 崩溃时它们仍可能残留。" } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index ca6a2dfe8e..7c7b1fe8d4 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -3723,15 +3723,81 @@ mod tests { assert_eq!(left, run_session_key("project-a", "primary")); } + #[cfg(windows)] + struct OwnedRunTree { + key: RunSessionKey, + root: Child, + descendant: Option, + output: Option, + } + + #[cfg(windows)] + impl OwnedRunTree { + fn assert_ended(&mut self) { + assert!(test_process_ended(&self.root, 2_000).expect("root exit signal")); + // The native wait has already observed exit, so reaping cannot block. + self.root.wait().expect("reap root"); + assert!(test_process_ended(self.descendant.as_ref().unwrap(), 2_000) + .expect("descendant exit signal")); + } + + fn assert_running(&mut self) { + assert!(self.root.try_wait().unwrap().is_none()); + assert!(!test_process_ended(self.descendant.as_ref().unwrap(), 0) + .expect("descendant is still running")); + } + } + + #[cfg(windows)] + impl Drop for OwnedRunTree { + fn drop(&mut self) { + // Assertion/setup failures must also reclaim only this fixture's tree. + if let Err(error) = stop_sessions( + take_owned_sessions(|key| key == &self.key), + Duration::from_secs(1), + ) { + eprintln!("fixture cleanup: {error}"); + // A failed stop may reinsert the session. Close its job handle + // as the final crash-cleanup fallback instead of retaining it. + drop(take_owned_sessions(|key| key == &self.key)); + } + let _ = self.root.kill(); + if test_process_ended(&self.root, 1_000).unwrap_or(false) { + let _ = self.root.wait(); + } else { + eprintln!("fixture root did not exit during cleanup"); + } + if let Some(descendant) = &self.descendant { + if !test_process_ended(descendant, 1_000).unwrap_or(false) { + eprintln!("fixture descendant did not exit during cleanup"); + } + } + } + } + + #[cfg(windows)] + fn test_process_ended( + process: &impl std::os::windows::io::AsRawHandle, + timeout_ms: u32, + ) -> std::io::Result { + use windows_sys::Win32::Foundation::{WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + // SAFETY: the borrowed process handle stays open throughout this bounded wait. + match unsafe { WaitForSingleObject(process.as_raw_handle(), timeout_ms) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + WAIT_FAILED => Err(std::io::Error::last_os_error()), + _ => Err(std::io::Error::other("unexpected process wait result")), + } + } + /// A Run session like `cmd -> mvn -> java`, owned the way /// `start_reserved_process` owns one: the suspended root joins its tree /// before ping can start. #[cfg(windows)] - fn spawn_owned_process_tree( - window: &str, - session: &str, - title: &str, - ) -> (Child, std::process::ChildStdout, u32) { + fn spawn_owned_process_tree(window: &str, session: &str, title: &str) -> OwnedRunTree { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; let mut command = Command::new("cmd"); command .args(["/C", "ping -n 30 127.0.0.1"]) @@ -3739,18 +3805,10 @@ mod tests { ProcessTree::prepare(&mut command); let mut child = command.spawn().expect("spawn service"); let tree = ProcessTree::adopt(&mut child).expect("own service tree"); - // cmd writes nothing itself; the first output byte comes from ping, so - // the descendant is running once it arrives. The pipe stays open for - // the test, otherwise ping would exit on its next write. let mut stdout = child.stdout.take().expect("service output"); - stdout - .read_exact(&mut [0; 1]) - .expect("service descendant output"); - let descendant = *child_process_ids(child.id()) - .first() - .expect("service descendant"); + let key = run_session_key(window, session); sessions().lock().unwrap().insert( - run_session_key(window, session), + key.clone(), RunningSession { pid: child.id(), execution_id: Some(format!("{session}-execution")), @@ -3761,7 +3819,49 @@ mod tests { tree, }, ); - (child, stdout, descendant) + let mut fixture = OwnedRunTree { + key, + root: child, + descendant: None, + output: None, + }; + // cmd writes nothing itself; ping's first byte signals readiness. Bound + // the pipe read and retain its returned pipe so ping does not exit early. + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = thread::spawn(move || { + let result = stdout.read_exact(&mut [0; 1]); + let _ = sender.send((result, stdout)); + }); + let (result, stdout) = match receiver.recv_timeout(Duration::from_secs(2)) { + Ok(ready) => ready, + Err(error) => { + // Closing this fixture's job releases the blocked pipe reader. + drop(fixture); + if receiver.recv_timeout(Duration::from_secs(2)).is_ok() { + reader.join().expect("join stopped fixture reader"); + } + panic!("service descendant readiness: {error}"); + } + }; + reader.join().expect("join ready fixture reader"); + fixture.output = Some(stdout); + result.expect("service descendant output"); + let descendant = *child_process_ids(fixture.root.id()) + .first() + .expect("service descendant"); + // Capture identity before stopping the job: PID snapshots can still list + // an exited process, and a later lookup can race PID reuse. + // SAFETY: PROCESS_SYNCHRONIZE grants only waiting; this opens a + // non-inheritable handle for the descendant we observed running. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, descendant) }; + assert!( + !handle.is_null(), + "open descendant: {}", + std::io::Error::last_os_error() + ); + // SAFETY: the non-null handle is transferred once and closed by OwnedHandle. + fixture.descendant = Some(unsafe { OwnedHandle::from_raw_handle(handle) }); + fixture } #[cfg(windows)] @@ -3776,32 +3876,21 @@ mod tests { .collect() } - #[cfg(windows)] - fn process_is_alive(pid: u32) -> bool { - let mut system = sysinfo::System::new(); - system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - system.process(sysinfo::Pid::from_u32(pid)).is_some() - } - #[cfg(windows)] #[test] fn window_release_ends_its_process_trees_and_keeps_other_windows() { // Regression for #1137: closing a window left Run services and their // descendants running. Another window's service must survive the close. - let (mut closed, _closed_output, closed_descendant) = - spawn_owned_process_tree("release-closed-window", "service", "Closed"); - let (mut kept, _kept_output, kept_descendant) = - spawn_owned_process_tree("release-kept-window", "service", "Kept"); + let mut closed = spawn_owned_process_tree("release-closed-window", "service", "Closed"); + let mut kept = spawn_owned_process_tree("release-kept-window", "service", "Kept"); stop_sessions( take_owned_sessions(|key| key.window_label == "release-closed-window"), RUN_STOP_TIMEOUT, ) .expect("the closed window's trees end"); - assert!(closed.wait().is_ok()); - assert!(!process_is_alive(closed_descendant)); - assert!(kept.try_wait().unwrap().is_none()); - assert!(process_is_alive(kept_descendant)); + closed.assert_ended(); + kept.assert_running(); // A second destroy/exit pass finds nothing left and must not reap again. assert!(take_owned_sessions(|key| key.window_label == "release-closed-window").is_empty()); @@ -3811,8 +3900,7 @@ mod tests { RUN_STOP_TIMEOUT, ) .expect("the kept window's trees end"); - assert!(kept.wait().is_ok()); - assert!(!process_is_alive(kept_descendant)); + kept.assert_ended(); } #[cfg(windows)] @@ -3820,13 +3908,11 @@ mod tests { fn dropping_a_session_ends_its_tree_like_a_crashed_lithe() { // When Lithe dies, Windows closes its job handles. Dropping the session // closes the handle the same way, so no stop call is involved. - let (mut root, _output, descendant) = - spawn_owned_process_tree("crash-window", "service", "Crash"); + let mut root = spawn_owned_process_tree("crash-window", "service", "Crash"); let owned = take_owned_sessions(|key| key.window_label == "crash-window"); assert_eq!(owned.len(), 1); drop(owned); - assert!(root.wait().is_ok()); - assert!(!process_is_alive(descendant)); + root.assert_ended(); } #[cfg(windows)] @@ -3835,11 +3921,9 @@ mod tests { // Maven goals and services of closed projects have no webview store, // so the close confirmation must come from the native table. let window = "listing-window"; - let (mut first, _first_output, _) = - spawn_owned_process_tree(window, "maven", "clean install"); - let (mut second, _second_output, _) = spawn_owned_process_tree(window, "service", "Api"); - let (mut other, _other_output, _) = - spawn_owned_process_tree("listing-other-window", "service", "Other"); + let mut first = spawn_owned_process_tree(window, "maven", "clean install"); + let mut second = spawn_owned_process_tree(window, "service", "Api"); + let mut other = spawn_owned_process_tree("listing-other-window", "service", "Other"); let preparing = { let mut pending = pending_launches().lock().unwrap(); let reservation = reserve_test_launch(&mut pending, window, "prepare"); @@ -3879,7 +3963,7 @@ mod tests { .expect("listing fixtures end"); } for root in [&mut first, &mut second, &mut other] { - assert!(root.wait().is_ok()); + root.assert_ended(); } } From d19444d3d771f9d1e4c110569203072697780bf1 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 17:36:10 +0800 Subject: [PATCH 12/16] fix(agent): close cancellation and MCP policy lifecycle gaps --- .../feature/2026-10-17-mcp-server-library.md | 3 + .../2026-10-20-agent-skill-management.md | 2 + .../Features/AgentSkillsFeatureModel.swift | 1 + .../McpServerLibraryFeatureModel.swift | 5 + .../Features/McpToolsFeatureModel.swift | 12 ++- macos/Sources/Lithe/Core/Ports/McpTools.swift | 7 +- .../Rust/RustAgentManagementService.swift | 22 +++-- .../Platform/MacOS/MCP/MacMcpTools.swift | 10 ++ .../AgentSkillsCancellationTests.swift | 94 +++++++++++++++++++ .../McpArgumentRoundTripTests.swift | 46 +++++++++ .../LitheTests/McpBatchTransactionTests.swift | 36 +++++++ .../LitheTests/McpToolsFeatureTests.swift | 72 +++++++++++++- .../features/agent-skill-management.json | 6 +- .../features/mcp-server-library.json | 4 +- 14 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 macos/Tests/LitheTests/AgentSkillsCancellationTests.swift diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index b9b1d327cf..ae441ed0d5 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -93,12 +93,15 @@ Core 提取原生允许/禁用名单,整批条件写入成功后才把这些 缺失条目时恢复名单,并根据恢复后的完整内容登记新所有权。现存条目和重命名沿用原生 规则,不能被旧保留值覆盖;空允许名单仍表示不允许任何工具,格式错误则拒绝删除。 这份状态随用户资料保留,不保存命令、env、header 或令牌;新页面和应用重启都能恢复。 +Codex 重命名还必须移动相同原生文件路径下、由 Lithe 登记的精确工具名所有权;只搬原生表会让原来可编辑的禁用项被误判为外部限制。迁移在整批文件条件写入成功后执行,失败或恢复不发布新名称所有权;清除旧名称记录,也不合并新名称处可能过期的记录。外部 deny 和 allow-list 不因此取得所有权,其他配置路径和 Agent 保持独立。 Claude 的规则引用 服务器名称,原生用户配置存在 MCP 限制时拒绝在本页重命名,避免旧限制未迁移而让 工具重新可用;用户需先在原生规则中处理。项目/受管规则仍由上游 Agent 决定,页面 不会显示为已解除全局之外的限制。生命周期退出会取消探测、丢弃迟到结果并释放内存 快照;这些状态登记在 mcp-tool-probe-state,没有工作树复用阶段或发行目录运行时写入。 +同一连接新增或移除 Agent 时,服务器行的名称和定义哈希不变,不能只按行 ID 保留旧策略。工具模型记录每行最新来源,来源改变时释放旧策略快照、按现有工具清单重读本地规则;探测尚未返回时也以最新来源读取策略。此刷新不重新连接第三方服务器,保留已检测工具;单个 Agent 的读取失败明确显示错误,其他 Agent 的可用策略保留。 + 检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消与指令保存共用 `RustCoreCancellation`:Core 登记操作后、执行命令前同步回调,补传注册前到达的取消,不需要重试计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 ### 发现市场与弹窗编辑 diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md index 4c6662672b..623f55f874 100644 --- a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -44,6 +44,8 @@ Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有 共享 Core 只负责 `agent.skills` 的类型化分派、取消和错误信封;原生发现与文件操作在 `lithe-agent-host`。Swift 的服务桥离开主线程执行,功能模型保存列表和操作状态,视图只消费模型及显式的原生对话框/打开/定位动作。 +技能服务与指令服务共用 `RustCoreCancellation`:进入服务和启动后台工作前检查取消,Core 同步发出 `requestStarted` 后补传更早的取消,再分派文件操作。只在取消处理器中发一次 `core.cancel` 不够,因为尚未注册的操作会返回未找到,独立后台任务随后仍可能创建、导入或改配置。功能模型在调用写入服务前也检查取消;安装进度继续通过同一事件回调转发,注册事件不被误当成进度。`AgentSkillsCancellationTests` 在 MainActor 不让出执行权即取消真实桥接任务,并在编码边界触发取消,验证独立项目的技能目录、原生配置和暂存状态均不变,后续合法操作仍能成功。 + 导入保留完整目录中的脚本、模板和文件权限,源目录只读。名称、YAML 文件头、UTF-8、目录深度、文件数和大小都有边界;拒绝导入中的符号链接和特殊文件,以及通过链接的目标容器。先写随机隐藏暂存目录,再以不覆盖模式发布;已有空目录也算冲突。失败或取消回收暂存目录,不改已有技能。查看正文仅返回有界文本,不执行文件中的命令或指令。 最终发布必须由操作系统原子地拒绝已有目标,不能只在准备暂存前检查目录是否存在。 diff --git a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift index b0a4ed4ab7..b3527054b4 100644 --- a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift @@ -167,6 +167,7 @@ final class AgentSkillsFeatureModel: ObservableObject { let service = service writeTask = Task { [weak self] in do { + try Task.checkCancellation() let url = try await service.writeSkill(request) guard let self, !Task.isCancelled, self.revision == token else { return } self.savedURL = url; self.isWriting = false diff --git a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift index 3c47b1615b..142a472c7c 100644 --- a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift @@ -922,6 +922,11 @@ final class McpServerLibraryFeatureModel: ObservableObject { lastBackupURL = receipts.last?.backupURL ?? lastBackupURL // A later failure must not publish ownership for an earlier write. for entry in planned where entry.changed { + for operation in operations[entry.file] ?? [] { + if case .set(let server, _, let previousName?) = operation { + model?.mcpTools?.serverRenamed(from: previousName, to: server.name, in: entry.file) + } + } setLedger(entry.entries, for: entry.file) } } diff --git a/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift index f24d548840..be291553db 100644 --- a/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift @@ -17,6 +17,7 @@ final class McpToolsFeatureModel: ObservableObject { private let service: (any McpToolsManaging)? private var probes: [String: Task] = [:] private var requests: [String: UUID] = [:] + private var targets: [String: McpServerRow] = [:] init(service: (any McpToolsManaging)?) { self.service = service } @@ -26,6 +27,7 @@ final class McpToolsFeatureModel: ObservableObject { func detect(_ row: McpServerRow) -> Task? { guard let service, !row.isProjectOnly else { return nil } cancelAll() + targets[row.id] = row let request = UUID(); requests[row.id] = request var state = states[row.id] ?? State(); state.isLoading = true; state.isReady = false; state.error = nil states[row.id] = state @@ -35,7 +37,7 @@ final class McpToolsFeatureModel: ObservableObject { guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } self.states[row.id]?.tools = result.tools self.states[row.id]?.isReady = true - self.loadPolicies(row) + self.loadPolicies(self.targets[row.id] ?? row) } catch { guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } self.states[row.id]?.isReady = false @@ -78,6 +80,13 @@ final class McpToolsFeatureModel: ObservableObject { for id in Array(states.keys) where !ids.contains(id) { probes.removeValue(forKey: id)?.cancel(); requests[id] = nil if let state = states.removeValue(forKey: id) { service?.release(Array(state.policies.values)) } + targets[id] = nil + } + for row in rows where states[row.id] != nil { + let previous = targets.updateValue(row, forKey: row.id) + // Registration changes leave connection identity and inventory intact. + // Refresh local policy without connecting to the server a second time. + if previous?.globalSources != row.globalSources { loadPolicies(row) } } } @@ -91,6 +100,7 @@ final class McpToolsFeatureModel: ObservableObject { cancelAll() for state in states.values { service?.release(Array(state.policies.values)) } states.removeAll() + targets.removeAll() } func commandLine(_ draft: McpServerDraft) throws -> String { diff --git a/macos/Sources/Lithe/Core/Ports/McpTools.swift b/macos/Sources/Lithe/Core/Ports/McpTools.swift index 851dff6886..c40618aba3 100644 --- a/macos/Sources/Lithe/Core/Ports/McpTools.swift +++ b/macos/Sources/Lithe/Core/Ports/McpTools.swift @@ -35,6 +35,11 @@ protocol McpToolsManaging: AnyObject { func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) func release(_ snapshots: [McpToolPolicySnapshot]) func validateRename(_ target: McpToolTarget) throws + /// Called only after the native batch commits, for each renamed source file. + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) } -extension McpToolsManaging { func validateRename(_ target: McpToolTarget) throws { } } +extension McpToolsManaging { + func validateRename(_ target: McpToolTarget) throws { } + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) { } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift index 20e05cb1e3..a36c5e4e01 100644 --- a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift +++ b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift @@ -91,20 +91,22 @@ struct RustAgentManagementService: AgentManagementService { ) async throws -> Result { let operationID = UUID().uuidString let core = core + let cancellation = RustCoreCancellation(core: core, operationID: operationID) return try await withTaskCancellationHandler { - try await Task.detached(priority: .userInitiated) { - try core.executeResult(command: command, payload: payload, operationID: operationID, onEvent: onProgress.map { callback in - { event in - guard let data = event.data(using: .utf8), + try Task.checkCancellation() + return try await Task.detached(priority: .userInitiated) { + if cancellation.isCancelled { throw CancellationError() } + let result: Swift.Result = core.executeResult( + command: command, payload: payload, operationID: operationID, onEvent: { event in + cancellation.receive(event) + guard let onProgress, let data = event.data(using: .utf8), let decoded = try? JSONDecoder().decode(InstallEvent.self, from: data), decoded.kind == "agentInstallProgress", decoded.operationId == operationID else { return } - callback(decoded.progress) - } - }).get() + onProgress(decoded.progress) + }) + return try result.get() }.value - } onCancel: { - core.cancel(operationID: operationID) - } + } onCancel: { cancellation.cancel() } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift index 168c33db3d..2998b3ae28 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift @@ -113,6 +113,16 @@ final class MacMcpTools: McpToolsManaging { func release(_ values: [McpToolPolicySnapshot]) { for value in values { snapshots.removeValue(forKey: value.id) } } + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) { + guard file.agent == .codex, previousName != name else { return } + // The native table already moved atomically with its exact restrictions. + // Move only our names at this profile path, never adopt external rules + // or stale ownership left at the destination by an older server. + let previousKey = key(file, previousName) + ledger.set(ledger.stringArray(forKey: previousKey), forKey: key(file, name)) + ledger.set(nil, forKey: previousKey) + } + func validateRename(_ target: McpToolTarget) throws { guard target.files.contains(where: { $0.agent == .claudeCode }) else { return } let file = McpConfigurationFile(agent: .claudeCode, scope: .global, url: claudeSettings, projectPath: nil) diff --git a/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift b/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift new file mode 100644 index 0000000000..56a2842322 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift @@ -0,0 +1,94 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct AgentSkillsCancellationTests { + @Test(arguments: ["create", "import", "disable"]) + func cancelledBeforeServiceEntryLeavesSkillAndNativeConfigurationUnchanged(action: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let service = RustAgentManagementService(core: RustCoreBridge()) + let existing = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "existing", + description: "Cancellation fixture", instructions: "Read local files.") + let source = try await service.writeSkill(existing) + let settings = root.appendingPathComponent(".claude/settings.local.json") + try Data(#"{"keep":"original","skillOverrides":{"existing":"on"}}"#.utf8).write(to: settings) + let imported = root.appendingPathComponent("imported", isDirectory: true) + try FileManager.default.createDirectory(at: imported, withIntermediateDirectories: true) + try Data("---\nname: imported\ndescription: Cancellation fixture\n---\nRead local files.\n".utf8) + .write(to: imported.appendingPathComponent("SKILL.md")) + let before = try snapshot(root) + let request = action == "import" + ? AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", source: imported) + : AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "cancelled", + description: "Must not be created", instructions: "Do not publish.") + let skill = AgentSkill(name: "existing", description: "Cancellation fixture", path: source.path, scope: "repo", enabled: true) + let task = Task { + if action == "disable" { + _ = try await service.setSkillEnabled(.init(agentID: "claude-acp", workspace: root, skill: skill, enabled: false)) + } else { + _ = try await service.writeSkill(request) + } + } + // Both caller and task are MainActor-isolated; cancellation precedes + // the service entry, without a sleep or dependence on worker speed. + task.cancel() + do { + try await task.value + Issue.record("Cancelled skill mutation must not succeed") + } catch { #expect(error is CancellationError) } + #expect(try snapshot(root) == before) + } + + @Test func cancellationDuringEncodingIsReplayedBeforeSkillPublication() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let core = RustCoreBridge() + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + let request = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "registered", + description: "Valid fixture", instructions: "Only publish when allowed.") + let result: Result = core.executeResult( + command: "agent.skills", payload: CancelDuringSkillEncoding(value: request, cancellation: cancellation), + operationID: operationID, onEvent: { cancellation.receive($0) }) + switch result { + case .success: Issue.record("Pre-registration cancellation must prevent skill dispatch") + case .failure(let error): #expect(error.code == "cancelled") + } + #expect(try snapshot(root).isEmpty) + #expect(!core.cancel(operationID: operationID)) + // The request is valid and the cancelled registration does not affect a + // subsequent operation. All paths stay in this isolated Claude project. + let path = try await RustAgentManagementService(core: core).writeSkill(request) + #expect(FileManager.default.fileExists(atPath: path.path)) + } + + private func snapshot(_ root: URL) throws -> [String: Data] { + let urls = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey])) + var result: [String: Data] = [:] + for case let url as URL in urls { + let directory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true + result[String(url.path.dropFirst(root.path.count))] = directory ? Data() : try Data(contentsOf: url) + } + return result + } + + private func remove(_ root: URL) { + do { try FileManager.default.removeItem(at: root) } + catch { Issue.record("Skill cancellation fixture cleanup: \(error)") } + } +} + +private struct SkillPath: Decodable, Sendable { let path: String } +private struct CancelDuringSkillEncoding: Encodable { + let value: AgentSkillWrite + let cancellation: RustCoreCancellation + func encode(to encoder: any Encoder) throws { + try value.encode(to: encoder) + cancellation.cancel() + } +} diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index 8a03a3858f..d7c0a0a6b0 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -52,6 +52,52 @@ struct McpArgumentRoundTripTests { #expect(try fixture.reparse(.claudeCode).name == "alpha") } + @Test func codexRenamePreservesOnlyOwnedExactToolRulesAndAllowsReenabling() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [.codex] + try #require(feature.save(draft)) + // The user owns read's deny and allow-list; Lithe owns only write's deny. + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["read", "write", "outside"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + var renamed = feature.draft(for: try #require(feature.rows.first)); renamed.name = "beta" + try #require(feature.save(renamed)) + #expect(try fixture.reparse(.codex, name: "beta").name == "beta") + let beta = try #require(feature.rows.first) + let betaTarget = McpToolTarget(definition: beta.definition, files: beta.globalSources.map(\.file)) + let policy = try service.policy(for: betaTarget, agent: .codex, tools: ["read", "write", "outside"]) + #expect(policy.tools.first { $0.name == "write" }?.enabled == false) + #expect(policy.tools.first { $0.name == "write" }?.editable == true) + #expect(policy.tools.first { $0.name == "read" }?.editable == false) + #expect(policy.tools.first { $0.name == "outside" }?.editable == false) + let on = try service.setEnabled(true, tool: "write", target: betaTarget, snapshot: policy) + feature.tools.didChangePolicy?(beta, on) + service.release([on]) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + + // Reusing alpha must not inherit the ownership moved to beta. + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["write"]) + feature.refresh() + let alpha = try #require(feature.rows.first { $0.name == "alpha" }) + let alphaPolicy = try service.policy(for: McpToolTarget(definition: alpha.definition, + files: alpha.globalSources.map(\.file)), agent: .codex, tools: ["write"]) + defer { service.release([alphaPolicy]) } + #expect(alphaPolicy.tools.first?.enabled == false) + #expect(alphaPolicy.tools.first?.editable == false) + } + @Test func compactEditorFitsBothNativeAppearances() throws { let fixture = try ArgumentFixture() defer { fixture.cleanUp() } diff --git a/macos/Tests/LitheTests/McpBatchTransactionTests.swift b/macos/Tests/LitheTests/McpBatchTransactionTests.swift index 54832fda5d..53e1d212c5 100644 --- a/macos/Tests/LitheTests/McpBatchTransactionTests.swift +++ b/macos/Tests/LitheTests/McpBatchTransactionTests.swift @@ -75,6 +75,39 @@ struct McpBatchTransactionTests { #expect(feature.rows.isEmpty) } + @Test + func failedRenameKeepsToolOwnershipUntilTheWholeBatchCommits() throws { + let fixture = try BatchFixture() + defer { fixture.cleanUp() } + let feature = McpServerLibraryFeatureModel(model: fixture) + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["write"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let before = fixture.ledger.values + fixture.store.written.removeAll() + fixture.store.beforeSecondWrite = { _, current in + throw McpConfigurationError.notWritable(current.url.path) + } + var edit = feature.draft(for: try #require(feature.rows.first)); edit.name = "beta" + #expect(!feature.save(edit)) + #expect(fixture.store.restored.count == 1) + #expect(NSDictionary(dictionary: fixture.ledger.values).isEqual(to: before)) + let restored = try #require(feature.rows.first) + #expect(restored.name == "alpha") + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let policy = try service.policy(for: restoredTarget, agent: .codex, tools: ["write"]) + defer { service.release([policy]) } + #expect(policy.tools.first?.enabled == false) + #expect(policy.tools.first?.editable == true) + } + @Test func failedProjectRegistrationDoesNotPublishEitherAgentOrLedger() throws { let fixture = try BatchFixture() @@ -104,6 +137,9 @@ private final class BatchFixture: McpServerLibraryActions { let root: URL let store: FailingBatchStore let ledger = BatchLedger() + private lazy var tools = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: BatchFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { tools } var mcpWorkspaceURL: URL? { root.appendingPathComponent("project") } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpLedgerStore: any KeyValueStore { ledger } diff --git a/macos/Tests/LitheTests/McpToolsFeatureTests.swift b/macos/Tests/LitheTests/McpToolsFeatureTests.swift index 1d7bb43ff3..7468e9ebd1 100644 --- a/macos/Tests/LitheTests/McpToolsFeatureTests.swift +++ b/macos/Tests/LitheTests/McpToolsFeatureTests.swift @@ -43,6 +43,69 @@ struct McpToolsFeatureTests { #expect(service.policies == 0) } + @Test func registrationChangesReloadLocalPoliciesAndReleaseOldSnapshotsWithoutProbing() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + let original = try #require(feature.state(for: codex).policies[.codex]) + let both = ToolStub.row() + #expect(codex.id == both.id) + feature.reconcile([both]) + let combined = feature.state(for: both) + #expect(combined.isReady && combined.error == nil) + #expect(Set(combined.policies.keys) == Set(McpAgent.allCases)) + #expect(combined.policies[.claudeCode]?.tools.first?.enabled == true) + #expect(combined.policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.released.contains(original.id)) + let claude = try #require(combined.policies[.claudeCode]) + feature.reconcile([codex]) + #expect(Set(feature.state(for: codex).policies.keys) == [.codex]) + #expect(service.released.contains(claude.id)) + let calls = service.policies + feature.reconcile([codex]) + #expect(service.policies == calls, "unchanged sources do not reload") + #expect(service.probes == 1) + #expect(feature.state(for: codex).tools.map(\.name) == ["read"]) + } + + @Test func registrationChangesDuringDetectionUseTheLatestSources() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let task = try #require(feature.detect(ToolStub.row(agents: [.codex]))) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + let both = ToolStub.row() + feature.reconcile([both]) + service.release.open() + await task.value + #expect(Set(feature.state(for: both).policies.keys) == Set(McpAgent.allCases)) + #expect(feature.state(for: both).error == nil) + #expect(service.probes == 1) + } + + @Test func changedRegistrationPolicyFailuresAreVisibleAndKeepOtherAgentsUsable() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + service.policyFailure = .claudeCode + let both = ToolStub.row() + feature.reconcile([both]) + #expect(feature.state(for: both).error != nil) + #expect(feature.state(for: both).policies[.codex]?.tools.first?.enabled == true) + #expect(feature.state(for: both).policies[.claudeCode] == nil) + service.policyFailure = nil + feature.loadPolicies(both) + #expect(feature.state(for: both).error == nil) + #expect(feature.state(for: both).policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.probes == 1) + } + @Test func nativeToolPanelFitsWideAndNarrowDarkAndLightLayouts() async throws { let service = ToolStub(); let feature = McpToolsFeatureModel(service: service); let row = ToolStub.row() let ready = TestGate() @@ -71,6 +134,8 @@ private final class ToolStub: McpToolsManaging { let started = TestGate(), release = TestGate() var waits = false, fails = false var probes = 0, policies = 0 + var policyFailure: McpAgent? + var released: [UUID] = [] func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { probes += 1; started.open() if waits { _ = await release.waitUntilOpen() } @@ -78,18 +143,19 @@ private final class ToolStub: McpToolsManaging { } func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { policies += 1 + if policyFailure == agent { throw CocoaError(.fileReadUnknown) } return McpToolPolicySnapshot(id: UUID(), agent: agent, tools: tools.map { McpToolPermission(name: $0, enabled: true, editable: true) }, previousSourceHash: nil, sourceHash: nil) } func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { if fails { throw CocoaError(.fileWriteUnknown) } return McpToolPolicySnapshot(id: UUID(), agent: snapshot.agent, tools: [McpToolPermission(name: tool, enabled: enabled, editable: true)], previousSourceHash: nil, sourceHash: nil) } - func release(_ snapshots: [McpToolPolicySnapshot]) { } + func release(_ snapshots: [McpToolPolicySnapshot]) { released += snapshots.map(\.id) } func commandLine(command: String, args: [String]) throws -> String { command } func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { (line, []) } - static func row() -> McpServerRow { + static func row(agents: Set = Set(McpAgent.allCases)) -> McpServerRow { let server = McpServerDefinition(name: "docs", transport: .http, command: nil, args: [], env: [], url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") - let sources = McpAgent.allCases.map { agent in McpServerSource(file: McpConfigurationFile(agent: agent, scope: .global, + let sources = McpAgent.allCases.filter { agents.contains($0) }.map { agent in McpServerSource(file: McpConfigurationFile(agent: agent, scope: .global, url: URL(fileURLWithPath: "/fixture/\(agent.rawValue)"), projectPath: nil), definitionHash: "fixture", isOwnedByLithe: false) } return McpServerRow(id: server.id, definition: server, sources: sources, hasDefinitionConflict: false) } diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json index 40e6aa321b..63d9f42587 100644 --- a/shared/platform-feature-matrix/features/agent-skill-management.json +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -14,7 +14,9 @@ "rust/lithe-agent-host/src/skills/tests.rs", "shared/fixtures/agent/skills-management-v1.json", "rust/lithe-agent-host/src/skills/settings.rs", - "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift" + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift", + "macos/Tests/LitheTests/AgentSkillsCancellationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -31,5 +33,5 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。 Windows 原生 CI 回归 publication_cannot_replace_an_existing_empty_directory,覆盖暂存后目标出现、普通文件与非空目录字节保留、Unicode/空格目标完整发布,以及 NUL 路径拒绝。Windows 使用 MoveFileExW 且不启用覆盖或跨卷复制,不以 std::fs::rename 的平台行为推断排他性;文件系统用例通过不代表设置页或会话已验收。" + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。 Windows 原生 CI 回归 publication_cannot_replace_an_existing_empty_directory,覆盖暂存后目标出现、普通文件与非空目录字节保留、Unicode/空格目标完整发布,以及 NUL 路径拒绝。Windows 使用 MoveFileExW 且不启用覆盖或跨卷复制,不以 std::fs::rename 的平台行为推断排他性;文件系统用例通过不代表设置页或会话已验收。 AgentSkillsCancellationTests 通过真实桥接验证创建、导入和启停在服务入口前取消时不改变技能包或原生配置;编码时取消经 requestStarted 注册握手补传,文件分派前停止且后续请求可成功。" } diff --git a/shared/platform-feature-matrix/features/mcp-server-library.json b/shared/platform-feature-matrix/features/mcp-server-library.json index 6b48a886c3..80007d4201 100644 --- a/shared/platform-feature-matrix/features/mcp-server-library.json +++ b/shared/platform-feature-matrix/features/mcp-server-library.json @@ -38,6 +38,6 @@ ] }, "owner": "Agent", - "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。 Codex 移除成功后只保留允许/禁用工具名到无 sourceHash 的账本条目,重新插入时恢复;已有原生条目和重命名不被旧保留值覆盖,格式错误拒绝移除,空允许名单仍拒绝全部工具。保留名单不构成现存文件所有权,不保存认证信息。取消探测复用 Core 注册回调,无重试计时器。", - "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。 McpArgumentRoundTripTests.disabledToolsSurviveServerRemovalAndReopening 对 Agent 开关、编辑器保存、删除重加三条真实隔离文件路径验证:工具仍禁用,外部 deny/allow-list 不放宽、另一 Agent 不变、新页面读回且仅 Lithe 自有规则可重新开启。Core 回归覆盖空 allow-list、非法类型、现存原生规则优先及恢复后完整哈希。CI test-stability-macos 产物包含 mcp-ui 的真实产品编辑器及工具面板深浅主题、宽窄截图。" + "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。 Codex 移除成功后只保留允许/禁用工具名到无 sourceHash 的账本条目,重新插入时恢复;已有原生条目和重命名不被旧保留值覆盖,格式错误拒绝移除,空允许名单仍拒绝全部工具。保留名单不构成现存文件所有权,不保存认证信息。取消探测复用 Core 注册回调,无重试计时器。 Codex 成功重命名后移动同一配置路径下的精确工具名所有权并清除旧名记录,不接管外部规则或合并目标旧记录;批量失败不迁移。已检测行的 Agent 来源改变时释放旧快照并重读本地策略,探测中变化采用最新来源,不重复连接服务器。", + "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。 McpArgumentRoundTripTests.disabledToolsSurviveServerRemovalAndReopening 对 Agent 开关、编辑器保存、删除重加三条真实隔离文件路径验证:工具仍禁用,外部 deny/allow-list 不放宽、另一 Agent 不变、新页面读回且仅 Lithe 自有规则可重新开启。Core 回归覆盖空 allow-list、非法类型、现存原生规则优先及恢复后完整哈希。CI test-stability-macos 产物包含 mcp-ui 的真实产品编辑器及工具面板深浅主题、宽窄截图。 验证禁用自有工具后重命名、重新读取并开启,外部 deny/allow-list 保持不可编辑,旧名重用不继承所有权;检测后及探测期间新增、移除 Agent 时刷新对应策略并释放旧快照,读取失败可见且不影响另一 Agent,服务器探测次数保持一次。" } From 7717c9afeb55bb21239fdcea8b72a4c86a1cb0e7 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 17:57:18 +0800 Subject: [PATCH 13/16] test(ci): bound Windows frontend path roundtrip startup --- .../2026-09-27-windows-git-path-identity.md | 1 + .../references/windows-and-rust.md | 11 ++++++++++ .../scripts/test-stability-windows.ps1 | 3 +++ .github/workflows/ci-windows.yml | 1 + rust/lithe-core/tests/git_path_roundtrip.rs | 22 +++++++++++++------ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md index d35c1f5c38..1062c1a047 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md @@ -33,6 +33,7 @@ Issue #627 中,前端先判断绝对路径再替换反斜杠,把原生 UNC - `shared/fixtures/git/windows-paths.json` 由前端与 Rust 测试共同读取。 - `git_path_roundtrip` 原生集成测试使用真实 Git 仓库,把发现结果交给实际前端 TypeScript 规范化函数后回传 Core,核对提交、分支、stash 和链接工作树的监听目录。它需要 Git 和 Node.js 22.6+,子进程通过现有 Git Host 设置本地期限并清理进程树。 + - Windows CI 39933419913 的超长路径用例在 Node 子进程十秒截止时返回 `Cancelled`,没有产生路径结果不匹配。测试仅将 Windows 的真实 Node/TypeScript 加载期限改为二十秒,Git 夹具命令仍为十秒、Core 请求仍为五秒;SharedRust 与真实 JDT 两个入口为 `git_path_roundtrip::` 设置单例六十秒上限,容纳多次进程交换,不调整其他测试或总套件期限。超时仍走原生进程树清理,并在失败诊断保留进程状态与 stderr;不能通过重试断言、复制前端逻辑或跳过长路径用例来绕开验证。 - 长路径用例先在短路径建立真实仓库,再移入超长目录。Windows CI 已观察到文件系统可访问但创建 Git 进程的工作目录被 `ERROR_DIRECTORY` 拒绝;用例仅接受成功读到原提交,或带原生错误 267 的 `process_start_failed`,不能返回空仓库或访问其他目录。 - Windows 专属测试真实创建末尾点/空格目录及普通同名目录,确认请求明确拒绝,不误用普通目录。 - 原生 watcher 测试用有界事件接收验证外部提交/切换所写的共享引用与独立 HEAD 元数据会通知对应工作树;没有用 sleep 等待防抖。 diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index a5dd93e398..d7c81eb3c0 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -107,6 +107,17 @@ HTML/JUnit classification, and remains capped by the shared suite deadline. Use scoped budgets only for measured integration costs, never to bypass an unbounded wait or an assertion failure. +`git_path_roundtrip::` imports the production TypeScript path normalizer in +real Node subprocesses between native Git/Core requests. Windows run 39933419913 +cancelled Node at the fixture's 10-second subprocess deadline in the long-path +case; no path-result mismatch was reported. Only Node's Windows allowance is +20 seconds; Git setup stays at 10 seconds and Core requests at 5 seconds. Both +SharedRust and real-JDT give this integration binary a 60-second case budget +for the repeated subprocess exchanges. Other cases keep their default budget, +the suite deadline is unchanged, and cancellation still reclaims the process +tree through the native Git Host. Do not replace the frontend import with a +Rust copy of its normalization logic or retry a failed assertion. + ## Windows verification Run the PowerShell harness in a real Windows environment. A macOS boundary diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index c72417a9f4..b05ec59913 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -69,6 +69,9 @@ function Invoke-TimedRustTests { $arguments += @("--package", $Package) } if ($Package -eq "lithe-core") { + # These cases start real Git and the production TypeScript module + # in Node. One Node startup hit its old 10s local deadline on CI. + $arguments += @("--test-budget", "git_path_roundtrip::=60000") # Integration tests that create multiple real repositories or push # through Git subprocesses need a bounded allowance on Windows. foreach ($prefix in @( diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index a966d88027..1a6d55e4cd 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -779,6 +779,7 @@ jobs: --package lithe-core ` --warn-ms 1000 ` --max-ms 15000 ` + --test-budget git_path_roundtrip::=60000 ` --test-budget lsp::interface::engine::real_jdt_tests::real_jdtls_discovers_builds_and_launches_java_25_entrypoints=600000 ` --test-budget tests::git_history_rewrite::=30000 ` --test-budget tests::git_history_rewrite::native_rebase_skip_omits_conflicted_message_from_later_squash=45000 ` diff --git a/rust/lithe-core/tests/git_path_roundtrip.rs b/rust/lithe-core/tests/git_path_roundtrip.rs index 58bfa986c9..9b8c9e1c48 100644 --- a/rust/lithe-core/tests/git_path_roundtrip.rs +++ b/rust/lithe-core/tests/git_path_roundtrip.rs @@ -32,9 +32,11 @@ impl Drop for Fixture { } fn run(command: &mut Command) -> String { - // Git/Node startup can exceed five seconds on Windows CI. Keep a local - // deadline inside the timing harness's 15-second case watchdog. - let deadline = Instant::now() + Duration::from_secs(10); + run_with_timeout(command, Duration::from_secs(10)) +} + +fn run_with_timeout(command: &mut Command, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; let result = lithe_git_host::run( command, None, @@ -44,8 +46,10 @@ fn run(command: &mut Command) -> String { ); assert!( result.failure.is_none(), - "{command:?}: {:?}", - result.failure + "{command:?} within {timeout:?}: {:?}; status: {:?}; stderr: {}", + result.failure, + result.status, + String::from_utf8_lossy(&result.stderr) ); assert!( result.status.is_some_and(|status| status.success()), @@ -88,11 +92,15 @@ fn frontend_path(path: &str) -> String { .join("../../windows/tauri/src/features/git/api/git-repository-path.ts"); // Node is already required by both platform timing harnesses. Import the // production TypeScript function rather than duplicating it in Rust. - run(Command::new("node").args([ + // Windows CI reached the old 10s deadline in this Node/TypeScript process. + // Keep Git and Core request deadlines unchanged; + // only this frontend subprocess gets 20s within the scoped 60s case budget. + let timeout = Duration::from_secs(if cfg!(windows) { 20 } else { 10 }); + run_with_timeout(Command::new("node").args([ "--experimental-strip-types", "--input-type=module", "-e", "import { pathToFileURL } from 'node:url'; const { normalizeRepositoryPath } = await import(pathToFileURL(process.argv[1]).href); console.log(normalizeRepositoryPath(process.argv[2]));", module.to_str().unwrap(), path, - ])) + ]), timeout) } fn initialize(root: &Path) -> String { From 54540c12dd62a2f534351a3328702e524b78024e Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 17:59:19 +0800 Subject: [PATCH 14/16] fix(ci): match raw path test names for scoped deadlines --- .../bug-fix/2026-09-27-windows-git-path-identity.md | 2 +- .../write-stable-tests/references/windows-and-rust.md | 7 +++++-- .../write-stable-tests/scripts/test-stability-windows.ps1 | 2 +- .github/workflows/ci-windows.yml | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md index 1062c1a047..83eb3692ce 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md @@ -33,7 +33,7 @@ Issue #627 中,前端先判断绝对路径再替换反斜杠,把原生 UNC - `shared/fixtures/git/windows-paths.json` 由前端与 Rust 测试共同读取。 - `git_path_roundtrip` 原生集成测试使用真实 Git 仓库,把发现结果交给实际前端 TypeScript 规范化函数后回传 Core,核对提交、分支、stash 和链接工作树的监听目录。它需要 Git 和 Node.js 22.6+,子进程通过现有 Git Host 设置本地期限并清理进程树。 - - Windows CI 39933419913 的超长路径用例在 Node 子进程十秒截止时返回 `Cancelled`,没有产生路径结果不匹配。测试仅将 Windows 的真实 Node/TypeScript 加载期限改为二十秒,Git 夹具命令仍为十秒、Core 请求仍为五秒;SharedRust 与真实 JDT 两个入口为 `git_path_roundtrip::` 设置单例六十秒上限,容纳多次进程交换,不调整其他测试或总套件期限。超时仍走原生进程树清理,并在失败诊断保留进程状态与 stderr;不能通过重试断言、复制前端逻辑或跳过长路径用例来绕开验证。 + - Windows CI 39933419913 的超长路径用例在 Node 子进程十秒截止时返回 `Cancelled`,没有产生路径结果不匹配。测试仅将 Windows 的真实 Node/TypeScript 加载期限改为二十秒,Git 夹具命令仍为十秒、Core 请求仍为五秒;SharedRust 与真实 JDT 两个入口为 `git_paths_roundtrip_` 设置单例六十秒上限,容纳多次进程交换,不调整其他测试或总套件期限。超时仍走原生进程树清理,并在失败诊断保留进程状态与 stderr;不能通过重试断言、复制前端逻辑或跳过长路径用例来绕开验证。 - 长路径用例先在短路径建立真实仓库,再移入超长目录。Windows CI 已观察到文件系统可访问但创建 Git 进程的工作目录被 `ERROR_DIRECTORY` 拒绝;用例仅接受成功读到原提交,或带原生错误 267 的 `process_start_failed`,不能返回空仓库或访问其他目录。 - Windows 专属测试真实创建末尾点/空格目录及普通同名目录,确认请求明确拒绝,不误用普通目录。 - 原生 watcher 测试用有界事件接收验证外部提交/切换所写的共享引用与独立 HEAD 元数据会通知对应工作树;没有用 sleep 等待防抖。 diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index d7c81eb3c0..2c012f238f 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -107,16 +107,19 @@ HTML/JUnit classification, and remains capped by the shared suite deadline. Use scoped budgets only for measured integration costs, never to bypass an unbounded wait or an assertion failure. -`git_path_roundtrip::` imports the production TypeScript path normalizer in +`git_paths_roundtrip_` imports the production TypeScript path normalizer in real Node subprocesses between native Git/Core requests. Windows run 39933419913 cancelled Node at the fixture's 10-second subprocess deadline in the long-path case; no path-result mismatch was reported. Only Node's Windows allowance is 20 seconds; Git setup stays at 10 seconds and Core requests at 5 seconds. Both -SharedRust and real-JDT give this integration binary a 60-second case budget +SharedRust and real-JDT give these path roundtrip cases a 60-second case budget for the repeated subprocess exchanges. Other cases keep their default budget, the suite deadline is unchanged, and cancellation still reclaims the process tree through the native Git Host. Do not replace the frontend import with a Rust copy of its normalization logic or retry a failed assertion. +The prefix matches the raw Rust test name, without the Cargo target printed +before it in the console. Check each report's `maxMs` to confirm the allowance +actually applies; a successful fast run alone does not prove prefix matching. ## Windows verification diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index b05ec59913..7402e94879 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -71,7 +71,7 @@ function Invoke-TimedRustTests { if ($Package -eq "lithe-core") { # These cases start real Git and the production TypeScript module # in Node. One Node startup hit its old 10s local deadline on CI. - $arguments += @("--test-budget", "git_path_roundtrip::=60000") + $arguments += @("--test-budget", "git_paths_roundtrip_=60000") # Integration tests that create multiple real repositories or push # through Git subprocesses need a bounded allowance on Windows. foreach ($prefix in @( diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 1a6d55e4cd..4c6cf98306 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -779,7 +779,7 @@ jobs: --package lithe-core ` --warn-ms 1000 ` --max-ms 15000 ` - --test-budget git_path_roundtrip::=60000 ` + --test-budget git_paths_roundtrip_=60000 ` --test-budget lsp::interface::engine::real_jdt_tests::real_jdtls_discovers_builds_and_launches_java_25_entrypoints=600000 ` --test-budget tests::git_history_rewrite::=30000 ` --test-budget tests::git_history_rewrite::native_rebase_skip_omits_conflicted_message_from_later_squash=45000 ` From 20a28aa7e8057277194aeb4300e06db11ba4b602 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 18:26:39 +0800 Subject: [PATCH 15/16] test(ci): scope checkout and clone integration timing allowance --- .../2026-09-12-git-execution-and-project-console.md | 6 ++++++ .../write-stable-tests/references/windows-and-rust.md | 9 +++++++++ .../scripts/test-stability-windows.ps1 | 1 + .github/workflows/ci-windows.yml | 1 + 4 files changed, 17 insertions(+) diff --git a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md index 137dbcec26..3694927485 100644 --- a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md +++ b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md @@ -253,6 +253,12 @@ JDT 通道曾触发其 15 秒测试期限,同一提交在 SharedRust 通道用 所有测试的时限来隐藏这类问题。计时工具用可控时钟验证普通、集成和总截止 时间,并验证 HTML 与 JUnit 不会把合法的集成耗时误判为失败。 +checkout/clone 完整流程用例 `git_write_executes_checkout_preflight_clone_and_validation` +在 Windows CI 39935852248 中全部断言通过,Rust 内部计时 15.22 秒、测试进程计时 +18.529 秒,超过默认十五秒后触发清缓存重编,继而耗尽原套件期限。该用例创建分叉 +分支,覆盖干净、脏和未跟踪文件预检、智能/强制切换以及本地 clone,因此只为它在 +SharedRust 和真实 JDT 两个入口设置三十秒预算;不延长请求或整套期限,不重试失败断言。 + 取消模块的确定性回归核对 mutation 后正常读取保留原截止时间和取消能力, 已取消或超时请求的独立读取仍受清理期限约束,错误和异常退出后恢复原状态。 真实仓库 rebase 回归核对 edit、conflict、continue、skip、abort 和过期 amendment; diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index 2c012f238f..66b65d585c 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -121,6 +121,15 @@ The prefix matches the raw Rust test name, without the Cargo target printed before it in the console. Check each report's `maxMs` to confirm the allowance actually applies; a successful fast run alone does not prove prefix matching. +`tests::git::git_write_executes_checkout_preflight_clone_and_validation` +gets 30 seconds in both Windows lanes. Run 39935852248 reported its assertions +passing in 15.22 seconds, but the complete test process took 18.529 seconds and +exceeded the default 15-second budget. It creates divergent branches, checks +clean/dirty/untracked checkout preflights, performs smart and forced checkouts, +and clones a real local repository. The cache retry then exhausted the existing +suite deadline; that secondary timeout does not justify extending the whole +suite. Keep every checkout/clone assertion and the existing request deadlines. + ## Windows verification Run the PowerShell harness in a real Windows environment. A macOS boundary diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index 7402e94879..b644ec84f7 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -83,6 +83,7 @@ function Invoke-TimedRustTests { "tests::git::git_write_deletes_a_local_commit_", "tests::git::git_write_edits_a_local_commit_message_", "tests::git::git_write_updates_a_noncurrent_branch_without_switching_head", + "tests::git::git_write_executes_checkout_preflight_clone_and_validation", "tests::git_patch_exchange::patch_exchange_detects_index_flags_and_hidden_destination_edits", "tests::git_patch_exchange::patch_exchange_rejects_stale_preview_and_forward_conflicts_without_writing", "tests::git_patch_exchange::patch_exchange_roundtrips_renames_deletes_and_platform_file_modes", diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 4c6cf98306..ae6d34bbf1 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -790,6 +790,7 @@ jobs: --test-budget tests::git::git_write_deletes_a_local_commit_=30000 ` --test-budget tests::git::git_write_edits_a_local_commit_message_=30000 ` --test-budget tests::git::git_write_updates_a_noncurrent_branch_without_switching_head=30000 ` + --test-budget tests::git::git_write_executes_checkout_preflight_clone_and_validation=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_detects_index_flags_and_hidden_destination_edits=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_rejects_stale_preview_and_forward_conflicts_without_writing=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_roundtrips_renames_deletes_and_platform_file_modes=30000 ` From 13c8d237921fc1930b6b91706b5de1c82e45835f Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 19:52:27 +0800 Subject: [PATCH 16/16] fix(ci): allow Agent Host cold compilation within Rust suite budget --- .../2026-09-13-ci-build-cache-and-artifact-strategy.md | 9 +++++++++ .../write-stable-tests/references/windows-and-rust.md | 8 ++++++++ scripts/verify-rust-core-crates.sh | 3 ++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index 6e9ed0e893..9f0f038c39 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -131,6 +131,15 @@ macOS 槽位约十分钟。只改 Rust 时 Swift 测试 job 仍会启动,但 桥接验证,不跑完整单元测试。本地 `scripts/verify-rust-core.sh` 依次运行两半, 入口不变。 +纯 Rust 通道的 Agent Host(Agent 进程和协议宿主)使用计时 runner 默认的 +20 分钟总预算,包含冷编译、测试枚举和执行。运行 `39939156503` 在持续编译 +Agent Host 及 ACP/MCP 协议依赖时,触发了原先的两分钟总限额,测试尚未开始。 +因此不再把轻量 Git Host 的两分钟预算套给 Agent Host;Git Host 仍保留原预算。 +每项 Agent Host 测试仍受 15 秒看门狗约束,整个 CI job 仍限制为 30 分钟。 +代价是异常冷编译可以占用更长时间,不能据此放宽单测试或重试失败断言。 +验证时使用空 Cargo target 目录,并检查报告中的编译耗时和每项测试预算, +不能用本机暖缓存的快速通过代替冷编译证据。 + 插件通道的冷编译与测试执行分别计时。运行 `39741361768` 中,Rust Core 和 Swift 测试编译共用的 20 分钟步骤到期时,Swift 仍在持续编译完整 Lithe 目标,尚未开始 测试;后续 PR 运行复用了这一进行中的任务,因此源任务超时也使后续插件门禁失败。 diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index 66b65d585c..60e2d8d72d 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -43,6 +43,14 @@ native Git process/AskPass tests in `git-host-rust.json` and Core tests in `shared-rust.json`. Testing a Cargo package does not run its dependencies' own tests. macOS's `verify-rust-core.sh` runs the same native adapter timing lane. +In macOS's Rust-only lane, Agent Host uses the Rust runner's default 20-minute +suite budget, including compilation. Run 39939156503 exhausted the old two-minute +override while still compiling Agent Host and its ACP/MCP dependencies; no test +had started. Git Host keeps its smaller two-minute suite allowance. Agent Host's +individual cases retain the default 15-second watchdog, and the CI job keeps its +30-minute limit. A warm-cache pass does not verify this path: use an empty Cargo +target directory and inspect both compilation time and per-case report budgets. + Every Bun and Rust lane writes JUnit XML plus a self-contained HTML dashboard below `.artifacts/test-stability/`. The dashboard groups Rust cases by crate module and Bun cases by their JUnit class or suite. diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 7cf4806ed8..9bfe4b8da0 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -21,9 +21,10 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-git-host \ --suite-timeout-ms 120000 \ --report .artifacts/test-stability/git-host-rust.json +# Agent Host's ACP/MCP dependencies need the runner's default cold-build budget; +# its suite deadline includes compilation, while each test remains capped at 15s. node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-agent-host \ - --suite-timeout-ms 120000 \ --report .artifacts/test-stability/agent-host-rust.json node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-ide-host \