From f2da1b3aca538a334a6792e75a5f8e4a0308ee57 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 18:13:11 +0800 Subject: [PATCH 01/61] docs: define OpenTake Beta 5 release design --- .../specs/2026-08-13-opentake-beta5-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-13-opentake-beta5-design.md diff --git a/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md b/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md new file mode 100644 index 00000000..53c42aca --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md @@ -0,0 +1,220 @@ +# OpenTake 1.0.0-beta.5 设计规格 + +日期:2026-08-13(Asia/Shanghai) + +## 1. 目标与范围 + +Beta 5 在 Beta 4 的编辑、播放、导出和更新基线上完成六个相互关联的产品面: + +1. 恢复长期外部 MCP,但不恢复历史上无认证、固定端口即开放的实现; +2. 修复设置页模型清理、文字出现/收缩、深浅色与标准/紧凑切换; +3. 调整素材库、Home 工程预览和 macOS 窗口栏; +4. 把应用内 Agent 改为连续、无边框、工具调用按顺序穿插的对话; +5. 把动效从 Agent 窄栏中移出,建立可编辑真实 HTML/CSS 文件的独立 Motion Studio; +6. 将以上变更以 `1.0.0-beta.5` 通过完整发布门禁并发布为 prerelease。 + +不在 Beta 5 范围内:完整 After Effects/Figma 级可视化设计器、任意 Node/npm 工程执行、 +任意文件系统访问、透明 ProRes 4444 输出,以及浅色主题实现。Motion Canvas 现有模板继续兼容, +但不再作为主要作者界面。 + +## 2. 已选方案 + +采用原生集成方案: + +- CodeMirror 6(MIT)提供 HTML/CSS 源文件编辑; +- 现有 `opentake-motion` Chromium 沙箱负责确定性实时预览和逐帧渲染; +- 现有 FFmpeg sidecar 负责编码和原子落轨; +- 现有 Motion Canvas 3.17.2(MIT)作为兼容模板来源; +- UI 参考 Songxia/Codex 的密度、层次和连续对话感,但不复制受保护资产。 + +没有选择完整嵌入 EasyLogic Studio 或 Motionity:二者虽然是 MIT 开源项目,但各自以自身画布 +模型作为真相,会形成第二套项目/时间线状态,不能满足 Agent 与用户直接编辑同一 HTML/CSS 文件 +的要求。第三方名称、版本、仓库、版权和许可证进入 `THIRD_PARTY_NOTICES.md` 与依赖门禁。 + +## 3. 长期外部 MCP + +### 3.1 生命周期和认证 + +外部 MCP 仅绑定 IPv4 loopback `127.0.0.1:19789`,不监听 `0.0.0.0`、IPv6 任意地址或局域网。 +服务仅在至少一个已配对客户端存在且用户启用外部连接时运行;应用退出时关闭监听器。 + +每个配对客户端拥有: + +- 随机客户端 id; +- 用户可编辑显示名称; +- 独立 256-bit Bearer token; +- 创建时间、最后使用时间和撤销状态; +- 仅用于显示/查找的 token 摘要。 + +明文 token 只在创建或重新生成时返回一次,并存入系统钥匙串。持久化元数据不得包含明文 token。 +设置页支持创建、复制配置、重新生成和撤销;撤销后旧 token 立即失效并取消该客户端的活动请求。 + +### 3.2 请求安全边界 + +所有 `/mcp` 与 well-known 请求先经过以下边界: + +- Host 与 Origin 必须是当前 loopback 端点; +- `Authorization: Bearer` 必须匹配一个未撤销客户端; +- 请求体、Content-Type、协议版本、并发和有限 JSON 数字沿用 Beta 4 限制; +- 日志、错误、事件和设置页永不回显完整 token; +- 认证比较使用常量时间;失败统一返回认证错误,不泄露客户端是否存在; +- 无保存工程、工程正在切换或请求捕获的工程身份已过期时,编辑和媒体写入失败关闭。 + +外部连接复用应用内 Agent 的同一个 `Dispatcher`、能力桥和插件注册表,不创建第二套工具宇宙。 +每个 rmcp session 使用独立 undo scope;一个客户端不能撤销另一个客户端或应用内聊天的操作。 +工程切换先停止新请求、取消旧工程活动请求、等待副作用终止,再切换工程身份。 + +### 3.3 产品状态与命令 + +Tauri 持有一个 `ExternalMcpState`,统一管理监听器、配对目录、活动请求和状态事件。前端只通过 +类型化命令读取状态和执行配对操作。设置页展示:关闭、启动中、监听中、端口冲突、认证故障和 +暂停状态;不能把启动失败表现为已连接。 + +必须增加针对未认证、错误 token、撤销 token、远程 Host/Origin、工程切换竞态、跨 session undo、 +端口占用、重启恢复和日志脱敏的 Rust 集成测试。 + +## 4. Agent 连续对话 + +### 4.1 信息结构 + +Agent 面板只保留 Chat;删除 Chat/Motion 二选一和对应本地状态。Motion Studio 成为一级视图, +因此切换动效不会销毁或错误复用聊天会话。 + +Assistant 回复是一个无气泡背景、无外边框的连续内容流。用户消息可保留轻量表面以区分输入。 +工具调用显示为内联状态行,默认只显示工具图标、动作摘要、进度/成功/失败和展开箭头;展开后显示 +参数、结果和图片,但仍属于同一回复,不出现独立卡片边框。 + +### 4.2 有序内容模型 + +渲染以 `ChatMessage.blocks` 为权威顺序:`Text → ToolUse/ToolResult → Text` 原样展示。不能继续先渲染 +整段 `content`、再把 `toolCalls` 全堆到末尾。流式事件必须携带或维护 block 序号,使多轮工具调用 +不会挂到上一条 assistant 消息。旧会话在反序列化时迁移到等价 blocks,仍可打开。 + +### 4.3 时间线截图结果 + +改变可见时间线的工具完成后可附合成预览;当 Agent 删除时间线上最后一个可见片段时必须附图。 +截图通过 Rust 权威时间线与合成路径生成,不截 WebView DOM。空时间线生成带画布比例、时间码和 +“时间线已清空”语义的真实 PNG,而不是返回无图片或空 JSON。图片作为同一 ToolResult 的 image +block 按顺序展示,限制尺寸和编码大小。 + +## 5. Motion Studio + +### 5.1 一级导航和布局 + +编辑器左上入口顺序固定为:Home、Chat、Motion Studio、Panel Management。四个入口与交通灯 +共享同一窗口栏基线和 26px 命中区域。Motion Studio 是独立 `AppView`,不是 Agent 子标签。 + +Motion Studio 采用深色、紧凑、无多余卡片的作者工作区: + +- 左侧:当前工程的动效文档、内置模板和最近版本; +- 中央:16:9 实时画布及播放/暂停、重播和时间刮擦; +- 右侧:宽高、fps、时长、背景、文字和发布参数; +- 下方:帧标尺、播放头和属性关键帧; +- 代码区:`index.html`、`styles.css` 两个 CodeMirror 标签。 + +宽度不足时按代码区、属性栏、文件栏的顺序折叠,画布和发布按钮保持可达;所有键盘操作有明确 +焦点,`prefers-reduced-motion` 下禁用非必要过渡。 + +### 5.2 文档和 Agent 编辑边界 + +每个工程在受控目录保存 motion 文档清单与真实 UTF-8 `index.html`、`styles.css`。HTML/CSS 是 +唯一作者真相;自动保存使用原子临时文件 + rename,保存失败不能覆盖上一版本。 + +Agent 获得仅限当前工程 motion 根目录的类型化工具:列出文档、读取文件、应用带基线 hash 的 +补丁、创建文档、预览和发布。拒绝绝对路径、`..`、符号链接、超限文档、脚本网络和文件系统访问。 +补丁基线过期返回冲突,不静默覆盖用户正在编辑的内容。 + +### 5.3 真实预览与发布 + +初始模板必须包含可见的真实中文/英文标题、副标题和动画。编辑 HTML/CSS 后采用防抖预览;预览 +由网络禁用的 Chromium 沙箱执行,使用确定性时钟和 `OpenTake.seek(frame/fps)`,不是 CSS 假图。 +错误显示具体行列且保留上一张成功帧。 + +发布复用 `motion_add`/`motion_edit` 原子路径:同一文档、fps、宽高和帧数逐帧渲染,经 FFmpeg +编码 MP4,验证结果清单后才注册媒体并加入/替换时间线;失败或取消不改变 manifest/timeline。 +已存在 Motion Canvas 片段可打开为兼容模板参数视图;HTML/CSS 文档以现有 Code source 进入生产桥。 + +## 6. 设置和文字动画 + +### 6.1 外观 + +删除 `Theme = "dark" | "light"`、主题分段控件和 `data-theme=light` 写入。启动迁移删除旧 `theme` +localStorage 值并始终使用深色 tokens。浅色暂不显示为禁用选项,避免让用户误以为可以使用。 + +外观页只显示一个“深色布局”选择组: + +- 深色 · 标准; +- 深色 · 紧凑。 + +两项等宽、固定内容槽,无勾号;选择仅改变背景、描边和文字颜色,标签几何不移动。标准与紧凑 +继续调用真实窗口尺寸变更,并在失败时恢复上一选择和显示错误。 + +### 6.2 模型清理与动态文字 + +模型清理二次确认属于同一行的展开区域,进入前预留/动画化高度;不得突然把说明文字插入按钮 +下方造成跳动。进入和退出统一动画 `max-block-size/opacity/translate`,退出完成后才卸载节点。 + +所有条件性帮助、错误、状态、空态和展开详情使用共享 `Reveal`/`Collapse` 原语。文字本身不逐字 +跳动;容器以 150–200ms 动画出现和收缩。Toast、对话框和一级页面沿用同一 motion tokens。 +系统减少动态效果时过渡时长为 0,内容立即可见且无闪动。 + +## 7. Home、素材库和工程封面 + +### 7.1 素材库 + +“返回主页”从内容 header 移除,放到左侧 CategoryTree 的最上方交通灯安全区之后。它位于素材库 +全局侧栏而非某个分类具体页面;标题、搜索和排序仍在右侧 header。目标以用户更具体的“左侧侧边 +栏上方”要求为准。 + +### 7.2 Home + +完全移除 Home 的 AI 生成记录区块、对应加载 effect 和主页 API 请求;生成审计数据后端保留供 +工程审计使用,不在主页展示。 + +“我的项目”卡片的视觉预览区使用 `aspect-ratio: 16 / 9`,卡片宽度响应式,不能再固定为 48px +高的条形占位。保存/关闭工程后,后端用权威合成器生成代表帧封面并原子写入工程 bundle;Home +读取实际封面。没有可见片段、封面缺失或旧工程时显示包含画布比例、轨道结构和项目名称的结构化 +占位,仍有具体内容,不能只有 Film 图标。 + +## 8. macOS 窗口栏对齐 + +窗口栏高度、交通灯中心、四个左侧入口和右侧操作按钮使用同一垂直中心变量。Tauri +`trafficLightPosition.y` 与 CSS titlebar height 必须按物理像素实测校准,不能继续靠个别按钮 +`top: -2` 之类偏移。Home、素材库、Motion Studio 和设置侧栏使用相同的 safe-area tokens。 + +验收使用最终打包 `.app` 截图:三个交通灯的整体中心与标题栏图标中心误差不超过 1 CSS px, +所有左/右图标共享基线,窗口缩放后仍成立。 + +## 9. 版本、发布和回滚 + +从 Beta 4 不可变发布状态建立 `release/v1.0.0-beta.5`。统一更新: + +- Cargo workspace:`1.0.0-beta.5`; +- Tauri:`1.0.0-beta.5`; +- Web package:`1.0.0-beta.5`; +- Windows WiX:`1.0.0.5`; +- 发布合同、测试 fixture、文档索引、CHANGELOG 和 Release Notes。 + +验证顺序:功能级 TDD → Web 全测/构建 → Rust workspace 测试 → clippy/fmt → MCP live transport +安全测试 → Motion 确定性/取消/真实字符输出 → 最终打包 App 的 Home/素材库/设置/Agent/Motion/ +交通灯 GUI 验收 → 发布工作流合同。不得把浏览器 fallback 结果当作 Tauri 真机证据。 + +发布前确认签名 secrets 可用、远端 tag 不存在、远端分支 SHA 与本地完全一致。只有全部门禁通过 +后创建不可变 `v1.0.0-beta.5` tag 并发布 prerelease。若发布后回归,保留 Beta 5 tag/审计,回退 +到 `v1.0.0-beta.4`,修复只能使用更高版本标签。 + +## 10. 验收矩阵 + +完成必须逐项提供以下权威证据: + +- 外部 MCP:真实客户端重启后仍可连接;撤销后旧凭据拒绝;远程/无认证拒绝;工程切换无越权; +- 存储:模型确认说明进入和退出均平滑,无突然行高跳变; +- 外观:页面无深浅切换、无勾号;深色标准/紧凑真实改变窗口并且文字位置不动; +- 素材库:返回按钮只在左侧侧栏顶部; +- Home:无 AI 生成记录;卡片 16:9 且真实工程显示真实画面; +- Agent:工具调用位于有序文本之间、无独立卡片边框;切会话不串流;清空时间线返回 PNG; +- Motion:独立一级入口;HTML/CSS 可编辑并显示真实文字;预览、发布视频和重开工程结果一致; +- 窗口栏:最终打包截图测量对齐; +- 发布:版本和 17 项资产/签名合同通过,GitHub prerelease 可下载并由 updater 识别。 + +任何一项只有源码、单元测试或视觉推测而没有匹配范围的证据,都不视为完成。 From 582443cdc64d0d690867b30366982c040f501941 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 18:23:11 +0800 Subject: [PATCH 02/61] docs: plan OpenTake Beta 5 implementation --- .../2026-08-13-beta5-agent-conversation.md | 199 +++++++++++ .../plans/2026-08-13-beta5-external-mcp.md | 221 +++++++++++++ .../2026-08-13-beta5-interface-polish.md | 294 +++++++++++++++++ .../plans/2026-08-13-beta5-motion-studio.md | 308 ++++++++++++++++++ .../plans/2026-08-13-beta5-release.md | 185 +++++++++++ 5 files changed, 1207 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md create mode 100644 docs/superpowers/plans/2026-08-13-beta5-external-mcp.md create mode 100644 docs/superpowers/plans/2026-08-13-beta5-interface-polish.md create mode 100644 docs/superpowers/plans/2026-08-13-beta5-motion-studio.md create mode 100644 docs/superpowers/plans/2026-08-13-beta5-release.md diff --git a/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md b/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md new file mode 100644 index 00000000..6e15d205 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md @@ -0,0 +1,199 @@ +# OpenTake Beta 5 Agent Conversation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the timeline Agent into a stable continuous conversation whose text and tools render in authoritative order, whose streams never cross sessions, and whose destructive timeline results include a real composited PNG. + +**Architecture:** Rust chat messages remain the persisted truth, but streaming events address an explicit message and block. The Web store applies those events only to the active matching session and the panel renders blocks as one borderless assistant turn. Timeline mutations are observed at the dispatcher boundary; the transition from visible content to empty requests a bounded PNG from the Rust compositor and adds it to the matching tool-result block. + +**Tech Stack:** Rust/Serde, OpenTake dispatcher and compositor, Tauri events, React/TypeScript, Zustand, Vitest. + +## Global Constraints + +- Remove the Agent-local Chat/Motion mode completely; Motion Studio is a separate app view. +- Treat `ChatMessage.blocks` as the only render order when present; legacy `content` and `toolCalls` exist only for wire compatibility and migration. +- Assistant turns have no enclosing bubble or card border. Tool detail disclosure stays inline with the same response. +- Every stream mutation identifies both `sessionId` and `messageId`; stale or inactive sessions may persist independently but cannot alter the visible draft. +- Generate timeline images through Rust timeline/compositor code, never by screenshotting the WebView. +- Bound image dimensions and encoded bytes and keep image data out of text logs. + +--- + +### Task 1: Make block order explicit in the Rust chat event protocol + +**Files:** +- Modify: `crates/opentake-agent/src/chat/session.rs` +- Modify: `crates/opentake-agent/src/chat/loop.rs` +- Modify: `crates/opentake-agent/src/chat/mod.rs` +- Modify: `src-tauri/src/chat.rs` + +**Interfaces:** +- stable `AgentContentBlock` serialization for text, tool use, and tool result +- `LoopEvent::BlockDelta { session_id, message_id, block_index, delta }` +- `LoopEvent::BlockUpsert { session_id, message_id, block_index, block }` +- `LoopEvent::Done { session_id, message_id, message }` + +- [ ] **Step 1: Write failing order and migration tests** + + Test an assistant message ordered as text A, tool use 1, text B, tool use 2; a tool result containing text and image content; round-trip serialization; and deserialization of legacy content/toolCalls into the equivalent stable order. Test that `refresh_legacy_fields` derives compatibility fields without reordering blocks. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent chat::session::tests::blocks_ chat::loop::tests::events_ -- --nocapture`. Expected: block-addressed events and ordered construction helpers are missing. + +- [ ] **Step 3: Implement ordered block mutation** + + Add constructors and mutation methods that append text/tool blocks in event order and consolidate only adjacent text deltas. Keep legacy field derivation one-way from blocks. Extend loop events with stable message ids generated before the first delta and preserve those ids in the final message. + +- [ ] **Step 4: Adapt Tauri emission and Codex compatibility paths** + + Emit the new event payloads from both normal provider streaming and Codex execution. Ensure every error/cancellation finalizes the same message id. Retain a temporary decoder for prior event fields only where an already-open Beta 4 window can receive them during development. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-agent chat:: -- --nocapture` and `cargo test -p opentake-tauri chat::tests --lib`. + +- [ ] **Step 6: Commit the protocol** + + Commit as `refactor(agent): stream authoritative ordered content blocks`. + +### Task 2: Make the front-end chat store session- and block-safe + +**Files:** +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/store/chatStore.ts` +- Modify: `web/src/store/chatStore.test.ts` + +**Interfaces:** +- `beginMessage(sessionId, messageId)` +- `appendBlockDelta(sessionId, messageId, blockIndex, delta)` +- `upsertBlock(sessionId, messageId, blockIndex, block)` +- `finalize(sessionId, messageId, message)` + +- [ ] **Step 1: Write failing reducer tests** + + Cover text/tool/text order, multiple tool rounds, duplicate retry events, out-of-order block indices, final replacement, switching sessions mid-stream, deleting a session mid-stream, and late events from a previous session. Assert no tool call can merge into the nearest unrelated assistant message. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/chatStore.test.ts`. Expected: current nearest-assistant merging fails the session isolation and block-order cases. + +- [ ] **Step 3: Replace proximity matching with exact identity matching** + + Track drafts by session/message identity, apply immutable block updates, ignore malformed negative/huge indices, and preserve inactive session drafts separately until persisted history is reloaded. Derive visible `messages` only for the selected session. + +- [ ] **Step 4: Wire the typed event decoder** + + Validate event discriminants and required ids before dispatch. On a sequence gap or malformed payload, stop applying that message and request authoritative history instead of guessing an order. + +- [ ] **Step 5: Verify GREEN** + + Run `pnpm -C web test -- src/store/chatStore.test.ts src/components/agent/AgentPanel.persistence.test.tsx` and `pnpm -C web build`. + +- [ ] **Step 6: Commit the store** + + Commit as `fix(agent): isolate ordered streams by session and message`. + +### Task 3: Render one continuous borderless assistant turn + +**Files:** +- Modify: `web/src/components/agent/AgentPanel.tsx` +- Modify: `web/src/components/agent/AgentPanel.persistence.test.tsx` +- Create: `web/src/components/agent/AgentConversation.test.tsx` +- Modify: `web/src/styles/components.css` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- `AssistantTurn` renders blocks sequentially. +- `InlineToolActivity` exposes collapsed status and accessible expanded arguments/results/images. +- User messages keep a quiet surface; assistant messages do not. + +- [ ] **Step 1: Write failing DOM/order tests** + + Render text A, tool use, tool result image, text B and assert exact DOM order, no assistant bubble class, no tool card border class, accessible expand/collapse, error state, and reduced-motion behavior. Assert the Chat/Motion tablist and its stored mode are absent. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/agent/AgentConversation.test.tsx src/components/agent/AgentPanel.persistence.test.tsx`. Expected: current bubble/card and panel-mode tests fail. + +- [ ] **Step 3: Build the continuous renderer** + + Replace the split content/tool rendering with a block switch. Keep tool rows on the text baseline, animate only detail height/opacity, render image blocks with constrained dimensions and alt text, and use live status labels without adding card chrome. + +- [ ] **Step 4: Remove Motion mode from AgentPanel** + + Delete local mode state, tab controls, motion-specific conditional content, and persistence keys. Keep current session selection/input operational across top-level app navigation. + +- [ ] **Step 5: Verify GREEN** + + Run the two focused test files, `pnpm -C web test -- src/components/agent`, and `pnpm -C web build`. + +- [ ] **Step 6: Commit the conversation UI** + + Commit as `feat(agent): render tools inline in continuous replies`. + +### Task 4: Attach a real PNG when the last visible timeline content is deleted + +**Files:** +- Modify: `crates/opentake-agent/src/mcp/dispatch.rs` +- Modify: `crates/opentake-agent/src/mcp/media_bridge.rs` +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/render.rs` +- Modify: `src-tauri/src/chat.rs` + +**Interfaces:** +- mutation receipt records visible clip count before/after execution +- `MediaBridge::capture_timeline_result(request) -> AgentToolResultContentBlock::Image` +- explicit empty-canvas compositor input with project width, height, fps, and playhead timecode + +- [ ] **Step 1: Write failing mutation receipt tests** + + Test deletion from one visible clip to zero, deletion that leaves another visible clip, non-visual mutations, failure/rollback, undo, and batched delete. Only the successful visible-to-empty transition must require an image. + +- [ ] **Step 2: Write failing compositor tests** + + Create a small deterministic empty project and assert returned bytes decode as PNG, dimensions are bounded, pixels include the canvas background and semantic empty-state overlay, and the tool result carries the image after its text summary. Add a non-empty fixture proving the authoritative compositor path is used. + +- [ ] **Step 3: Verify RED** + + Run `cargo test -p opentake-agent mcp::dispatch::tests::timeline_image_ -- --nocapture` and `cargo test -p opentake-tauri render::tests::empty_timeline_ --lib`. Expected: mutation receipts and empty-canvas PNG capture are absent. + +- [ ] **Step 4: Implement post-commit capture** + + Compare Rust timeline visibility before and after an admitted successful mutation. After commit, call the compositor at the current clamped playhead; for zero visible clips, render the explicit project canvas with timecode and localized-neutral empty marker. PNG-encode with the existing image crate, cap dimensions/bytes, and add the image to the same tool result. + +- [ ] **Step 5: Keep failure semantics atomic** + + A capture failure must not roll back a successful edit, but it must append a sanitized warning block. A failed edit, cancelled edit, or stale-project edit must never return a success image. + +- [ ] **Step 6: Verify GREEN** + + Run the focused tests, `cargo test -p opentake-agent mcp:: -- --nocapture`, and `cargo test -p opentake-tauri chat::tests render::tests --lib`. + +- [ ] **Step 7: Commit timeline result images** + + Commit as `feat(agent): show composited result after clearing timeline`. + +### Task 5: Verify conversation behavior in the packaged application + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-agent-conversation.md` +- Create: `docs/audit/2026-08-13/screenshots/agent-continuous-conversation.png` +- Create: `docs/audit/2026-08-13/screenshots/agent-empty-timeline-result.png` + +- [ ] **Step 1: Run all automated Agent gates** + + Run `cargo test -p opentake-agent`, `cargo test -p opentake-tauri chat::tests --lib`, `pnpm -C web test -- src/store/chatStore.test.ts src/components/agent`, and `pnpm -C web build`. + +- [ ] **Step 2: Exercise a real multi-tool conversation** + + In the packaged Tauri app, execute a request that produces text, at least two tools, and final text. Switch sessions during a stream and return. Confirm order, no cross-session mutation, inline disclosure, and no standalone tool cards. + +- [ ] **Step 3: Exercise clear-to-empty** + + Place visible content, ask Agent to delete it, and confirm the ordered tool result includes a decodable PNG showing the empty project canvas rather than an empty JSON result or WebView screenshot. + +- [ ] **Step 4: Record evidence and commit** + + Record exact commands and observed packaged-app behavior, include the two screenshots, and commit as `test(agent): verify Beta 5 continuous conversation`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md b/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md new file mode 100644 index 00000000..1cfef77e --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md @@ -0,0 +1,221 @@ +# OpenTake Beta 5 Long-Lived External MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore a persistent, authenticated loopback MCP endpoint that shares the in-app Agent dispatcher and survives application restarts without persisting plaintext bearer tokens. + +**Architecture:** `ExternalMcpState` owns a listener task and an atomic pairing catalog. The catalog keeps non-secret client metadata in application data while `KeyringStore` keeps one bearer token per client. `opentake-agent` exposes a dynamically authenticated Streamable HTTP endpoint; every accepted session is gated by the production `LiveProjectMcpGate` and uses the same dispatcher, capability bridges, and plugin registry as in-app chat. + +**Tech Stack:** Rust, Tokio, Axum/rmcp Streamable HTTP, Tauri 2 managed state and events, system keyring, React/TypeScript, Zustand, Vitest. + +## Global Constraints + +- Bind only `127.0.0.1:19789`; never fall back to a random port or broader interface. +- Start only when external MCP is enabled and at least one non-revoked client exists. +- Return a plaintext 256-bit bearer token only from pair/regenerate commands and never serialize it into metadata, logs, errors, telemetry, events, or tests. +- Authenticate `/mcp` and well-known routes with constant-time token comparison plus loopback Host/Origin checks before rmcp session creation. +- Reuse `ChatState`'s dispatcher and plugin registry; do not construct a second tool universe. +- Keep a distinct undo scope per rmcp session and cancel affected requests immediately on revoke or project transition. +- Preserve all user-owned `docs/audit/2026-08-07/*` changes and assets. + +--- + +### Task 1: Add an atomic, keychain-backed pairing catalog + +**Files:** +- Create: `src-tauri/src/external_mcp.rs` +- Modify: `src-tauri/src/secret.rs` +- Modify: `src-tauri/src/lib.rs` + +**Interfaces:** +- `ExternalMcpClientSummary { id, name, token_digest, created_at, last_used_at, revoked_at }` +- `ExternalMcpPairingReceipt { client, endpoint, bearer_token }` +- `ExternalMcpCatalog::{load,pair,regenerate,revoke,active_credentials}` +- keychain account name `external-mcp:` under the existing OpenTake service. + +- [ ] **Step 1: Write failing catalog tests** + + Add unit tests proving pair creates a unique client id and 32-byte random token, persisted JSON omits the token, a restart reloads metadata and retrieves the token from a fake secret store, regeneration invalidates the previous token, revoke removes the secret, duplicate display names remain distinguishable, and a failed atomic rename leaves the previous catalog readable. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri external_mcp::tests::catalog --lib`. Expected: compilation fails because the catalog and secret-store seam do not exist. + +- [ ] **Step 3: Implement the secret-store seam and catalog** + + Introduce a narrow `McpSecretStore` trait implemented by the existing keyring wrapper and by an in-memory test double. Persist only versioned metadata beneath `app_data_dir/external-mcp/clients.json` using create-new temp file, sync, rename, and parent sync. Generate credentials with the operating-system RNG, expose only a short SHA-256 digest, validate names and lengths, and serialize timestamps in a stable integer representation. + +- [ ] **Step 4: Verify GREEN** + + Run `cargo test -p opentake-tauri external_mcp::tests::catalog --lib`. All catalog and restart tests must pass without touching the developer's real keychain. + +- [ ] **Step 5: Commit the catalog** + + Commit `src-tauri/src/external_mcp.rs`, `src-tauri/src/secret.rs`, and the module declaration as `feat(mcp): add keychain-backed pairing catalog`. + +### Task 2: Generalize Streamable HTTP authentication for long-lived credentials + +**Files:** +- Modify: `crates/opentake-agent/src/mcp/server.rs` +- Modify: `crates/opentake-agent/src/mcp/mod.rs` + +**Interfaces:** +- `trait BearerAuthorizer { fn authorize(&self, token: &str) -> Option; }` +- `AuthenticatedMcpClient { client_id: Arc, credential_generation: u64 }` +- `ManagedMcpEndpoint { addr, shutdown(), wait() }` +- `bind_managed_gated_on(listener, dispatcher, registry, gate, authorizer)` for production and deterministic tests. + +- [ ] **Step 1: Write failing transport/authentication tests** + + Cover missing authorization, wrong token, revoked token, token regeneration, malformed bearer syntax, remote Host, remote Origin, loopback Origin, valid initialize, and shutdown. Assert all authentication failures have the same public status/body shape and captured tracing output does not contain supplied tokens. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent mcp::server::tests::managed_ -- --nocapture`. Expected: tests fail because the dynamic authorizer and managed endpoint are absent. + +- [ ] **Step 3: Implement dynamic authorization and managed shutdown** + + Refactor the existing single-token guard into a shared boundary that parses once and delegates matching to `BearerAuthorizer`. Compare all active token byte strings in constant time, attach the authenticated client identity to request extensions, retain Beta 4 body/content-type/protocol/concurrency limits, and add an explicit cancellation token plus join handle for listener shutdown. + +- [ ] **Step 4: Preserve ephemeral behavior** + + Adapt `bind_ephemeral_gated` to the new shared boundary with a one-entry authorizer, keeping its random port, one-time token receipt, Host/Origin behavior, and tests unchanged. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-agent mcp::server::tests -- --nocapture` and `cargo test -p opentake-agent chat:: -- --nocapture`. Both suites must pass. + +- [ ] **Step 6: Commit the transport** + + Commit the agent crate changes as `feat(mcp): authenticate managed loopback sessions`. + +### Task 3: Promote project gating to production and share the Agent tool universe + +**Files:** +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/chat.rs` +- Modify: `src-tauri/src/external_mcp.rs` + +**Interfaces:** +- production `LiveProjectMcpGate` implementing `ChatTurnGate` +- `ChatState::external_mcp_components() -> ExternalMcpComponents` +- `ExternalMcpState::new(core, components, catalog)` + +- [ ] **Step 1: Write failing shared-state and transition tests** + + Add tests proving external state receives pointer-identical dispatcher/registry values from `ChatState`, refuses mutating calls with no saved project, cancels an active old-project request before activating the new identity, rejects a stale identity, and keeps undo isolated across two rmcp sessions and in-app chat. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri mcp::tests::live_project_ external_mcp::tests::shared_ --lib`. Expected: the production external components and public gate construction are unavailable. + +- [ ] **Step 3: Move the proven gate out of test-only compilation** + + Remove the `cfg(test)` boundary from the gate and its required imports, keep test-only helpers gated, and make transition admission/cancellation usable by the listener. Do not weaken project identity or side-effect termination checks. + +- [ ] **Step 4: Expose shared ChatState components** + + Add a crate-private immutable component bundle that clones Arcs for the existing dispatcher and registry. Construct `ExternalMcpState` from that bundle during Tauri setup after the core and bridges are ready. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-tauri mcp::tests --lib` and `cargo test -p opentake-tauri external_mcp::tests::shared_ --lib`. + +- [ ] **Step 6: Commit the shared gate** + + Commit as `refactor(mcp): share live project dispatcher with external sessions`. + +### Task 4: Implement the listener lifecycle and typed Tauri command surface + +**Files:** +- Modify: `src-tauri/src/external_mcp.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` + +**Interfaces:** +- `external_mcp_status() -> ExternalMcpStatus` +- `external_mcp_set_enabled(enabled) -> ExternalMcpStatus` +- `external_mcp_pair(name) -> ExternalMcpPairingReceipt` +- `external_mcp_regenerate(client_id) -> ExternalMcpPairingReceipt` +- `external_mcp_revoke(client_id) -> ExternalMcpStatus` +- event `external_mcp_status_changed` + +- [ ] **Step 1: Write failing lifecycle tests** + + Cover disabled startup, restart recovery, zero-client shutdown, port conflict, status transition ordering, pair while enabled, revoke of the final client, regenerate cancellation, application shutdown, and last-used timestamp updates without high-frequency disk writes. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri external_mcp::tests::lifecycle_ --lib`. Expected: command/state lifecycle types do not yet exist. + +- [ ] **Step 3: Implement a serialized state machine** + + Guard lifecycle changes with one async mutex; expose `disabled`, `starting`, `listening`, `portConflict`, `authFailure`, and `paused` states; bind the fixed IPv4 socket before reporting listening; cancel old client sessions on regenerate/revoke; and stop the endpoint in Tauri exit handling. Emit sanitized summaries only. + +- [ ] **Step 4: Register commands and front-end types** + + Register all five commands in `generate_handler!`, add exact camelCase TypeScript DTOs and API wrappers, and provide a typed listener that can re-sync after missed events. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-tauri external_mcp::tests --lib`, `pnpm -C web test -- src/lib/api.test.ts`, and `pnpm -C web build`. + +- [ ] **Step 6: Commit the lifecycle** + + Commit as `feat(mcp): manage persistent external endpoint lifecycle`. + +### Task 5: Replace the external MCP settings placeholder with pairing management + +**Files:** +- Modify: `web/src/components/settings/SettingsView.tsx` +- Create: `web/src/components/settings/ExternalMcpPane.tsx` +- Create: `web/src/components/settings/ExternalMcpPane.test.tsx` +- Modify: `web/src/i18n/dict.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- One enable switch, authoritative status row, fixed endpoint display, client list, and pair/regenerate/revoke/config-copy actions. +- Copy payload uses the documented Streamable HTTP endpoint and bearer header without storing the token in browser persistence. + +- [ ] **Step 1: Write failing interaction tests** + + Assert disabled/listening/port-conflict/auth-failure views, enable rollback on command failure, one-time token reveal, config copy, confirmation before regenerate/revoke, token removal after dismiss, and no credential text in rendered status after navigation/reload. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/settings/ExternalMcpPane.test.tsx`. Expected: the pane and API interactions are absent. + +- [ ] **Step 3: Implement the pane** + + Build the settings UI with existing tokens and shared disclosure motion, accessible labels, clear destructive confirmations, clipboard failure feedback, and status re-sync on mount/event. Do not render an enabled state until the backend reports listening. + +- [ ] **Step 4: Verify GREEN** + + Run `pnpm -C web test -- src/components/settings/ExternalMcpPane.test.tsx src/components/settings/SettingsView.interaction.test.tsx` and `pnpm -C web build`. + +- [ ] **Step 5: Commit settings integration** + + Commit as `feat(settings): manage external MCP pairings`. + +### Task 6: Exercise a real restart and security matrix + +**Files:** +- Create: `src-tauri/tests/external_mcp_integration.rs` +- Create: `docs/audit/2026-08-13/beta5-external-mcp.md` + +- [ ] **Step 1: Add an opt-in real-keychain integration harness** + + Use unique test service/account identifiers, bind only loopback, start a client through rmcp, restart the state against the same temporary catalog/keychain namespace, and clean up only those exact test credentials. + +- [ ] **Step 2: Run the live transport matrix** + + Run `cargo test -p opentake-tauri --test external_mcp_integration -- --nocapture` plus the agent server test suite. Record authenticated restart, revoke rejection, Host/Origin rejection, project-switch cancellation, cross-session undo isolation, and port-conflict results. + +- [ ] **Step 3: Audit logs and persisted bytes** + + Search captured logs and catalog files for each generated full token and require zero matches. Confirm the listener socket is closed after disable and process exit. + +- [ ] **Step 4: Commit verified evidence** + + Commit the integration test and Markdown receipt as `test(mcp): verify persistent external connection boundary`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md b/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md new file mode 100644 index 00000000..1f61a1bb --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md @@ -0,0 +1,294 @@ +# OpenTake Beta 5 Interface Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove misleading appearance controls, animate conditional copy without layout flashes, relocate library navigation, replace Home placeholders with useful 16:9 previews, and align macOS traffic lights with every title-bar icon. + +**Architecture:** A shared disclosure primitive owns conditional text motion and reduced-motion behavior. Settings retain only the real dark standard/compact window choice with optimistic rollback. Library and Home are simplified at their source components. Project saves request an authoritative representative composite for thumbnails. All window chrome uses one CSS geometry contract mirrored by Tauri's macOS traffic-light configuration. + +**Tech Stack:** React/TypeScript, Zustand, CSS tokens, Tauri 2, Rust compositor/image encoding, Vitest, packaged macOS GUI measurement. + +## Global Constraints + +- Always use the dark token set in Beta 5; do not display dark/light choices or persist a theme setting. +- Standard/compact options have stable equal geometry and no checkmark; failed native resize restores the prior selection. +- Conditional copy enters and exits through the shared primitive in 150–200ms, or immediately under `prefers-reduced-motion`. +- Put Library Home navigation at the top of the left category rail only. +- Remove Home generation activity UI and its Home-specific request/effect, but retain backend audit data used elsewhere. +- Use `aspect-ratio: 16 / 9` for project preview surfaces and actual project content where available. +- Final alignment acceptance comes from a packaged `.app`, not browser-only CSS inspection. + +--- + +### Task 1: Create a shared disclosure motion primitive + +**Files:** +- Create: `web/src/components/ui/Reveal.tsx` +- Create: `web/src/components/ui/Reveal.test.tsx` +- Modify: `web/src/styles/tokens.css` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- `Reveal { open, children, id?, role?, onExited? }` +- CSS tokens `--motion-disclosure-duration` and `--motion-disclosure-ease`. + +- [ ] **Step 1: Write failing lifecycle tests** + + Assert open content mounts, close retains content through exit then unmounts, rapid reopen cancels unmount, measured block size does not flash from auto/zero, focus leaves hidden content, and reduced-motion closes synchronously. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/ui/Reveal.test.tsx`. Expected: component and tokens are absent. + +- [ ] **Step 3: Implement measured disclosure** + + Use a wrapper and inner content element with ResizeObserver, CSS custom block size, opacity, and translate. Keep layout reserved throughout exit, clean timers/listeners, handle dynamic content height, and set the duration token to zero in the existing reduced-motion media query. + +- [ ] **Step 4: Verify GREEN** + + Run the focused test and `pnpm -C web build`. + +- [ ] **Step 5: Commit the primitive** + + Commit as `feat(ui): add shared disclosure motion`. + +### Task 2: Remove theme switching and make standard/compact reliable + +**Files:** +- Modify: `web/src/store/settingsStore.ts` +- Create: `web/src/store/settingsStore.test.ts` +- Modify: `web/src/components/settings/SettingsView.tsx` +- Modify: `web/src/components/settings/SettingsView.interaction.test.tsx` +- Modify: `web/src/components/settings/SettingsView.visual.test.ts` +- Modify: `web/src/components/ui/Dropdown.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.lifecycle.test.tsx` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- delete `Theme`, `theme`, `setTheme`, `applyTheme`, and `initTheme`. +- startup clears legacy `theme` and versioned theme keys and sets `document.documentElement.dataset.theme = "dark"` only if compatibility CSS still requires it. +- `setWindowSize(mode)` returns/awaits native success and rolls state back on rejection. + +- [ ] **Step 1: Write failing migration and rollback tests** + + Seed dark/light legacy keys and assert startup removes them and stays dark. Assert Appearance contains exactly two equal-width choices, “深色 · 标准” and “深色 · 紧凑”, no check icon, stable text offsets, native resize success, and rollback/error on rejection. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts`. Expected: current theme control/checkmark and optimistic failure behavior violate assertions. + +- [ ] **Step 3: Remove the unused theme state and UI** + + Delete theme loading/storage/actions and App initialization. Replace the generic checked segmented appearance control with two stable layout cells whose selected state changes color/background only. Keep keyboard radiogroup semantics. + +- [ ] **Step 4: Make native window selection transactional** + + Store the previous mode, call the existing Tauri resize command, commit/persist only on success, and restore plus toast on failure. Ignore a stale failure from an earlier click after a later choice has succeeded. + +- [ ] **Step 5: Verify GREEN** + + Run focused tests, full settings tests, App lifecycle tests, and `pnpm -C web build`. + +- [ ] **Step 6: Commit appearance changes** + + Commit as `fix(settings): keep only stable dark window layouts`. + +### Task 3: Animate model-clear confirmation without a text jump + +**Files:** +- Modify: `web/src/components/settings/StoragePane.tsx` +- Modify: `web/src/components/settings/StoragePane.test.tsx` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- clear confirmation uses `Reveal` within the selected model row. +- destructive action remains disabled during deletion and returns to the stable row on completion/cancel. + +- [ ] **Step 1: Write failing geometry and interaction tests** + + Assert the first clear click expands confirmation inside the row, explanation remains mounted during exit, sibling row top offsets change through the disclosure wrapper instead of immediate insertion, cancel/delete animate closed, repeated clicks do not duplicate copy, and reduced-motion is immediate. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/settings/StoragePane.test.tsx`. Expected: direct conditional text insertion fails lifecycle/geometry assertions. + +- [ ] **Step 3: Integrate the shared primitive** + + Keep action and confirmation in a fixed row layout, animate the explanation/action group with `Reveal`, preserve focus on cancel, move focus to the next valid control after successful deletion, and keep backend failures visible through the same disclosure. + +- [ ] **Step 4: Verify GREEN** + + Run Storage and Reveal tests plus `pnpm -C web build`. + +- [ ] **Step 5: Commit storage motion** + + Commit as `fix(settings): animate model removal confirmation`. + +### Task 4: Move Library Home navigation into the category rail + +**Files:** +- Modify: `web/src/components/media/LibraryView.tsx` +- Modify: `web/src/components/media/LibraryView.test.tsx` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- `CategoryTree` owns the single Home button above category content and beneath title-bar safe space. +- The right content header contains only category title, search, sort, and filter controls. + +- [ ] **Step 1: Write the failing structure test** + + Assert one Home button exists, it is the first interactive element in the left navigation, it is absent from the right header for every category, and the category selection remains unchanged after return/re-entry. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/media/LibraryView.test.tsx`. Expected: current Home action is in the content header. + +- [ ] **Step 3: Relocate the action and apply safe-area spacing** + + Pass the navigation callback into `CategoryTree`, render it once above the category list, and use shared title-bar safe-area tokens. Remove the old content-header button and any duplicate mobile rendering. + +- [ ] **Step 4: Verify GREEN** + + Run Library tests and `pnpm -C web build`. + +- [ ] **Step 5: Commit navigation adjustment** + + Commit as `fix(library): place Home navigation in the global rail`. + +### Task 5: Remove Home generation activity and build useful 16:9 project cards + +**Files:** +- Modify: `web/src/components/home/HomeView.tsx` +- Modify: `web/src/components/home/HomeView.test.tsx` +- Modify: `web/src/components/home/HomeView.interaction.test.tsx` +- Modify: `web/src/components/home/HomeView.visual.test.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- no `GenerationActivity` state/effect/render branch in Home. +- project preview is a semantic figure with `aspect-ratio: 16 / 9`. +- fallback shows project name, canvas ratio, and a small track structure visualization. + +- [ ] **Step 1: Write failing absence and card tests** + + Assert Home never calls the generation-activity API, contains no generation record region, renders thumbnail URLs as images with 16:9 geometry and object-fit cover, and renders a structured named fallback rather than a lone Film icon. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/components/home/HomeView.visual.test.ts`. Expected: generation region and 48px preview fail. + +- [ ] **Step 3: Remove the Home-only generation request** + + Delete the component, state, effect, polling, imports, and Home invocation. Retain shared API methods only if another view/test calls them; otherwise remove the dead front-end wrapper without changing backend audit storage. + +- [ ] **Step 4: Rebuild the card visual hierarchy** + + Use responsive card columns, a 16:9 preview figure, actual thumbnail when present, and a structured CSS fallback that exposes project title and known aspect/track metadata. Preserve project open, context menu, keyboard focus, and loading states. + +- [ ] **Step 5: Verify GREEN** + + Run all Home tests and `pnpm -C web build`. + +- [ ] **Step 6: Commit Home UI changes** + + Commit as `fix(home): simplify activity and show useful project previews`. + +### Task 6: Generate project covers from the authoritative composite + +**Files:** +- Modify: `crates/opentake-media/src/thumbnail/project.rs` +- Modify: `crates/opentake-media/src/thumbnail/mod.rs` +- Modify: `crates/opentake-media/src/lib.rs` +- Modify: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/home.rs` + +**Interfaces:** +- `capture_project_composite_thumbnail(snapshot, manifest, frame, bounds) -> Option>` +- save/close writes `thumbnail.jpg` atomically only after successful composite encode. + +- [ ] **Step 1: Write failing composite thumbnail tests** + + Use a project with background video, overlay image/text, transform, and transition; assert the cover includes composite-layer evidence rather than the decoded representative source alone. Cover empty project, missing/offline source, invalid prior thumbnail, deterministic bounds, and atomic write failure. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-media thumbnail::project::tests::composite_ -- --nocapture` and `cargo test -p opentake-tauri home::tests::thumbnail_ --lib`. Expected: current source-only capture fails layered fixtures. + +- [ ] **Step 3: Reuse the compositor at a representative frame** + + Select a stable frame from visible content, build the same render snapshot as preview/export, composite at a bounded 16:9 output, encode JPEG, and atomically replace the bundle thumbnail. On capture failure retain the last valid thumbnail rather than deleting it. + +- [ ] **Step 4: Verify GREEN** + + Run focused media/Tauri tests and the existing project save/open test suites. + +- [ ] **Step 5: Commit composite covers** + + Commit as `feat(home): save composited project cover frames`. + +### Task 7: Align traffic lights and title-bar controls to one geometry contract + +**Files:** +- Modify: `web/src/components/shell/TitleBar.tsx` +- Modify: `web/src/components/shell/TitleBar.visual.test.ts` +- Modify: `web/src/components/shell/ShellComponentMapping.test.tsx` +- Modify: `web/src/styles/tokens.css` +- Modify: `web/src/styles/components.css` +- Modify: `src-tauri/tauri.conf.json` +- Create: `scripts/measure_titlebar_alignment.py` +- Create: `scripts/test_measure_titlebar_alignment.py` + +**Interfaces:** +- CSS variables `--titlebar-height`, `--titlebar-center-y`, `--titlebar-control-size`, `--titlebar-safe-left`. +- measurement script accepts packaged-app PNG plus traffic-light/icon sample rectangles and fails above 1 CSS px center deviation. + +- [ ] **Step 1: Write failing static and measurement tests** + + Require every left/right title-bar button to use the shared size/alignment class, forbid local vertical transforms/margins, and test the image measurement math with aligned and 2px-offset synthetic fixtures. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/shell/TitleBar.visual.test.ts src/components/shell/ShellComponentMapping.test.tsx` and `python3 -B -m unittest scripts/test_measure_titlebar_alignment.py`. Expected: missing shared geometry/script and current offsets fail. + +- [ ] **Step 3: Consolidate title-bar geometry** + + Use one grid/flex center line and 26px control boxes for all navigation/action icons, remove per-button y nudges, and derive safe areas from tokens. Set Tauri's `trafficLightPosition.y` to the matching packaged macOS center after accounting for native button radius. + +- [ ] **Step 4: Verify static GREEN** + + Run focused tests and `pnpm -C web build`. + +- [ ] **Step 5: Measure the packaged app** + + Build the release `.app`, capture Home, Library, Motion Studio, and Editor title bars at 1x CSS scale, run the measurement script, and require the traffic-light group center and all icon centers to differ by no more than 1 CSS px. + +- [ ] **Step 6: Commit geometry and evidence tooling** + + Commit as `fix(shell): align traffic lights and title-bar controls`. + +### Task 8: Record packaged UI evidence + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-interface-polish.md` +- Create: `docs/audit/2026-08-13/screenshots/settings-dark-layouts.png` +- Create: `docs/audit/2026-08-13/screenshots/library-home-rail.png` +- Create: `docs/audit/2026-08-13/screenshots/home-project-cards.png` +- Create: `docs/audit/2026-08-13/screenshots/titlebar-alignment.png` + +- [ ] **Step 1: Run automated UI gates** + + Run focused tests from Tasks 1–7, full Web tests/build, relevant Rust thumbnail/home tests, visual contract scripts, and `git diff --check`. + +- [ ] **Step 2: Exercise model removal and layout switching** + + In the packaged app, record enter/exit for model clear at normal and reduced motion; switch standard/compact repeatedly; provoke one native resize error in a test harness and confirm rollback. Verify no text/checkmark shift and no light option. + +- [ ] **Step 3: Exercise Library and Home** + + Verify the sole Library Home control is atop the left rail, Home has no generation activity, and saved projects with content display composite 16:9 covers while an empty legacy project displays the structured fallback. + +- [ ] **Step 4: Record alignment receipts and commit** + + Store exact commands, screenshots, measured centers, and limitations; commit as `test(ui): verify Beta 5 interface polish`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md b/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md new file mode 100644 index 00000000..a606b0e3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md @@ -0,0 +1,308 @@ +# OpenTake Beta 5 Motion Studio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a first-level Motion Studio where users and Agent edit the same real HTML/CSS files, preview deterministic visible animation, and publish through OpenTake's existing Chromium/FFmpeg atomic timeline path. + +**Architecture:** Each project owns a capability-confined motion document directory containing a manifest, `index.html`, and `styles.css`. Tauri provides typed atomic document commands and delegates preview/render to `opentake-motion`. React presents a CodeMirror editor, live 16:9 canvas, parameters, and keyframe timeline. MCP document tools use the same backend with baseline hashes, so user and Agent edits converge on one source of truth. + +**Tech Stack:** React 18, CodeMirror 6 MIT packages, TypeScript, Rust, Tauri 2, cap-std, SHA-256, headless Chromium CDP, FFmpeg, existing `motion_add`/`motion_edit`. + +## Global Constraints + +- Motion Studio is a top-level app view between Chat and Panel Management, never an Agent sub-tab. +- Only UTF-8 `index.html` and `styles.css` under the current project's controlled motion root are editable. +- Reject absolute paths, traversal, symlinks, network access, filesystem URLs, oversized documents, stale hashes, and unbounded render dimensions/duration. +- Save atomically and keep the last successful preview visible when a new preview fails. +- Preview and final render use the same source, dimensions, fps, duration, deterministic clock, and network-disabled Chromium sandbox. +- A failed or cancelled publish leaves media manifest and timeline unchanged. +- Record exact CodeMirror packages, versions, copyright, repository, and MIT license in third-party notices. + +--- + +### Task 1: Add the CodeMirror dependency contract and license evidence + +**Files:** +- Modify: `web/package.json` +- Modify: `web/pnpm-lock.yaml` +- Modify: `THIRD_PARTY_NOTICES.md` +- Modify: `scripts/check_license_inventory.py` +- Modify: `scripts/test_check_license_inventory.py` + +**Interfaces:** +- Runtime packages: `codemirror`, `@codemirror/lang-html`, `@codemirror/lang-css`, `@codemirror/theme-one-dark`. +- License inventory maps each resolved package/version to its official MIT source and installed license file. + +- [ ] **Step 1: Write the failing license inventory test** + + Require all four packages and their license entries, and add a mutation fixture that removes one notice or changes one resolved version. + +- [ ] **Step 2: Verify RED** + + Run `python3 -B -m unittest scripts/test_check_license_inventory.py`. Expected: missing CodeMirror package/notice failures. + +- [ ] **Step 3: Install pinned compatible dependencies** + + Use `pnpm -C web add codemirror@6.0.2 @codemirror/lang-html@6.4.12 @codemirror/lang-css@6.3.1 @codemirror/theme-one-dark@6.1.3`. Update notices from the installed packages and official repositories; do not add Animate.css, EasyLogic, or Motionity code. + +- [ ] **Step 4: Verify GREEN** + + Run the license test, `pnpm -C web build`, and `pnpm -C web licenses list --prod` if the installed pnpm supports the command. + +- [ ] **Step 5: Commit the dependency boundary** + + Commit as `build(motion): add licensed CodeMirror editor dependencies`. + +### Task 2: Build the project-confined motion document store + +**Files:** +- Create: `src-tauri/src/motion_documents.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/Cargo.toml` + +**Interfaces:** +- `MotionDocumentSummary { id, title, revision_hash, updated_at }` +- `MotionDocument { summary, html, css, parameters }` +- `MotionDocumentStore::{list,create,read,save_patch}` +- patch request includes `document_id`, `file`, `baseline_hash`, replacement edits, and expected result hash. + +- [ ] **Step 1: Write failing store and confinement tests** + + Cover initial template creation, visible Chinese/English title/subtitle, atomic restart persistence, concurrent stale hash, traversal, absolute path, symlink escape, invalid UTF-8, oversized input, invalid manifest, and failed rename preserving prior content. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri motion_documents::tests --lib`. Expected: module and commands are absent. + +- [ ] **Step 3: Implement capability-confined storage** + + Resolve the motion root from the currently saved project bundle, open it through `cap-std`, map document ids to generated safe directory names, and expose only the two known files. Normalize line endings, hash exact UTF-8 bytes, apply non-overlapping bounded edits, and atomically replace files plus manifest. + +- [ ] **Step 4: Add typed commands** + + Register `motion_document_list`, `motion_document_create`, `motion_document_read`, and `motion_document_patch`; each captures current project identity and fails closed if the project changes before commit. + +- [ ] **Step 5: Verify GREEN** + + Run the focused tests and `cargo test -p opentake-tauri motion_documents:: --lib`. + +- [ ] **Step 6: Commit the store** + + Commit as `feat(motion): persist confined HTML and CSS documents`. + +### Task 3: Add deterministic single-frame preview from the production renderer + +**Files:** +- Modify: `crates/opentake-motion/src/source.rs` +- Modify: `crates/opentake-motion/src/renderer.rs` +- Modify: `crates/opentake-motion/src/sandbox.rs` +- Modify: `crates/opentake-motion/src/integration.rs` +- Modify: `src-tauri/src/motion.rs` + +**Interfaces:** +- `MotionPreviewRequest { document_id, revision_hash, width, height, fps, duration_frames, frame }` +- `MotionPreviewResponse { revision_hash, frame, png_data_url, diagnostics }` +- HTML runtime calls `window.OpenTake.seek(seconds)` before deterministic capture. + +- [ ] **Step 1: Write failing source and deterministic-clock tests** + + Assert generated source contains the document's real title/subtitle, local CSS, OpenTake seek bridge, blocked network policy, and no filesystem URL. Render the same frame twice and require identical decoded pixel hashes; render two animation frames and require a meaningful pixel difference. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-motion preview_ --features chromium -- --nocapture`. Expected: document preview API is absent; live tests may explicitly skip only when the pinned Chromium sidecar is unavailable. + +- [ ] **Step 3: Implement the preview source and capture path** + + Generate a self-contained HTML document from sanitized user HTML/CSS, inject deterministic animation controls before user content, disable fetch/XHR/WebSocket/navigation, apply dimensions/fps/frame bounds, and capture PNG through the existing CDP process manager. Return structured line/column diagnostics and retain no browser process after cancellation. + +- [ ] **Step 4: Expose the Tauri command** + + Read the requested revision from `MotionDocumentStore`, reject a stale hash, call the production renderer, bound PNG/data URL size, and return sanitized diagnostics. + +- [ ] **Step 5: Verify GREEN** + + Run default offline motion tests, feature-gated live preview tests, and `cargo test -p opentake-tauri motion::tests --lib`. + +- [ ] **Step 6: Commit preview support** + + Commit as `feat(motion): preview real HTML and CSS deterministically`. + +### Task 4: Add the Motion Studio top-level view and navigation entry + +**Files:** +- Modify: `web/src/store/uiStore.ts` +- Modify: `web/src/store/uiStore.test.ts` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.lifecycle.test.tsx` +- Modify: `web/src/components/shell/TitleBar.tsx` +- Modify: `web/src/components/shell/TitleBar.interaction.test.tsx` +- Create: `web/src/components/motion/MotionStudio.tsx` +- Create: `web/src/components/motion/MotionStudio.test.tsx` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- `AppView` adds `motion`. +- Title-bar order: Home, Chat, Motion Studio, Panel Management. +- Motion view mounts independently while editor/chat state remains in stores. + +- [ ] **Step 1: Write failing navigation tests** + + Assert the four buttons exist in exact order with 26px hit areas, selecting Motion mounts only Motion Studio, returning to Chat preserves the active chat session, and reloading an invalid persisted view falls back safely. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/uiStore.test.ts src/components/shell/TitleBar.interaction.test.tsx src/components/motion/MotionStudio.test.tsx`. Expected: `motion` is not a valid app view and no entry exists. + +- [ ] **Step 3: Add the view and semantic shell** + + Extend the store and App view switch, add the title-bar button with localized label/tooltip, and create landmarks for file rail, editor, preview canvas, inspector, and timeline. Keep editor hooks mounted without rendering the editor layout in Motion view. + +- [ ] **Step 4: Verify GREEN** + + Run focused tests and `pnpm -C web build`. + +- [ ] **Step 5: Commit navigation** + + Commit as `feat(motion): add Motion Studio as a primary view`. + +### Task 5: Implement the editor, live canvas, parameters, and keyframe strip + +**Files:** +- Modify: `web/src/components/motion/MotionStudio.tsx` +- Create: `web/src/components/motion/MotionCodeEditor.tsx` +- Create: `web/src/components/motion/MotionPreview.tsx` +- Create: `web/src/components/motion/MotionTimeline.tsx` +- Create: `web/src/components/motion/MotionStudio.interaction.test.tsx` +- Create: `web/src/store/motionStudioStore.ts` +- Create: `web/src/store/motionStudioStore.test.ts` +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- HTML/CSS tabs backed by one controlled CodeMirror instance. +- 300ms debounced atomic patch, then preview; revision conflict offers reload or explicit reapply. +- Preview controls update frame without changing saved source. +- Publish parameters share width/height/fps/duration with backend request. + +- [ ] **Step 1: Write failing store and UI tests** + + Cover document load, HTML/CSS tab state, visible initial text, debounced save, stale response suppression, compile diagnostic line/column, retained last-good frame, play/pause/replay/scrub, parameter bounds, narrow layout folding order, keyboard focus, and reduced motion. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/motionStudioStore.test.ts src/components/motion/MotionStudio.interaction.test.tsx`. Expected: store/components do not exist. + +- [ ] **Step 3: Implement state and CodeMirror lifecycle** + + Create one editor view per mounted code panel, swap language extensions by active file, dispatch controlled source updates without cursor reset, dispose on unmount, and serialize saves through revision hashes. Keep errors adjacent to the affected source tab. + +- [ ] **Step 4: Implement the authoring layout** + + Build the Songxia/Codex-inspired dark workspace with low chrome: files/templates/history left, code and 16:9 preview center, parameters right, frame ruler/keyframes below. Use real text in the starter document and semantic buttons/sliders. + +- [ ] **Step 5: Implement deterministic preview scheduling** + + Abort superseded requests, ignore stale revision/frame replies, retain last success on failure, and ensure playback advances integer frames based on the configured fps. + +- [ ] **Step 6: Verify GREEN** + + Run all Motion front-end tests, `pnpm -C web test`, and `pnpm -C web build`. + +- [ ] **Step 7: Commit the authoring UI** + + Commit as `feat(motion): build HTML and CSS authoring workspace`. + +### Task 6: Publish the document through the existing atomic timeline path + +**Files:** +- Modify: `src-tauri/src/motion.rs` +- Modify: `crates/opentake-motion/src/integration.rs` +- Modify: `web/src/components/motion/MotionStudio.tsx` +- Modify: `web/src/lib/api.ts` +- Modify: `src-tauri/tests/motion_integration.rs` + +**Interfaces:** +- `MotionAddCommand` and edit request accept `document_id` plus revision hash while retaining legacy code/template inputs. +- Publish response identifies the committed clip, media asset, render hash, and source document. + +- [ ] **Step 1: Write failing publish integration tests** + + Publish a short visible text animation and verify decoded beginning/middle/end frames contain expected non-background pixels and differ over time. Cover cancellation, FFmpeg failure, stale document, invalid dimensions, reopen/re-render equivalence, and edit replacement without duplicate media registration. + +- [ ] **Step 2: Verify RED** + + Run `OPENTAKE_RUN_FFMPEG_TESTS=1 cargo test -p opentake-tauri --test motion_integration -- --nocapture`. Expected: document-backed publish cases fail while existing Motion Canvas cases remain green. + +- [ ] **Step 3: Connect document source to motion add/edit** + + Resolve the exact revision once, generate the same production source used by preview, render integer frames, encode through the provisioned FFmpeg sidecar, validate the output and cache manifest, then use the existing atomic add/edit commit. Remove staged output on every pre-commit failure/cancel path. + +- [ ] **Step 4: Wire publish UI** + + Disable publish while unsaved or preview-invalid, show frame progress and cancellation, and navigate to/select the committed timeline clip only after the backend returns success. + +- [ ] **Step 5: Verify GREEN** + + Run the integration suite, `cargo test -p opentake-motion --all-features`, the Tauri motion tests, and Motion front-end tests. + +- [ ] **Step 6: Commit publishing** + + Commit as `feat(motion): publish Studio documents atomically`. + +### Task 7: Give Agent conflict-safe Motion document tools + +**Files:** +- Create: `crates/opentake-agent/src/mcp/motion_documents.rs` +- Modify: `crates/opentake-agent/src/mcp/mod.rs` +- Modify: `crates/opentake-agent/src/mcp/server.rs` +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/motion_documents.rs` + +**Interfaces:** +- tools `list_motion_documents`, `read_motion_document`, `create_motion_document`, `patch_motion_document`, `preview_motion_document`, `publish_motion_document`. +- `MotionDocumentBridge` is capability-limited to current-project typed operations. + +- [ ] **Step 1: Write failing schema and capability tests** + + Verify exact JSON schemas, read/list limits, hash-required patch, stale conflict, traversal/absolute/symlink rejection, preview bounds, publish admission, project switch cancellation, and no raw filesystem path in results. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent mcp::motion_documents::tests -- --nocapture` and the Tauri MCP bridge tests. Expected: the bridge/tools are absent. + +- [ ] **Step 3: Implement typed bridge and handlers** + + Keep filesystem access entirely behind the Tauri bridge, register tools in the shared plugin registry, return revision hashes on every read/write, translate conflicts into structured non-mutating results, and reuse the production preview/publish functions. + +- [ ] **Step 4: Verify GREEN** + + Run all agent MCP tests and Tauri MCP tests, then use in-app Agent to change starter title/CSS and confirm the open editor receives the authoritative revision. + +- [ ] **Step 5: Commit Agent integration** + + Commit as `feat(agent): edit Motion Studio documents with hash-safe tools`. + +### Task 8: Verify real characters, animation, persistence, and cancellation + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-motion-studio.md` +- Create: `docs/audit/2026-08-13/screenshots/motion-studio-editor.png` +- Create: `docs/audit/2026-08-13/screenshots/motion-studio-preview.png` + +- [ ] **Step 1: Run automated Motion gates** + + Run Rust default/all-feature tests, live Chromium tests, FFmpeg integration, all Motion UI tests, full Web tests/build, license inventory, and `git diff --check`. + +- [ ] **Step 2: Exercise the packaged workflow** + + Create a document, visibly edit Chinese and English characters plus CSS animation, scrub frames, publish, reopen the project, and compare preview/published representative frames. Cancel a second publish and verify no new timeline/media entry. + +- [ ] **Step 3: Exercise Agent co-editing** + + Ask Agent to patch the same document, provoke a stale hash conflict by typing concurrently, resolve explicitly, preview, and publish. Confirm no silent overwrite or path escape. + +- [ ] **Step 4: Record evidence and commit** + + Capture source, preview, timeline result, exact hashes/commands, and screenshots; commit as `test(motion): verify Beta 5 Studio end to end`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-release.md b/docs/superpowers/plans/2026-08-13-beta5-release.md new file mode 100644 index 00000000..6b8f2d3c --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-release.md @@ -0,0 +1,185 @@ +# OpenTake 1.0.0-beta.5 Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish the verified Beta 5 product as immutable GitHub prerelease `v1.0.0-beta.5`, with the existing seventeen signed updater assets and auditable packaged-app evidence for every requested behavior. + +**Architecture:** Functional plans land first on `release/v1.0.0-beta.5`. A test-first version-contract migration updates Cargo, Web, Tauri, WiX, workflow, documentation, and validator digests as one identity. The frozen candidate passes focused, full, security, license, real MCP, Chromium/FFmpeg, and packaged GUI gates before it can move through a PR to remote `main`; only the verified remote main SHA receives the annotated tag. + +**Tech Stack:** Rust/Cargo, React/TypeScript/Vite/pnpm, Python validators, Tauri 2, GitHub Actions, gh CLI, Minisign/Tauri updater, macOS and Windows package jobs. + +## Global Constraints + +- Product version is exactly `1.0.0-beta.5`, tag `v1.0.0-beta.5`, WiX `1.0.0.5`. +- Do not tag, push, merge, publish, or modify secrets until all local implementation plans and preflight gates are complete. +- Never move/delete/reuse a release tag and never force-push. +- Preserve Beta 4 as rollback; fixes after publication use a higher version. +- Preserve the exact updater trust boundary and seventeen-asset release contract unless a failing primary-platform tool proves a required additive change. +- Keep macOS ad-hoc/not-notarized and Windows non-Authenticode limitations explicit. +- Never stage user-owned untracked/modified `docs/audit/2026-08-07/*` files. +- Use explicit staging paths and inspect every staged diff; never use `git add -A`. + +--- + +### Task 1: Migrate the repository release identity to Beta 5 + +**Files:** +- Modify: `scripts/test_check_release_workflow.py` +- Modify: `.github/workflows/release.yml` +- Modify: `scripts/check_release_workflow.py` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `web/package.json` +- Modify: `src-tauri/tauri.conf.json` +- Create: `docs/releases/1.0.0-beta.5.md` +- Modify: `README.md` +- Modify: `docs/INDEX.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- repository-wide Cargo/Tauri/Web `1.0.0-beta.5`, WiX `1.0.0.5`, release note `docs/releases/1.0.0-beta.5.md`. +- historical/generic updater fixtures remain historical; only current-release contracts change. + +- [ ] **Step 1: Write the failing metadata contract** + + Update current-release test fixtures to require Beta 5 identities, note path, tag trigger, artifact prefixes, and WiX fourth component. Add mutations for each stale Beta 4 value. + +- [ ] **Step 2: Verify RED** + + Run `python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py'`. Expected: current production metadata/workflow remains Beta 4. + +- [ ] **Step 3: Apply the minimal identity migration** + + Update root workspace version, Web package, Tauri version/WiX version, release workflow/current validator literals, and approved validation/job digests derived from final YAML. Regenerate lockfiles with Cargo/pnpm rather than hand-editing resolved entries. + +- [ ] **Step 4: Write Beta 5 release notes** + + Cover persistent authenticated MCP, ordered Agent/tool conversation, clear-timeline PNG, Motion Studio, settings/library/Home/title-bar improvements, licenses, platform signing limitations, updater behavior, and rollback. Update current links without rewriting Beta 4 history. + +- [ ] **Step 5: Verify GREEN** + + Run release workflow tests/validator, strict YAML parser, actionlint, updater manifest/attestation tests, Windows workflow contract tests, and version searches that distinguish intentional historical references. + +- [ ] **Step 6: Commit release identity** + + Commit as `chore(release): prepare v1.0.0-beta.5 metadata`. + +### Task 2: Run the complete local release gate matrix + +**Files:** +- Modify: `docs/audit/2026-08-13/beta5-release-candidate.md` + +- [ ] **Step 1: Run focused product gates fresh** + + Re-run external MCP security/restart, Agent ordering/session/PNG, Motion document/Chromium/FFmpeg, appearance/storage, Library/Home thumbnail, and title-bar measurement suites from the four implementation plans. No cached historical receipt substitutes for a fresh command. + +- [ ] **Step 2: Run full Rust gates** + + Run `cargo test --workspace --no-fail-fast`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, required feature-gated integration suites, and the repository security audit command documented in the release workflow. + +- [ ] **Step 3: Run full Web and dependency gates** + + Run `pnpm -C web test`, `pnpm -C web build`, lockfile/install integrity, license inventory, dependency audit commands used by CI, and ensure all CodeMirror notices match installed versions. + +- [ ] **Step 4: Run release/integrity gates** + + Run all Python release tests, workflow validator, actionlint, updater tests, `git diff --check`, generated-file checks, and a case-sensitive search for secrets/tokens/private keys in tracked/staged output. + +- [ ] **Step 5: Record exact results** + + Write command, exit code, test count/skip reason, timestamp, platform, artifact path/hash, and any accepted signing limitation to the candidate audit. Mark a gate not run or failed accurately; do not infer success. + +### Task 3: Build and visually verify the final packaged candidate + +**Files:** +- Modify: `docs/audit/2026-08-13/beta5-release-candidate.md` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-home.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-library.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-motion.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-settings.png` + +- [ ] **Step 1: Build the release application and packages** + + Provision checksum-pinned FFmpeg/Chromium sidecars through repository scripts, then run the platform release build with the same feature set/environment as the workflow. Record `.app`/DMG paths, sizes, and SHA-256. + +- [ ] **Step 2: Run the acceptance matrix in the packaged `.app`** + + Verify external MCP across app restart/revoke, model clear animation, standard/compact switching, Library return placement, Home card content, continuous Agent/tool ordering, clear-timeline PNG, Motion real text/edit/preview/publish/reopen, and title-bar alignment. + +- [ ] **Step 3: Measure rather than eyeball geometry** + + Run the title-bar image measurement script on each applicable screenshot and assert at most 1 CSS px center deviation. Record 16:9 project/Motion preview pixel bounds and disclosure animation/reduced-motion observations. + +- [ ] **Step 4: Re-run affected checks after any correction** + + Any packaged-app defect returns to the relevant TDD task. Rebuild from a clean output directory and repeat the full affected acceptance slice before updating the receipt. + +### Task 4: Obtain final code, security, and release review + +**Files:** +- Review the complete Beta 5 diff and candidate audit; no new product file is expected unless findings require a fix. + +- [ ] **Step 1: Request independent code review** + + Review Rust/TypeScript correctness, compatibility, cancellation, persistence, stale-event handling, atomicity, and test coverage. Resolve every P0–P2 finding and rerun affected tests. + +- [ ] **Step 2: Request independent security review** + + Review MCP auth/loopback/Host/Origin/logging/keychain/revoke, Motion path/network/script confinement, token/image bounds, process cleanup, release secrets, updater trust, and dependency licenses. Resolve every P0–P2 finding and rerun the full security slice. + +- [ ] **Step 3: Inspect the final diff and worktree** + + Check changed file inventory, no debug code, no accidental binaries/build output, no unrelated formatting, no plaintext credential, required docs/screenshots present, and all user-owned audit assets still unstaged. + +- [ ] **Step 4: Freeze and commit the candidate** + + Stage explicit reviewed paths, inspect `git diff --cached --stat` and `git diff --cached`, run a staged secret scan and `git diff --cached --check`, then commit remaining candidate evidence as `chore(release): freeze v1.0.0-beta.5 candidate`. + +### Task 5: Merge Beta 5 through GitHub CI + +- [ ] **Step 1: Verify remote preconditions** + + Authenticate `gh`, fetch `origin/main` and tags, confirm no Beta 5 tag/release exists, confirm required signing secret names exist without reading values, and rebase/merge current remote main only through a reviewed non-destructive integration if it advanced. + +- [ ] **Step 2: Push the release branch** + + Push `release/v1.0.0-beta.5` with tracking and no force. Confirm the remote branch SHA equals the local candidate SHA. + +- [ ] **Step 3: Open a ready PR** + + Create a PR to `main` containing scope, risk boundaries, exact local evidence, external MCP threat model, new licenses, signing limitations, and rollback. Do not publish the tag from the branch. + +- [ ] **Step 4: Wait for all required checks** + + Monitor every branch-protection check to a terminal state. Diagnose any failure from logs, patch on the release branch, rerun local affected/full gates, push normally, and wait again. + +- [ ] **Step 5: Merge and validate merged main** + + Merge with the repository's required merge method. Wait for the merge commit's own `main` CI to pass and record immutable `MAIN_SHA`. If main advances again before tagging, repeat the boundary check rather than tagging an unverified SHA. + +### Task 6: Tag, publish, and verify the immutable prerelease + +- [ ] **Step 1: Recheck the release boundary** + + Confirm remote `main == MAIN_SHA`, merged metadata/note are Beta 5, signing secret names exist, all required main checks are green, and neither tag nor release exists. + +- [ ] **Step 2: Create and push the annotated tag** + + Run `git tag -a v1.0.0-beta.5 MAIN_SHA -m 'OpenTake 1.0.0-beta.5'` and push only `refs/tags/v1.0.0-beta.5`. Never move it after this step. + +- [ ] **Step 3: Monitor the tag-triggered workflow** + + Wait for validate, quality, macOS, Windows, and publish jobs to complete. A code failure requires a higher version; an external infrastructure retry may use workflow dispatch only if it leaves tag/SHA/source unchanged and the workflow contract permits it. + +- [ ] **Step 4: Verify the public prerelease** + + Confirm tag target SHA, prerelease/draft/latest flags, release-note content, exactly seventeen expected asset names, GitHub digests, `SHA256SUMS`, updater platform keys/URLs, Minisign companions, attestations, content types, and non-zero bounded downloads. + +- [ ] **Step 5: Verify updater discovery from Beta 4** + + Against the production endpoint, run the updater check from a clean Beta 4 installation/configuration, verify it selects Beta 5, validates signature/hash/size/attestation, and does not install an unexpected platform artifact. + +- [ ] **Step 6: Publish the final receipt** + + Record release URL, workflow URL, `MAIN_SHA`, tag object SHA, asset digests/sizes, updater result, release limitations, and rollback to Beta 4 in the audit and final user report. From 3576548bb3ee745b4e46a8d94126a94360a9fca4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 18:43:16 +0800 Subject: [PATCH 03/61] feat(mcp): add keychain-backed pairing catalog --- Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/external_mcp.rs | 536 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/secret.rs | 58 ++++ 5 files changed, 597 insertions(+) create mode 100644 src-tauri/src/external_mcp.rs diff --git a/Cargo.lock b/Cargo.lock index 48f0737a..3e6fe447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3884,6 +3884,7 @@ dependencies = [ "crossbeam-channel", "futures", "futures-util", + "getrandom 0.3.4", "glob", "http-range", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7dee8519..c0991fa9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ same-file = "1.0.6" cap-std = "4.0.2" cap-fs-ext = "4.0.2" sha2 = "0.10" +getrandom = "0.3" uuid = { workspace = true } tempfile = "3" # Optional account scaffold: verify a token only against a user-configured diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs new file mode 100644 index 00000000..4994f6e5 --- /dev/null +++ b/src-tauri/src/external_mcp.rs @@ -0,0 +1,536 @@ +#![allow(dead_code)] // Task 1 establishes the catalog before listener/UI tasks consume it. + +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::secret::McpSecretStore; + +const CATALOG_VERSION: u32 = 1; +const CATALOG_DIRECTORY: &str = "external-mcp"; +const CATALOG_FILE: &str = "clients.json"; +const EXTERNAL_MCP_ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; +const MAX_CLIENT_NAME_CHARS: usize = 128; +const TOKEN_BYTES: usize = 32; +const TOKEN_DIGEST_HEX_CHARS: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct ExternalMcpClientSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) token_digest: String, + pub(crate) created_at: i64, + pub(crate) last_used_at: Option, + pub(crate) revoked_at: Option, +} + +impl Default for ExternalMcpClientSummary { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + token_digest: String::new(), + created_at: 0, + last_used_at: None, + revoked_at: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExternalMcpPairingReceipt { + pub(crate) client: ExternalMcpClientSummary, + pub(crate) endpoint: String, + pub(crate) bearer_token: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExternalMcpCredential { + pub(crate) client_id: String, + pub(crate) bearer_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct PersistedCatalog { + version: u32, + clients: Vec, +} + +impl Default for PersistedCatalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + clients: Vec::new(), + } + } +} + +pub(crate) struct ExternalMcpCatalog { + root: PathBuf, + clients: Vec, + secrets: Arc, + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool, + #[cfg(test)] + fail_next_parent_sync: std::sync::atomic::AtomicBool, +} + +impl ExternalMcpCatalog { + pub(crate) fn load( + app_data_dir: &Path, + secrets: Arc, + ) -> Result { + let root = app_data_dir.join(CATALOG_DIRECTORY); + let path = root.join(CATALOG_FILE); + let clients = match fs::read(&path) { + Ok(bytes) => { + let persisted: PersistedCatalog = serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP catalog: {error}"))?; + if persisted.version != CATALOG_VERSION { + return Err("unsupported external MCP catalog version".to_string()); + } + for client in &persisted.clients { + validate_client(client)?; + } + persisted.clients + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(format!("read external MCP catalog: {error}")), + }; + Ok(Self { + root, + clients, + secrets, + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + fail_next_parent_sync: std::sync::atomic::AtomicBool::new(false), + }) + } + + pub(crate) fn clients(&self) -> &[ExternalMcpClientSummary] { + &self.clients + } + + pub(crate) fn metadata_path(&self) -> PathBuf { + self.root.join(CATALOG_FILE) + } + + pub(crate) fn pair(&mut self, name: &str) -> Result { + let name = validate_name(name)?; + let token = generate_token()?; + let client = ExternalMcpClientSummary { + id: uuid::Uuid::new_v4().to_string(), + name, + token_digest: token_digest(&token), + created_at: unix_timestamp()?, + last_used_at: None, + revoked_at: None, + }; + let account = secret_account(&client.id); + self.secrets.save_mcp_secret(&account, &token)?; + self.clients.push(client.clone()); + if let Err(error) = self.persist() { + self.clients.pop(); + if let Err(rollback_error) = self.secrets.delete_mcp_secret(&account) { + return Err(format!( + "{error}; external MCP credential cleanup failed: {rollback_error}" + )); + } + return Err(error); + } + Ok(receipt(client, token)) + } + + pub(crate) fn regenerate( + &mut self, + client_id: &str, + ) -> Result { + let index = self.client_index(client_id)?; + if self.clients[index].revoked_at.is_some() { + return Err("external MCP client is revoked".to_string()); + } + let account = secret_account(client_id); + let previous_token = self + .secrets + .load_mcp_secret(&account)? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + let token = generate_token()?; + self.secrets.save_mcp_secret(&account, &token)?; + let previous_client = self.clients[index].clone(); + self.clients[index].token_digest = token_digest(&token); + if let Err(error) = self.persist() { + self.clients[index] = previous_client; + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &previous_token) { + return Err(format!( + "{error}; external MCP credential rollback failed: {rollback_error}" + )); + } + return Err(error); + } + Ok(receipt(self.clients[index].clone(), token)) + } + + pub(crate) fn revoke(&mut self, client_id: &str) -> Result<(), String> { + let index = self.client_index(client_id)?; + if self.clients[index].revoked_at.is_some() { + return Ok(()); + } + let previous_client = self.clients[index].clone(); + let account = secret_account(client_id); + let previous_token = self.secrets.load_mcp_secret(&account)?; + self.secrets.delete_mcp_secret(&account)?; + self.clients[index].revoked_at = Some(unix_timestamp()?); + if let Err(error) = self.persist() { + self.clients[index] = previous_client; + if let Some(token) = previous_token { + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &token) { + return Err(format!( + "{error}; external MCP credential rollback failed: {rollback_error}" + )); + } + } + return Err(error); + } + Ok(()) + } + + pub(crate) fn active_credentials(&self) -> Result, String> { + self.clients + .iter() + .filter(|client| client.revoked_at.is_none()) + .map(|client| { + let bearer_token = self + .secrets + .load_mcp_secret(&secret_account(&client.id))? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + if bearer_token.len() != TOKEN_BYTES * 2 + || !bearer_token.bytes().all(|byte| byte.is_ascii_hexdigit()) + || token_digest(&bearer_token) != client.token_digest + { + return Err("external MCP client credential is invalid".to_string()); + } + Ok(ExternalMcpCredential { + client_id: client.id.clone(), + bearer_token, + }) + }) + .collect() + } + + fn client_index(&self, client_id: &str) -> Result { + self.clients + .iter() + .position(|client| client.id == client_id) + .ok_or_else(|| "external MCP client not found".to_string()) + } + + fn persist(&self) -> Result<(), String> { + fs::create_dir_all(&self.root) + .map_err(|error| format!("create external MCP catalog directory: {error}"))?; + let bytes = serde_json::to_vec_pretty(&PersistedCatalog { + version: CATALOG_VERSION, + clients: self.clients.clone(), + }) + .map_err(|error| format!("encode external MCP catalog: {error}"))?; + let temp = self + .root + .join(format!(".clients.{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| format!("create external MCP catalog staging file: {error}"))?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("write external MCP catalog staging file: {error}"))?; + self.rename_atomically(&temp, &self.metadata_path())?; + // A successful rename has already made the new catalog authoritative. + // Do not roll back metadata/keychain state if the best-effort parent + // durability barrier fails after that point. + let _ = self.sync_parent_directory(); + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temp); + } + result + } + + #[cfg(test)] + fn fail_next_atomic_rename_for_test(&self) { + self.fail_next_rename + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + fn fail_next_parent_sync_for_test(&self) { + self.fail_next_parent_sync + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + fn rename_atomically(&self, temp: &Path, destination: &Path) -> Result<(), String> { + #[cfg(test)] + if self + .fail_next_rename + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err("publish external MCP catalog: injected rename failure".to_string()); + } + fs::rename(temp, destination) + .map_err(|error| format!("publish external MCP catalog: {error}")) + } + + fn sync_parent_directory(&self) -> std::io::Result<()> { + #[cfg(test)] + if self + .fail_next_parent_sync + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(std::io::Error::other("injected parent sync failure")); + } + sync_parent_directory(&self.root) + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { + fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { + // Windows does not support opening a directory as a synchronizable File. + // `rename` remains atomic; the OS owns the corresponding directory flush. + Ok(()) +} + +fn receipt(client: ExternalMcpClientSummary, bearer_token: String) -> ExternalMcpPairingReceipt { + ExternalMcpPairingReceipt { + client, + endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), + bearer_token, + } +} + +fn validate_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() + || name.chars().count() > MAX_CLIENT_NAME_CHARS + || name.chars().any(char::is_control) + { + return Err( + "external MCP client name must contain 1 to 128 non-control characters".to_string(), + ); + } + Ok(name.to_string()) +} + +fn validate_client(client: &ExternalMcpClientSummary) -> Result<(), String> { + uuid::Uuid::parse_str(&client.id) + .map_err(|_| "external MCP catalog has an invalid client id".to_string())?; + validate_name(&client.name)?; + if client.token_digest.len() != TOKEN_DIGEST_HEX_CHARS + || !client + .token_digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || client.created_at < 0 + || client.last_used_at.is_some_and(|timestamp| timestamp < 0) + || client.revoked_at.is_some_and(|timestamp| timestamp < 0) + { + return Err("external MCP catalog has invalid client metadata".to_string()); + } + Ok(()) +} + +fn generate_token() -> Result { + let mut bytes = [0_u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes) + .map_err(|error| format!("generate external MCP credential: {error}"))?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn token_digest(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + digest + .iter() + .take(TOKEN_DIGEST_HEX_CHARS / 2) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn secret_account(client_id: &str) -> String { + format!("external-mcp:{client_id}") +} + +fn unix_timestamp() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("read external MCP clock: {error}")) + .and_then(|duration| { + i64::try_from(duration.as_secs()) + .map_err(|_| "external MCP clock is out of range".to_string()) + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::secret::{McpSecretStore, MemoryMcpSecretStore}; + + fn catalog_root() -> tempfile::TempDir { + tempfile::tempdir().expect("create temporary application data directory") + } + + fn load_catalog( + root: &tempfile::TempDir, + secrets: Arc, + ) -> ExternalMcpCatalog { + ExternalMcpCatalog::load(root.path(), secrets) + .expect("load catalog against the in-memory secret store") + } + + #[test] + fn catalog_pair_creates_unique_client_ids_and_32_byte_tokens() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + let second = catalog.pair("Cursor").expect("pair second client"); + assert_ne!(first.client.id, second.client.id); + assert_ne!(first.bearer_token, second.bearer_token); + for token in [&first.bearer_token, &second.bearer_token] { + assert_eq!(token.len(), 64, "token is 32 bytes encoded as hexadecimal"); + assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + assert_eq!(first.endpoint, EXTERNAL_MCP_ENDPOINT); + } + + #[test] + fn catalog_persisted_json_omits_the_bearer_token() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let receipt = catalog.pair("Claude Desktop").expect("pair client"); + let serialized = std::fs::read_to_string(catalog.metadata_path()) + .expect("read persisted client metadata"); + assert!(!serialized.contains(&receipt.bearer_token)); + assert!(!serialized.contains("bearer_token")); + assert!(serialized.contains(&receipt.client.token_digest)); + } + + #[test] + fn catalog_restart_reloads_metadata_and_retrieves_secret_from_fake_store() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let receipt = { + let mut catalog = load_catalog(&root, secrets.clone()); + catalog.pair("Claude Desktop").expect("pair client") + }; + let catalog = load_catalog(&root, secrets); + let credentials = catalog + .active_credentials() + .expect("load active credentials"); + assert_eq!(catalog.clients(), &[receipt.client.clone()]); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].client_id, receipt.client.id); + assert_eq!(credentials[0].bearer_token, receipt.bearer_token); + } + + #[test] + fn catalog_regeneration_invalidates_the_previous_token() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair client"); + let regenerated = catalog + .regenerate(&first.client.id) + .expect("regenerate credential"); + let credentials = catalog + .active_credentials() + .expect("load active credentials"); + assert_eq!(regenerated.client.id, first.client.id); + assert_ne!(regenerated.bearer_token, first.bearer_token); + assert_ne!(regenerated.client.token_digest, first.client.token_digest); + assert_eq!(credentials[0].bearer_token, regenerated.bearer_token); + assert_ne!(credentials[0].bearer_token, first.bearer_token); + } + + #[test] + fn catalog_revoke_removes_the_secret() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let receipt = catalog.pair("Claude Desktop").expect("pair client"); + catalog.revoke(&receipt.client.id).expect("revoke client"); + assert!(catalog + .active_credentials() + .expect("load active credentials") + .is_empty()); + assert_eq!( + secrets + .load_mcp_secret(&secret_account(&receipt.client.id)) + .expect("read in-memory secret"), + None + ); + assert!(catalog.clients()[0].revoked_at.is_some()); + } + + #[test] + fn catalog_duplicate_display_names_remain_distinguishable() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + let second = catalog.pair("Claude Desktop").expect("pair second client"); + assert_eq!(first.client.name, second.client.name); + assert_ne!(first.client.id, second.client.id); + assert_eq!(catalog.clients().len(), 2); + } + + #[test] + fn catalog_failed_atomic_rename_leaves_the_previous_catalog_readable() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + catalog.fail_next_atomic_rename_for_test(); + assert!(catalog.pair("Cursor").is_err()); + let reloaded = load_catalog(&root, secrets); + assert_eq!(reloaded.clients(), &[first.client]); + } + + #[test] + fn catalog_persists_after_the_post_rename_parent_sync_fails() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + + catalog.fail_next_parent_sync_for_test(); + let receipt = catalog.pair("Claude Desktop").expect("pair client"); + + let reloaded = load_catalog(&root, secrets); + assert_eq!(reloaded.clients(), &[receipt.client]); + assert_eq!( + reloaded + .active_credentials() + .expect("load active credentials")[0] + .bearer_token, + receipt.bearer_token + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 140da9db..4788395f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod commands; // drive the export orchestrator (`export::run_export`) against the library // target. The Tauri command itself is registered below like the other modules. pub mod export; +mod external_mcp; pub mod feedback; mod fs_availability; mod generation; diff --git a/src-tauri/src/secret.rs b/src-tauri/src/secret.rs index c065793c..866c4271 100644 --- a/src-tauri/src/secret.rs +++ b/src-tauri/src/secret.rs @@ -14,6 +14,64 @@ use tauri::State; use opentake_gen::{KeyStore, KeyringStore}; +/// Narrow secret storage boundary for persistent external MCP credentials. +/// Catalog metadata deliberately contains only a token digest; this boundary +/// keeps the token itself in the existing OpenTake keychain service. +#[allow(dead_code)] // Task 1 adds the seam before Task 4 wires the Tauri commands. +pub(crate) trait McpSecretStore: Send + Sync { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String>; + fn load_mcp_secret(&self, account: &str) -> Result, String>; + fn delete_mcp_secret(&self, account: &str) -> Result<(), String>; +} + +impl McpSecretStore for KeyringStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.save(account, value).map_err(|error| error.to_string()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + self.load(account).map_err(|error| error.to_string()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.delete(account).map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct MemoryMcpSecretStore { + secrets: std::sync::Mutex>, +} + +#[cfg(test)] +impl McpSecretStore for MemoryMcpSecretStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .insert(account.to_owned(), value.to_owned()); + Ok(()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + Ok(self + .secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .get(account) + .cloned()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .remove(account); + Ok(()) + } +} + /// Masked status of a provider's stored key. `has_key` drives the UI; `masked` /// is the bullet-masked form (empty when there is no key). #[derive(Debug, Serialize)] From df7820c36aee48dad4fdf89d744c468ca9acd908 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 18:59:41 +0800 Subject: [PATCH 04/61] fix(mcp): fail closed on catalog durability errors --- src-tauri/src/external_mcp.rs | 531 ++++++++++++++++++++++++++-------- 1 file changed, 407 insertions(+), 124 deletions(-) diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 4994f6e5..9544adbd 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -16,6 +16,7 @@ use crate::secret::McpSecretStore; const CATALOG_VERSION: u32 = 1; const CATALOG_DIRECTORY: &str = "external-mcp"; const CATALOG_FILE: &str = "clients.json"; +const PENDING_FILE: &str = "clients.pending.json"; const EXTERNAL_MCP_ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; const MAX_CLIENT_NAME_CHARS: usize = 128; const TOKEN_BYTES: usize = 32; @@ -45,7 +46,7 @@ impl Default for ExternalMcpClientSummary { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ExternalMcpPairingReceipt { pub(crate) client: ExternalMcpClientSummary, @@ -53,19 +54,45 @@ pub(crate) struct ExternalMcpPairingReceipt { pub(crate) bearer_token: String, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ExternalMcpCredential { - pub(crate) client_id: String, - pub(crate) bearer_token: String, +impl std::fmt::Debug for ExternalMcpPairingReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalMcpPairingReceipt") + .field("client", &self.client) + .field("endpoint", &self.endpoint) + .field("bearer_token", &"") + .finish() + } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] struct PersistedCatalog { version: u32, clients: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum PendingSecretState { + Present { token_digest: String }, + Absent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PendingCatalogCommit { + client_id: String, + target: PersistedCatalog, + secret_state: PendingSecretState, +} + +#[derive(Debug)] +struct PublishError { + error: String, + published: bool, +} + impl Default for PersistedCatalog { fn default() -> Self { Self { @@ -79,10 +106,11 @@ pub(crate) struct ExternalMcpCatalog { root: PathBuf, clients: Vec, secrets: Arc, + pending: bool, #[cfg(test)] fail_next_rename: std::sync::atomic::AtomicBool, #[cfg(test)] - fail_next_parent_sync: std::sync::atomic::AtomicBool, + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize, } impl ExternalMcpCatalog { @@ -92,30 +120,19 @@ impl ExternalMcpCatalog { ) -> Result { let root = app_data_dir.join(CATALOG_DIRECTORY); let path = root.join(CATALOG_FILE); - let clients = match fs::read(&path) { - Ok(bytes) => { - let persisted: PersistedCatalog = serde_json::from_slice(&bytes) - .map_err(|error| format!("read external MCP catalog: {error}"))?; - if persisted.version != CATALOG_VERSION { - return Err("unsupported external MCP catalog version".to_string()); - } - for client in &persisted.clients { - validate_client(client)?; - } - persisted.clients - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(error) => return Err(format!("read external MCP catalog: {error}")), - }; - Ok(Self { + let clients = read_catalog(&path)?; + let mut catalog = Self { root, clients, secrets, + pending: false, #[cfg(test)] fail_next_rename: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] - fail_next_parent_sync: std::sync::atomic::AtomicBool::new(false), - }) + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + }; + catalog.recover_pending_commit()?; + Ok(catalog) } pub(crate) fn clients(&self) -> &[ExternalMcpClientSummary] { @@ -126,7 +143,35 @@ impl ExternalMcpCatalog { self.root.join(CATALOG_FILE) } + fn pending_path(&self) -> PathBuf { + self.root.join(PENDING_FILE) + } + + fn recover_pending_commit(&mut self) -> Result<(), String> { + let path = self.pending_path(); + let pending = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|error| format!("read external MCP pending commit: {error}"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("read external MCP pending commit: {error}")), + }; + validate_pending(&pending)?; + let secret = self + .secrets + .load_mcp_secret(&secret_account(&pending.client_id))?; + if pending_matches_secret(&pending.secret_state, secret.as_deref()) { + self.publish_clients(&pending.target.clients) + .map_err(|error| error.error)?; + self.clients = pending.target.clients; + } else if self.clients == pending.target.clients { + return Err("external MCP pending commit has inconsistent secret state".to_string()); + } + self.clear_pending()?; + Ok(()) + } + pub(crate) fn pair(&mut self, name: &str) -> Result { + self.ensure_ready()?; let name = validate_name(name)?; let token = generate_token()?; let client = ExternalMcpClientSummary { @@ -138,17 +183,44 @@ impl ExternalMcpCatalog { revoked_at: None, }; let account = secret_account(&client.id); - self.secrets.save_mcp_secret(&account, &token)?; - self.clients.push(client.clone()); - if let Err(error) = self.persist() { - self.clients.pop(); - if let Err(rollback_error) = self.secrets.delete_mcp_secret(&account) { - return Err(format!( - "{error}; external MCP credential cleanup failed: {rollback_error}" - )); + let mut next = self.clients.clone(); + next.push(client.clone()); + if let Err(error) = self.prepare_pending( + &client.id, + &next, + PendingSecretState::Present { + token_digest: client.token_digest.clone(), + }, + ) { + if self.pending { + return Err(error); } + self.clear_pending()?; return Err(error); } + self.pending = true; + self.secrets.save_mcp_secret(&account, &token)?; + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Err(rollback_error) = self.secrets.delete_mcp_secret(&account) { + return Err(format!( + "{}; external MCP credential cleanup failed: {rollback_error}", + error.error + )); + } + self.clear_pending()?; + return Err(error.error); + } + } Ok(receipt(client, token)) } @@ -156,6 +228,7 @@ impl ExternalMcpCatalog { &mut self, client_id: &str, ) -> Result { + self.ensure_ready()?; let index = self.client_index(client_id)?; if self.clients[index].revoked_at.is_some() { return Err("external MCP client is revoked".to_string()); @@ -166,66 +239,114 @@ impl ExternalMcpCatalog { .load_mcp_secret(&account)? .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; let token = generate_token()?; - self.secrets.save_mcp_secret(&account, &token)?; - let previous_client = self.clients[index].clone(); - self.clients[index].token_digest = token_digest(&token); - if let Err(error) = self.persist() { - self.clients[index] = previous_client; - if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &previous_token) { - return Err(format!( - "{error}; external MCP credential rollback failed: {rollback_error}" - )); + let mut next = self.clients.clone(); + next[index].token_digest = token_digest(&token); + if let Err(error) = self.prepare_pending( + client_id, + &next, + PendingSecretState::Present { + token_digest: next[index].token_digest.clone(), + }, + ) { + if self.pending { + return Err(error); } + self.clear_pending()?; return Err(error); } + self.pending = true; + self.secrets.save_mcp_secret(&account, &token)?; + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &previous_token) + { + return Err(format!( + "{}; external MCP credential rollback failed: {rollback_error}", + error.error + )); + } + self.clear_pending()?; + return Err(error.error); + } + } Ok(receipt(self.clients[index].clone(), token)) } pub(crate) fn revoke(&mut self, client_id: &str) -> Result<(), String> { + self.ensure_ready()?; let index = self.client_index(client_id)?; if self.clients[index].revoked_at.is_some() { return Ok(()); } - let previous_client = self.clients[index].clone(); let account = secret_account(client_id); let previous_token = self.secrets.load_mcp_secret(&account)?; + let revoked_at = unix_timestamp()?; + let mut next = self.clients.clone(); + next[index].revoked_at = Some(revoked_at); + if let Err(error) = self.prepare_pending(client_id, &next, PendingSecretState::Absent) { + if self.pending { + return Err(error); + } + self.clear_pending()?; + return Err(error); + } + self.pending = true; self.secrets.delete_mcp_secret(&account)?; - self.clients[index].revoked_at = Some(unix_timestamp()?); - if let Err(error) = self.persist() { - self.clients[index] = previous_client; - if let Some(token) = previous_token { - if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &token) { - return Err(format!( - "{error}; external MCP credential rollback failed: {rollback_error}" - )); + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Some(token) = previous_token { + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &token) { + return Err(format!( + "{}; external MCP credential rollback failed: {rollback_error}", + error.error + )); + } } + self.clear_pending()?; + return Err(error.error); } - return Err(error); } Ok(()) } - pub(crate) fn active_credentials(&self) -> Result, String> { - self.clients + pub(crate) fn verify_candidate(&self, candidate: &str) -> Result, String> { + self.ensure_ready()?; + let mut match_id = None; + for client in self + .clients .iter() .filter(|client| client.revoked_at.is_none()) - .map(|client| { - let bearer_token = self - .secrets - .load_mcp_secret(&secret_account(&client.id))? - .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; - if bearer_token.len() != TOKEN_BYTES * 2 - || !bearer_token.bytes().all(|byte| byte.is_ascii_hexdigit()) - || token_digest(&bearer_token) != client.token_digest - { - return Err("external MCP client credential is invalid".to_string()); - } - Ok(ExternalMcpCredential { - client_id: client.id.clone(), - bearer_token, - }) - }) - .collect() + { + let secret = self + .secrets + .load_mcp_secret(&secret_account(&client.id))? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + if token_digest(&secret) != client.token_digest { + return Err("external MCP client credential is invalid".to_string()); + } + if constant_time_eq(secret.as_bytes(), candidate.as_bytes()) { + match_id = Some(client.id.clone()); + } + } + Ok(match_id) } fn client_index(&self, client_id: &str) -> Result { @@ -235,31 +356,107 @@ impl ExternalMcpCatalog { .ok_or_else(|| "external MCP client not found".to_string()) } - fn persist(&self) -> Result<(), String> { - fs::create_dir_all(&self.root) - .map_err(|error| format!("create external MCP catalog directory: {error}"))?; - let bytes = serde_json::to_vec_pretty(&PersistedCatalog { - version: CATALOG_VERSION, - clients: self.clients.clone(), - }) - .map_err(|error| format!("encode external MCP catalog: {error}"))?; + fn ensure_ready(&self) -> Result<(), String> { + if self.pending { + Err("external MCP catalog recovery is pending".to_string()) + } else { + Ok(()) + } + } + + fn prepare_pending( + &mut self, + client_id: &str, + target_clients: &[ExternalMcpClientSummary], + secret_state: PendingSecretState, + ) -> Result<(), String> { + match self.write_json_atomically( + &self.pending_path(), + &PendingCatalogCommit { + client_id: client_id.to_string(), + target: PersistedCatalog { + version: CATALOG_VERSION, + clients: target_clients.to_vec(), + }, + secret_state, + }, + ) { + Ok(()) => Ok(()), + Err(error) => { + self.pending = error.published; + Err(error.error) + } + } + } + + fn clear_pending(&mut self) -> Result<(), String> { + fs::remove_file(self.pending_path()) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| format!("clear external MCP pending commit: {error}"))?; + if let Err(error) = self.sync_parent_directory() { + self.pending = true; + return Err(format!("sync external MCP catalog directory: {error}")); + } + self.pending = false; + Ok(()) + } + + fn publish_clients(&self, clients: &[ExternalMcpClientSummary]) -> Result<(), PublishError> { + self.write_json_atomically( + &self.metadata_path(), + &PersistedCatalog { + version: CATALOG_VERSION, + clients: clients.to_vec(), + }, + ) + } + + fn write_json_atomically( + &self, + destination: &Path, + value: &T, + ) -> Result<(), PublishError> { + fs::create_dir_all(&self.root).map_err(|error| PublishError { + error: format!("create external MCP catalog directory: {error}"), + published: false, + })?; + let bytes = serde_json::to_vec_pretty(value).map_err(|error| PublishError { + error: format!("encode external MCP catalog: {error}"), + published: false, + })?; let temp = self .root .join(format!(".clients.{}.tmp", uuid::Uuid::new_v4())); - let result = (|| { + let result: Result<(), PublishError> = (|| { let mut file = OpenOptions::new() .write(true) .create_new(true) .open(&temp) - .map_err(|error| format!("create external MCP catalog staging file: {error}"))?; + .map_err(|error| PublishError { + error: format!("create external MCP catalog staging file: {error}"), + published: false, + })?; file.write_all(&bytes) .and_then(|()| file.sync_all()) - .map_err(|error| format!("write external MCP catalog staging file: {error}"))?; - self.rename_atomically(&temp, &self.metadata_path())?; - // A successful rename has already made the new catalog authoritative. - // Do not roll back metadata/keychain state if the best-effort parent - // durability barrier fails after that point. - let _ = self.sync_parent_directory(); + .map_err(|error| PublishError { + error: format!("write external MCP catalog staging file: {error}"), + published: false, + })?; + self.rename_atomically(&temp, destination) + .map_err(|error| PublishError { + error, + published: false, + })?; + self.sync_parent_directory().map_err(|error| PublishError { + error: format!("sync external MCP catalog directory: {error}"), + published: true, + })?; Ok(()) })(); if result.is_err() { @@ -275,9 +472,10 @@ impl ExternalMcpCatalog { } #[cfg(test)] - fn fail_next_parent_sync_for_test(&self) { - self.fail_next_parent_sync - .store(true, std::sync::atomic::Ordering::SeqCst); + fn fail_parent_sync_during_publish_for_test(&self) { + // One sync seals the pending journal; the second seals clients.json. + self.fail_parent_sync_on_call + .store(2, std::sync::atomic::Ordering::SeqCst); } fn rename_atomically(&self, temp: &Path, destination: &Path) -> Result<(), String> { @@ -294,11 +492,17 @@ impl ExternalMcpCatalog { fn sync_parent_directory(&self) -> std::io::Result<()> { #[cfg(test)] - if self - .fail_next_parent_sync - .swap(false, std::sync::atomic::Ordering::SeqCst) { - return Err(std::io::Error::other("injected parent sync failure")); + let remaining = self + .fail_parent_sync_on_call + .load(std::sync::atomic::Ordering::SeqCst); + if remaining != 0 { + self.fail_parent_sync_on_call + .store(remaining - 1, std::sync::atomic::Ordering::SeqCst); + if remaining == 1 { + return Err(std::io::Error::other("injected parent sync failure")); + } + } } sync_parent_directory(&self.root) } @@ -316,6 +520,70 @@ fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { Ok(()) } +fn read_catalog(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => { + let persisted: PersistedCatalog = serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP catalog: {error}"))?; + if persisted.version != CATALOG_VERSION { + return Err("unsupported external MCP catalog version".to_string()); + } + for client in &persisted.clients { + validate_client(client)?; + } + Ok(persisted.clients) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(format!("read external MCP catalog: {error}")), + } +} + +fn validate_pending(pending: &PendingCatalogCommit) -> Result<(), String> { + if pending.target.version != CATALOG_VERSION { + return Err("unsupported external MCP pending catalog version".to_string()); + } + let client = pending + .target + .clients + .iter() + .find(|client| client.id == pending.client_id) + .ok_or_else(|| "external MCP pending commit has no target client".to_string())?; + for candidate in &pending.target.clients { + validate_client(candidate)?; + } + match &pending.secret_state { + PendingSecretState::Present { token_digest } if token_digest == &client.token_digest => { + Ok(()) + } + PendingSecretState::Absent if client.revoked_at.is_some() => Ok(()), + _ => Err("external MCP pending commit has inconsistent target state".to_string()), + } +} + +fn pending_matches_secret(state: &PendingSecretState, secret: Option<&str>) -> bool { + match (state, secret) { + (PendingSecretState::Absent, None) => true, + ( + PendingSecretState::Present { + token_digest: digest, + }, + Some(secret), + ) => digest == &token_digest(secret), + _ => false, + } +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + let width = left.len().max(right.len()); + for index in 0..width { + let a = left.get(index).copied().unwrap_or(0); + let b = right.get(index).copied().unwrap_or(0); + difference |= usize::from(a ^ b); + } + difference == 0 +} + fn receipt(client: ExternalMcpClientSummary, bearer_token: String) -> ExternalMcpPairingReceipt { ExternalMcpPairingReceipt { client, @@ -412,12 +680,13 @@ mod tests { let first = catalog.pair("Claude Desktop").expect("pair first client"); let second = catalog.pair("Cursor").expect("pair second client"); assert_ne!(first.client.id, second.client.id); - assert_ne!(first.bearer_token, second.bearer_token); + assert_ne!(first.client.token_digest, second.client.token_digest); for token in [&first.bearer_token, &second.bearer_token] { assert_eq!(token.len(), 64, "token is 32 bytes encoded as hexadecimal"); assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit())); } assert_eq!(first.endpoint, EXTERNAL_MCP_ENDPOINT); + assert!(!format!("{first:?}").contains(&first.bearer_token)); } #[test] @@ -442,13 +711,13 @@ mod tests { catalog.pair("Claude Desktop").expect("pair client") }; let catalog = load_catalog(&root, secrets); - let credentials = catalog - .active_credentials() - .expect("load active credentials"); assert_eq!(catalog.clients(), &[receipt.client.clone()]); - assert_eq!(credentials.len(), 1); - assert_eq!(credentials[0].client_id, receipt.client.id); - assert_eq!(credentials[0].bearer_token, receipt.bearer_token); + assert_eq!( + catalog + .verify_candidate(&receipt.bearer_token) + .expect("verify stored secret"), + Some(receipt.client.id) + ); } #[test] @@ -460,14 +729,20 @@ mod tests { let regenerated = catalog .regenerate(&first.client.id) .expect("regenerate credential"); - let credentials = catalog - .active_credentials() - .expect("load active credentials"); assert_eq!(regenerated.client.id, first.client.id); - assert_ne!(regenerated.bearer_token, first.bearer_token); assert_ne!(regenerated.client.token_digest, first.client.token_digest); - assert_eq!(credentials[0].bearer_token, regenerated.bearer_token); - assert_ne!(credentials[0].bearer_token, first.bearer_token); + assert_eq!( + catalog + .verify_candidate(&first.bearer_token) + .expect("reject prior credential"), + None + ); + assert_eq!( + catalog + .verify_candidate(®enerated.bearer_token) + .expect("verify regenerated credential"), + Some(first.client.id) + ); } #[test] @@ -477,10 +752,12 @@ mod tests { let mut catalog = load_catalog(&root, secrets.clone()); let receipt = catalog.pair("Claude Desktop").expect("pair client"); catalog.revoke(&receipt.client.id).expect("revoke client"); - assert!(catalog - .active_credentials() - .expect("load active credentials") - .is_empty()); + assert_eq!( + catalog + .verify_candidate(&receipt.bearer_token) + .expect("reject revoked credential"), + None + ); assert_eq!( secrets .load_mcp_secret(&secret_account(&receipt.client.id)) @@ -510,27 +787,33 @@ mod tests { let first = catalog.pair("Claude Desktop").expect("pair first client"); catalog.fail_next_atomic_rename_for_test(); assert!(catalog.pair("Cursor").is_err()); - let reloaded = load_catalog(&root, secrets); + let reloaded = load_catalog(&root, secrets.clone()); assert_eq!(reloaded.clients(), &[first.client]); } #[test] - fn catalog_persists_after_the_post_rename_parent_sync_fails() { + fn catalog_recovers_after_the_post_rename_parent_sync_fails() { let root = catalog_root(); let secrets = Arc::new(MemoryMcpSecretStore::default()); let mut catalog = load_catalog(&root, secrets.clone()); - catalog.fail_next_parent_sync_for_test(); - let receipt = catalog.pair("Claude Desktop").expect("pair client"); + catalog.fail_parent_sync_during_publish_for_test(); + let receipt = match catalog.pair("Claude Desktop") { + Ok(_) => panic!("report sync failure"), + Err(error) => error, + }; - let reloaded = load_catalog(&root, secrets); - assert_eq!(reloaded.clients(), &[receipt.client]); - assert_eq!( - reloaded - .active_credentials() - .expect("load active credentials")[0] - .bearer_token, - receipt.bearer_token - ); + let reloaded = load_catalog(&root, secrets.clone()); + assert_eq!(reloaded.clients().len(), 1); + assert!(receipt.contains("sync external MCP catalog directory")); + assert!(reloaded + .verify_candidate("not-the-stored-token") + .expect("catalog reconciles before authorization") + .is_none()); + let stored = secrets + .load_mcp_secret(&secret_account(&reloaded.clients()[0].id)) + .expect("read recovered secret") + .expect("recovered secret exists"); + assert_eq!(token_digest(&stored), reloaded.clients()[0].token_digest); } } From 539a73437568eab1c188336284e45a230fdba1a0 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 19:12:18 +0800 Subject: [PATCH 05/61] feat(mcp): authenticate managed loopback sessions --- crates/opentake-agent/src/mcp/mod.rs | 2 + crates/opentake-agent/src/mcp/server.rs | 697 +++++++++++++++++++++++- 2 files changed, 670 insertions(+), 29 deletions(-) diff --git a/crates/opentake-agent/src/mcp/mod.rs b/crates/opentake-agent/src/mcp/mod.rs index 9da0a0d8..cce39e6f 100644 --- a/crates/opentake-agent/src/mcp/mod.rs +++ b/crates/opentake-agent/src/mcp/mod.rs @@ -20,3 +20,5 @@ mod media_catalog; pub mod motion; pub mod server; pub mod vision; + +pub use server::{AuthenticatedMcpClient, BearerAuthorizer, ManagedMcpEndpoint, ManagedMcpError}; diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index d65f95d0..aa673b6d 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -612,44 +612,93 @@ async fn localhost_guard( } } -fn bearer_token_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { +/// The external client authenticated for an MCP HTTP request. Credential +/// generations distinguish a freshly regenerated long-lived credential from a +/// prior credential for the same client identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthenticatedMcpClient { + pub client_id: Arc, + pub credential_generation: u64, +} + +/// Resolves a syntactically valid bearer candidate without retaining or +/// reporting its secret value. Implementations are responsible for comparing +/// their active credentials in constant time and returning the matching client. +pub trait BearerAuthorizer: Send + Sync { + fn authorize(&self, token: &str) -> Option; +} + +struct SingleBearerAuthorizer { + token: Arc, + client: AuthenticatedMcpClient, +} + +impl SingleBearerAuthorizer { + fn new(token: Arc) -> Self { + Self { + token, + client: AuthenticatedMcpClient { + client_id: Arc::from("ephemeral"), + credential_generation: 0, + }, + } + } +} + +impl BearerAuthorizer for SingleBearerAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + (candidate.len() == self.token.len() + && bool::from(candidate.as_bytes().ct_eq(self.token.as_bytes()))) + .then(|| self.client.clone()) + } +} + +fn bearer_candidate(headers: &axum::http::HeaderMap) -> Option<&str> { let mut values = headers.get_all(axum::http::header::AUTHORIZATION).iter(); let Some(value) = values.next() else { - return false; + return None; }; if values.next().is_some() { - return false; + return None; } let Ok(value) = value.to_str() else { - return false; + return None; }; let Some((scheme, supplied)) = value.split_once(' ') else { - return false; + return None; }; - scheme.eq_ignore_ascii_case("bearer") - && supplied.len() == expected.len() - && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes())) + (scheme.eq_ignore_ascii_case("bearer") + && !supplied.is_empty() + && !supplied.chars().any(char::is_whitespace)) + .then_some(supplied) } -/// Authenticate every route on a per-turn endpoint before any MCP session is -/// created. The fixed-size token is compared in constant time after its public -/// length and scheme have been validated. -async fn ephemeral_bearer_guard( - axum::extract::State(expected): axum::extract::State>, - request: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { +fn authentication_required() -> axum::response::Response { use axum::response::IntoResponse; - if bearer_token_matches(request.headers(), &expected) { + ( + axum::http::StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], + "OpenTake MCP authentication required", + ) + .into_response() +} + +/// Authenticate every route before any MCP session is created. This boundary +/// parses the bearer syntax once, delegates credential matching, and adds only +/// the authenticated public identity to the request extensions. +async fn bearer_authorization_guard( + axum::extract::State(authorizer): axum::extract::State>, + mut request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + if let Some(client) = + bearer_candidate(request.headers()).and_then(|token| authorizer.authorize(token)) + { + request.extensions_mut().insert(client); next.run(request).await } else { - ( - axum::http::StatusCode::UNAUTHORIZED, - [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], - "OpenTake MCP authentication required", - ) - .into_response() + authentication_required() } } @@ -934,7 +983,7 @@ fn build_gated_router_for_port( activity: Arc, shutdown: CancellationToken, expected_port: u16, - bearer_token: Option>, + authorizer: Option>, ) -> axum::Router { use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{ @@ -976,10 +1025,10 @@ fn build_gated_router_for_port( expected_port, localhost_guard, )); - match bearer_token { - Some(token) => router.layer(axum::middleware::from_fn_with_state( - token, - ephemeral_bearer_guard, + match authorizer { + Some(authorizer) => router.layer(axum::middleware::from_fn_with_state( + authorizer, + bearer_authorization_guard, )), None => router, } @@ -1084,6 +1133,130 @@ impl Drop for EphemeralMcpEndpoint { } } +#[derive(Debug, thiserror::Error)] +pub enum ManagedMcpError { + #[error("could not use the managed OpenTake MCP listener")] + Bind(#[source] std::io::Error), + #[error("the managed OpenTake MCP endpoint failed")] + Serve(#[source] std::io::Error), + #[error("the managed OpenTake MCP endpoint task failed")] + Join, +} + +/// A long-lived, externally authorized MCP endpoint. The authorizer is queried +/// for every request, so credential revocation and regeneration take effect +/// without restarting the listener. +#[must_use = "the endpoint must be shut down and awaited before release"] +pub struct ManagedMcpEndpoint { + addr: SocketAddr, + shutdown: CancellationToken, + activity: Arc, + cancel_gate: Arc, + join: Option>>, + closed: bool, +} + +impl ManagedMcpEndpoint { + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Stop admission and transport sessions. Call [`Self::wait`] afterwards to + /// wait for admitted blocking work and the listener task to finish. + pub fn shutdown(&self) { + self.activity.stop_accepting(); + self.cancel_gate.request_cancel(); + self.shutdown.cancel(); + } + + /// Complete shutdown safely after [`Self::shutdown`] has stopped admission. + pub async fn wait(mut self) -> Result<(), ManagedMcpError> { + self.shutdown(); + self.activity.wait_zero().await; + let result = match self.join.as_mut() { + Some(join) => match join.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(ManagedMcpError::Serve(error)), + Err(error) => { + tracing::error!( + target: "opentake::mcp::private", + task_cancelled = error.is_cancelled(), + task_panic = error.is_panic(), + "managed MCP listener task failed" + ); + Err(ManagedMcpError::Join) + } + }, + None => Err(ManagedMcpError::Join), + }; + self.join.take(); + self.closed = true; + result + } +} + +impl Drop for ManagedMcpEndpoint { + fn drop(&mut self) { + if self.closed { + return; + } + self.shutdown(); + if let Some(join) = self.join.take() { + join.abort(); + } + } +} + +/// Serve a long-lived externally authorized MCP endpoint on a caller-bound +/// loopback listener. Passing the listener directly makes bind behavior +/// deterministic for integration tests and lets the Tauri shell own port +/// selection without duplicating transport setup. +pub async fn bind_managed_gated_on( + listener: tokio::net::TcpListener, + dispatcher: Arc, + registry: Arc>, + gate: Arc, + authorizer: Arc, +) -> Result { + let bound_addr = listener.local_addr().map_err(ManagedMcpError::Bind)?; + if !bound_addr.ip().is_loopback() { + return Err(ManagedMcpError::Bind(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "managed MCP endpoint requires a loopback address", + ))); + } + let instructions = registry + .read() + .map(|registry| assemble_system_prompt(®istry, "default")) + .unwrap_or_default(); + let activity = DispatchActivity::new(); + let shutdown = CancellationToken::new(); + let cancel_gate = gate.clone(); + let router = build_gated_router_for_port( + dispatcher, + instructions, + gate, + activity.clone(), + shutdown.clone(), + bound_addr.port(), + Some(authorizer), + ); + let listener_shutdown = shutdown.clone(); + let join = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(listener_shutdown.cancelled_owned()) + .await + }); + Ok(ManagedMcpEndpoint { + addr: bound_addr, + shutdown, + activity, + cancel_gate, + join: Some(join), + closed: false, + }) +} + /// Bind a per-turn project-authorized MCP server on a fresh IPv4 loopback port. pub async fn bind_ephemeral_gated( dispatcher: Arc, @@ -1137,7 +1310,7 @@ async fn bind_ephemeral_gated_on( activity.clone(), shutdown.clone(), bound_addr.port(), - Some(bearer_token.clone()), + Some(Arc::new(SingleBearerAuthorizer::new(bearer_token.clone()))), ); let listener_shutdown = shutdown.clone(); let listener_stopped = stopped.clone(); @@ -1511,6 +1684,151 @@ mod tests { McpServer::from_gated_dispatcher(dispatcher, String::new(), gate, activity, admission) } + struct TestBearerAuthorizer { + credentials: RwLock>, + } + + impl TestBearerAuthorizer { + fn with_credential(token: &str, client_id: &str, credential_generation: u64) -> Self { + Self { + credentials: RwLock::new(vec![( + (token).to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from(client_id), + credential_generation, + }, + )]), + } + } + + fn replace_credential(&self, token: &str, client_id: &str, credential_generation: u64) { + *self + .credentials + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = vec![( + token.to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from(client_id), + credential_generation, + }, + )]; + } + + fn revoke_all(&self) { + self.credentials + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + } + + impl BearerAuthorizer for TestBearerAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + self.credentials + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find_map(|(token, client)| { + (token.len() == candidate.len() + && bool::from(token.as_bytes().ct_eq(candidate.as_bytes()))) + .then(|| client.clone()) + }) + } + } + + #[derive(Clone, Default)] + struct CapturingSubscriber { + events: Arc>>, + } + + impl tracing::Subscriber for CapturingSubscriber { + fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + struct Visitor<'a>(&'a mut String); + + impl tracing::field::Visit for Visitor<'_> { + fn record_debug( + &mut self, + field: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + let _ = write!(self.0, " {}={value:?}", field.name()); + } + } + + let mut recorded = event.metadata().name().to_owned(); + event.record(&mut Visitor(&mut recorded)); + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(recorded); + } + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} + + fn register_callsite( + &self, + _metadata: &'static tracing::Metadata<'static>, + ) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + } + + fn managed_fixture() -> ( + Arc, + Arc>, + Arc, + ) { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + (dispatcher, registry, Arc::new(CountingGate::new(true))) + } + + fn initialize_body() -> Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "managed-test", "version": "0" } + } + }) + } + + async fn bind_managed_test_endpoint( + authorizer: Arc, + ) -> ManagedMcpEndpoint { + let (dispatcher, registry, gate) = managed_fixture(); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed test listener"); + bind_managed_gated_on(listener, dispatcher, registry, gate, authorizer) + .await + .expect("bind managed endpoint") + } + #[test] fn lists_every_advertised_tool() { let server = server(); @@ -2053,6 +2371,327 @@ mod tests { second.close().await.expect("close second endpoint"); } + #[tokio::test] + async fn managed_authentication_failures_have_one_redacted_public_shape() { + let valid = "managed-valid-credential"; + let wrong = "managed-wrong-credential"; + let authorizer = Arc::new(TestBearerAuthorizer::with_credential(valid, "external", 7)); + let endpoint = bind_managed_test_endpoint(authorizer.clone()).await; + let client = reqwest::Client::new(); + + let missing = client + .post(format!("http://{}/mcp", endpoint.addr())) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send missing credential"); + let wrong = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(wrong) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send wrong credential"); + let malformed = client + .post(format!("http://{}/mcp", endpoint.addr())) + .header("authorization", format!("Bearer {valid} extra")) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send malformed credential"); + authorizer.revoke_all(); + let revoked = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(valid) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send revoked credential"); + + let mut shapes = Vec::new(); + for response in [missing, wrong, malformed, revoked] { + let status = response.status(); + let www_authenticate = response.headers().get("www-authenticate").cloned(); + let body = response.text().await.expect("read authentication response"); + assert!( + !body.contains(valid), + "authentication response leaked valid credential" + ); + assert!( + !body.contains("managed-wrong-credential"), + "authentication response leaked candidate credential" + ); + shapes.push((status, www_authenticate, body)); + } + assert!(shapes.iter().all(|shape| shape == &shapes[0])); + assert_eq!(shapes[0].0, reqwest::StatusCode::UNAUTHORIZED); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_authorizer_observes_regeneration_before_new_initialize() { + let old_token = "managed-generation-one"; + let new_token = "managed-generation-two"; + let authorizer = Arc::new(TestBearerAuthorizer::with_credential( + old_token, "external", 1, + )); + let endpoint = bind_managed_test_endpoint(authorizer.clone()).await; + let client = reqwest::Client::new(); + authorizer.replace_credential(new_token, "external", 2); + + let old = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(old_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send old credential"); + assert_eq!(old.status(), reqwest::StatusCode::UNAUTHORIZED); + + let regenerated = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(new_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send regenerated credential"); + assert!(regenerated.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_endpoint_keeps_loopback_origin_and_host_guards() { + let token = "managed-loopback-credential"; + let endpoint = bind_managed_test_endpoint(Arc::new(TestBearerAuthorizer::with_credential( + token, "external", 1, + ))) + .await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + + let remote_host = client + .post(&url) + .bearer_auth(token) + .header( + "host", + format!("attacker.example:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send remote host"); + assert_eq!(remote_host.status(), reqwest::StatusCode::FORBIDDEN); + + let remote_origin = client + .post(&url) + .bearer_auth(token) + .header( + "origin", + format!("http://attacker.example:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send remote origin"); + assert_eq!(remote_origin.status(), reqwest::StatusCode::FORBIDDEN); + + let loopback_origin = client + .post(&url) + .bearer_auth(token) + .header( + "origin", + format!("http://127.0.0.1:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send loopback origin"); + assert!(loopback_origin.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_shutdown_stops_listener_admission_and_workers() { + let token = "managed-shutdown-credential"; + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let gate = Arc::new(BlockingGate::new(entered_tx, cancel_tx)); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed shutdown listener"); + let endpoint = bind_managed_gated_on( + listener, + dispatcher, + registry, + gate.clone(), + Arc::new(TestBearerAuthorizer::with_credential(token, "external", 1)), + ) + .await + .expect("bind managed endpoint"); + let addr = endpoint.addr(); + let client = reqwest::Client::new(); + let url = format!("http://{addr}/mcp"); + let initialized = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize managed session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("stateful managed session") + .clone(); + let call_client = client.clone(); + let call_url = url.clone(); + let call = tokio::spawn(async move { + call_client + .post(call_url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "get_timeline", "arguments": {} } + })) + .send() + .await + }); + entered_rx + .await + .expect("blocking managed worker entered gate"); + endpoint.shutdown(); + cancel_rx + .await + .expect("managed shutdown requested gate cancellation"); + let mut stopped = tokio::spawn(async move { endpoint.wait().await }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut stopped) + .await + .is_err(), + "managed shutdown returned before its admitted worker finished" + ); + gate.release(); + let _ = call.await.expect("managed call joined"); + stopped + .await + .expect("managed endpoint task joined") + .expect("stop managed endpoint"); + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); + } + + #[tokio::test] + async fn managed_authorization_attaches_client_identity_to_request_extensions() { + use axum::extract::Extension; + use tower::ServiceExt as _; + + async fn identity(Extension(client): Extension) -> String { + format!("{}:{}", client.client_id, client.credential_generation) + } + + let router = axum::Router::new() + .route("/identity", axum::routing::get(identity)) + .layer(axum::middleware::from_fn_with_state( + Arc::new(TestBearerAuthorizer::with_credential( + "extension-token", + "external", + 9, + )) as Arc, + bearer_authorization_guard, + )); + let response = router + .oneshot( + axum::http::Request::builder() + .uri("/identity") + .header("authorization", "Bearer extension-token") + .body(axum::body::Body::empty()) + .expect("identity request"), + ) + .await + .expect("identity response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .expect("read identity response"); + assert_eq!(&body[..], b"external:9"); + } + + #[test] + fn bearer_authorization_never_records_candidate_tokens() { + use tower::ServiceExt as _; + + let router = axum::Router::new() + .route("/", axum::routing::get(|| async { "authorized" })) + .layer(axum::middleware::from_fn_with_state( + Arc::new(TestBearerAuthorizer::with_credential( + "active-token", + "external", + 1, + )) as Arc, + bearer_authorization_guard, + )); + let subscriber = CapturingSubscriber::default(); + let events = subscriber.events.clone(); + let dispatch = tracing::Dispatch::new(subscriber); + tracing::dispatcher::with_default(&dispatch, || { + for candidate in ["wrong-candidate-a", "wrong-candidate-b"] { + let response = futures::executor::block_on( + router.clone().oneshot( + axum::http::Request::builder() + .uri("/") + .header("authorization", format!("Bearer {candidate}")) + .body(axum::body::Body::empty()) + .expect("authorization request"), + ), + ) + .expect("authorization response"); + assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED); + } + }); + let captured = events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .join("\n"); + assert!(!captured.contains("wrong-candidate-a")); + assert!(!captured.contains("wrong-candidate-b")); + } + #[test] fn host_guard_accepts_local_rejects_remote() { assert!(host_is_local("127.0.0.1:19789", MCP_PORT)); From 24ec56c705f4736aeeb98d82475c3d9ddf0c5c22 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 19:26:41 +0800 Subject: [PATCH 06/61] fix(mcp): drain managed requests on shutdown --- crates/opentake-agent/src/mcp/server.rs | 251 +++++++++++++++++++++++- 1 file changed, 242 insertions(+), 9 deletions(-) diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index aa673b6d..a76c21e1 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -81,11 +81,14 @@ enum DispatchAuthority { } impl DispatchAuthority { - fn try_enter(&self) -> Result, McpError> { + fn try_enter( + &self, + request_cancel: opentake_media::MediaCancelToken, + ) -> Result, McpError> { match self { Self::Direct => Ok(None), Self::Gated { activity, .. } => activity - .try_enter() + .try_enter(request_cancel) .map(Some) .ok_or_else(turn_inactive_error), } @@ -123,6 +126,7 @@ struct DispatchActivity { struct DispatchActivityState { accepting: bool, active: usize, + request_cancellations: Vec, } impl DispatchActivity { @@ -131,12 +135,16 @@ impl DispatchActivity { state: Mutex::new(DispatchActivityState { accepting: true, active: 0, + request_cancellations: Vec::new(), }), changed: tokio::sync::Notify::new(), }) } - fn try_enter(self: &Arc) -> Option { + fn try_enter( + self: &Arc, + request_cancel: opentake_media::MediaCancelToken, + ) -> Option { let mut state = self .state .lock() @@ -145,8 +153,10 @@ impl DispatchActivity { return None; } state.active = state.active.saturating_add(1); + state.request_cancellations.push(request_cancel.clone()); Some(DispatchPermit { activity: self.clone(), + request_cancel, }) } @@ -161,6 +171,24 @@ impl DispatchActivity { } } + /// Stop admission and synchronously cancel every admitted request-local + /// worker. This is independent of the host's optional whole-turn + /// cancellation hook, so managed endpoints drain even for gates that leave + /// that hook at its no-op default. + fn stop_and_cancel(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.accepting = false; + for request_cancel in &state.request_cancellations { + request_cancel.cancel(); + } + if state.active == 0 { + self.changed.notify_one(); + } + } + async fn wait_zero(&self) { loop { let changed = self.changed.notified(); @@ -187,6 +215,7 @@ impl DispatchActivity { struct DispatchPermit { activity: Arc, + request_cancel: opentake_media::MediaCancelToken, } impl Drop for DispatchPermit { @@ -197,6 +226,13 @@ impl Drop for DispatchPermit { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.active = state.active.saturating_sub(1); + if let Some(index) = state + .request_cancellations + .iter() + .position(|tracked| tracked.same_instance(&self.request_cancel)) + { + state.request_cancellations.swap_remove(index); + } if state.active == 0 { self.activity.changed.notify_one(); } @@ -433,10 +469,10 @@ impl McpServer { let admission_permit = self .admission .try_enter(dispatch_admission_class(&name, &args))?; - let permit = self.authority.try_enter()?; + let cancel = opentake_media::MediaCancelToken::new(); + let permit = self.authority.try_enter(cancel.clone())?; let dispatcher = self.dispatcher.clone(); let authority = self.authority.clone(); - let cancel = opentake_media::MediaCancelToken::new(); let worker_cancel = cancel.clone(); let worker_authority = authority.clone(); let mut worker = tokio::task::spawn_blocking(move || { @@ -1164,7 +1200,7 @@ impl ManagedMcpEndpoint { /// Stop admission and transport sessions. Call [`Self::wait`] afterwards to /// wait for admitted blocking work and the listener task to finish. pub fn shutdown(&self) { - self.activity.stop_accepting(); + self.activity.stop_and_cancel(); self.cancel_gate.request_cancel(); self.shutdown.cancel(); } @@ -1202,7 +1238,59 @@ impl Drop for ManagedMcpEndpoint { } self.shutdown(); if let Some(join) = self.join.take() { - join.abort(); + reap_managed_listener(self.activity.clone(), join); + } + } +} + +async fn drain_managed_listener( + activity: Arc, + join: tokio::task::JoinHandle>, +) { + activity.wait_zero().await; + if let Err(error) = join.await { + tracing::error!( + target: "opentake::mcp::private", + task_cancelled = error.is_cancelled(), + task_panic = error.is_panic(), + "managed MCP listener reaper task failed" + ); + } +} + +/// Preserve the managed drain invariant even when its owner is dropped from a +/// synchronous context after the originating Tokio runtime has ended. +fn reap_managed_listener( + activity: Arc, + join: tokio::task::JoinHandle>, +) { + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(drain_managed_listener(activity, join)); + } + Err(_) => { + let spawn = std::thread::Builder::new() + .name("opentake-mcp-reaper".to_owned()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime.block_on(drain_managed_listener(activity, join)), + Err(error) => tracing::error!( + target: "opentake::mcp::private", + %error, + "could not start managed MCP listener reaper runtime" + ), + } + }); + if let Err(error) = spawn { + tracing::error!( + target: "opentake::mcp::private", + %error, + "could not start managed MCP listener reaper thread" + ); + } } } } @@ -1606,6 +1694,54 @@ mod tests { } } + struct RequestTokenOnlyGate { + entered: Mutex>>, + } + + impl RequestTokenOnlyGate { + fn new(entered: tokio::sync::oneshot::Sender<()>) -> Self { + Self { + entered: Mutex::new(Some(entered)), + } + } + } + + impl ChatTurnGate for RequestTokenOnlyGate { + fn timeline(&self, dispatcher: &Dispatcher) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + ) -> Option { + panic!("managed requests must use the request-local cancellation path") + } + + fn dispatch_cancellable( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + if let Some(entered) = self + .entered + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = entered.send(()); + } + while !request_cancel.is_cancelled() { + std::thread::yield_now(); + } + Some(ToolResult::ok("request cancelled")) + } + } + struct RecordingBlockingGate { blocked_name: Option<&'static str>, entered: tokio::sync::mpsc::UnboundedSender, @@ -1986,10 +2122,14 @@ mod tests { #[tokio::test] async fn stopping_admission_rejects_new_calls_and_waits_for_active_permit() { let activity = DispatchActivity::new(); - let permit = activity.try_enter().expect("first dispatch admitted"); + let permit = activity + .try_enter(opentake_media::MediaCancelToken::new()) + .expect("first dispatch admitted"); activity.stop_accepting(); assert!( - activity.try_enter().is_none(), + activity + .try_enter(opentake_media::MediaCancelToken::new()) + .is_none(), "new dispatch must be rejected" ); @@ -2616,6 +2756,99 @@ mod tests { assert!(tokio::net::TcpStream::connect(addr).await.is_err()); } + #[tokio::test] + async fn managed_shutdown_cancels_request_local_workers_when_gate_cancel_is_noop() { + let token = "managed-request-token-credential"; + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let gate = Arc::new(RequestTokenOnlyGate::new(entered_tx)); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind request-token managed listener"); + let endpoint = bind_managed_gated_on( + listener, + dispatcher, + registry, + gate, + Arc::new(TestBearerAuthorizer::with_credential(token, "external", 1)), + ) + .await + .expect("bind managed endpoint"); + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let initialized = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize request-token session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("stateful request-token session") + .clone(); + let call_client = client.clone(); + let call_url = url.clone(); + let call = tokio::spawn(async move { + call_client + .post(call_url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "get_timeline", "arguments": {} } + })) + .send() + .await + }); + entered_rx + .await + .expect("request-local worker entered no-op gate"); + + endpoint.shutdown(); + tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.wait()) + .await + .expect("managed shutdown must not wait for a no-op gate") + .expect("managed endpoint stopped"); + let response = call + .await + .expect("request-local call joined") + .expect("request-local call completed"); + assert!(response.status().is_success()); + } + + #[test] + fn dropping_managed_endpoint_after_its_runtime_stops_does_not_panic() { + let endpoint = { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build endpoint runtime"); + runtime.block_on(async { + bind_managed_test_endpoint(Arc::new(TestBearerAuthorizer::with_credential( + "managed-drop-credential", + "external", + 1, + ))) + .await + }) + }; + + drop(endpoint); + } + #[tokio::test] async fn managed_authorization_attaches_client_identity_to_request_extensions() { use axum::extract::Extension; From 0799a83c399d2b30cf170baaabb71418b94a7f1b Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 19:39:36 +0800 Subject: [PATCH 07/61] refactor(mcp): share live project dispatcher with external sessions --- src-tauri/src/chat.rs | 16 +++ src-tauri/src/external_mcp.rs | 233 +++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 20 ++- src-tauri/src/mcp.rs | 48 +++++-- 4 files changed, 299 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs index 26649bb2..4fbaf789 100644 --- a/src-tauri/src/chat.rs +++ b/src-tauri/src/chat.rs @@ -46,6 +46,15 @@ pub struct ChatState { admission: crate::updater::InstallAdmissionGate, } +/// Immutable handles for long-lived MCP sessions to enter the exact same +/// dispatcher and workflow registry used by in-app Agent chat. +#[derive(Clone)] +#[allow(dead_code)] // Task 4's listener consumes both handles. +pub(crate) struct ExternalMcpComponents { + pub(crate) dispatcher: Arc, + pub(crate) registry: Arc>, +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct SessionKey { project_epoch: u64, @@ -140,6 +149,13 @@ impl ChatProjectContext { } impl ChatState { + pub(crate) fn external_mcp_components(&self) -> ExternalMcpComponents { + ExternalMcpComponents { + dispatcher: self.dispatcher.clone(), + registry: self.registry.clone(), + } + } + #[cfg(test)] pub fn new( core: AppCore, diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 9544adbd..1b9eca09 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -4,13 +4,17 @@ use std::{ fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, RwLock}, time::{SystemTime, UNIX_EPOCH}, }; +use opentake_agent::mcp::{AuthenticatedMcpClient, BearerAuthorizer}; +use opentake_core::AppCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::chat::ExternalMcpComponents; +use crate::mcp::LiveProjectMcpGate; use crate::secret::McpSecretStore; const CATALOG_VERSION: u32 = 1; @@ -113,6 +117,50 @@ pub(crate) struct ExternalMcpCatalog { fail_parent_sync_on_call: std::sync::atomic::AtomicUsize, } +struct ExternalMcpCatalogAuthorizer { + catalog: Arc>, +} + +impl BearerAuthorizer for ExternalMcpCatalogAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + let catalog = self.catalog.read().ok()?; + let client_id = catalog.verify_candidate(candidate).ok()??; + Some(AuthenticatedMcpClient { + client_id: client_id.into(), + credential_generation: credential_generation(candidate), + }) + } +} + +pub(crate) struct ExternalMcpState { + core: AppCore, + components: ExternalMcpComponents, + catalog: Arc>, + gate: Arc, + authorizer: Arc, +} + +impl ExternalMcpState { + pub(crate) fn new( + core: AppCore, + components: ExternalMcpComponents, + catalog: ExternalMcpCatalog, + ) -> Self { + let catalog = Arc::new(RwLock::new(catalog)); + let authorizer = Arc::new(ExternalMcpCatalogAuthorizer { + catalog: catalog.clone(), + }); + let gate = LiveProjectMcpGate::new(core.clone()); + Self { + core, + components, + catalog, + gate, + authorizer, + } + } +} + impl ExternalMcpCatalog { pub(crate) fn load( app_data_dir: &Path, @@ -639,6 +687,15 @@ fn token_digest(token: &str) -> String { .collect() } +fn credential_generation(token: &str) -> u64 { + let digest = Sha256::digest(token.as_bytes()); + u64::from_be_bytes( + digest[..std::mem::size_of::()] + .try_into() + .expect("SHA-256 contains a u64 credential generation"), + ) +} + fn secret_account(client_id: &str) -> String { format!("external-mcp:{client_id}") } @@ -658,7 +715,9 @@ mod tests { use std::sync::Arc; use super::*; + use crate::chat::ChatState; use crate::secret::{McpSecretStore, MemoryMcpSecretStore}; + use opentake_agent::chat::ChatTurnGate; fn catalog_root() -> tempfile::TempDir { tempfile::tempdir().expect("create temporary application data directory") @@ -672,6 +731,178 @@ mod tests { .expect("load catalog against the in-memory secret store") } + fn shared_state( + root: &tempfile::TempDir, + core: opentake_core::AppCore, + ) -> (ChatState, ExternalMcpState) { + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let catalog = load_catalog(root, Arc::new(MemoryMcpSecretStore::default())); + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + (chat, external) + } + + fn shared_dispatch( + state: &ExternalMcpState, + scope: &str, + tool: &str, + args: serde_json::Value, + ) -> opentake_agent::tools::result::ToolResult { + state + .gate + .dispatch_cancellable_scoped( + &state.components.dispatcher, + tool, + args, + scope, + &opentake_media::MediaCancelToken::new(), + ) + .expect("saved live project accepts the dispatch") + } + + #[test] + fn shared_state_reuses_chat_dispatcher_and_registry_arcs() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let expected = chat.external_mcp_components(); + let catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + + assert!(Arc::ptr_eq( + &external.components.dispatcher, + &expected.dispatcher + )); + assert!(Arc::ptr_eq( + &external.components.registry, + &expected.registry + )); + } + + #[test] + fn shared_live_gate_refuses_mutation_without_a_saved_project() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let (_chat, external) = shared_state(&root, core.clone()); + + let refused = external.gate.dispatch_cancellable_scoped( + &external.components.dispatcher, + "create_folder", + serde_json::json!({ "name": "must-not-exist" }), + "opentake:mcp:unsaved", + &opentake_media::MediaCancelToken::new(), + ); + + assert!(refused.is_none()); + assert!(core.media().folders.is_empty()); + } + + #[test] + fn shared_catalog_authorizer_tracks_regenerated_credentials_without_exporting_them() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let first = catalog.pair("Claude Desktop").expect("pair client"); + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + + let first_client = external + .authorizer + .authorize(&first.bearer_token) + .expect("authorize original credential"); + assert_eq!(first_client.client_id.as_ref(), first.client.id); + assert!(external.authorizer.authorize("wrong credential").is_none()); + + let regenerated = external + .catalog + .write() + .expect("write catalog") + .regenerate(&first.client.id) + .expect("regenerate credential"); + assert!(external.authorizer.authorize(&first.bearer_token).is_none()); + let regenerated_client = external + .authorizer + .authorize(®enerated.bearer_token) + .expect("authorize regenerated credential"); + assert_eq!(regenerated_client.client_id, first_client.client_id); + assert_ne!( + regenerated_client.credential_generation, + first_client.credential_generation + ); + } + + #[test] + fn shared_dispatcher_isolates_two_mcp_sessions_and_in_app_chat_undo() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + core.save_project(Some(root.path().join("Shared.opentake"))) + .expect("save shared project"); + let (_chat, external) = shared_state(&root, core.clone()); + let mcp_a = "opentake:mcp:test:a"; + let mcp_b = "opentake:mcp:test:b"; + let chat = "opentake:chat:test"; + + let created = shared_dispatch( + &external, + mcp_a, + "create_folder", + serde_json::json!({ "name": "MCP A" }), + ); + assert!(!created.is_error, "{}", created.text_joined()); + for foreign_scope in [mcp_b, chat] { + let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); + assert!(refused.is_error, "foreign scope consumed MCP A's edit"); + } + let undone = shared_dispatch(&external, mcp_a, "undo", serde_json::json!({})); + assert!(!undone.is_error, "{}", undone.text_joined()); + assert!(core.media().folders.is_empty()); + + let created = shared_dispatch( + &external, + chat, + "create_folder", + serde_json::json!({ "name": "Chat" }), + ); + assert!(!created.is_error, "{}", created.text_joined()); + for foreign_scope in [mcp_a, mcp_b] { + let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); + assert!(refused.is_error, "external scope consumed chat's edit"); + } + let undone = shared_dispatch(&external, chat, "undo", serde_json::json!({})); + assert!(!undone.is_error, "{}", undone.text_joined()); + assert!(core.media().folders.is_empty()); + + let created = shared_dispatch( + &external, + mcp_b, + "create_folder", + serde_json::json!({ "name": "MCP B" }), + ); + assert!(!created.is_error, "{}", created.text_joined()); + for foreign_scope in [mcp_a, chat] { + let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); + assert!(refused.is_error, "foreign scope consumed MCP B's edit"); + } + let undone = shared_dispatch(&external, mcp_b, "undo", serde_json::json!({})); + assert!(!undone.is_error, "{}", undone.text_joined()); + assert!(core.media().folders.is_empty()); + } + #[test] fn catalog_pair_creates_unique_client_ids_and_32_byte_tokens() { let root = catalog_root(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4788395f..96f25595 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -225,9 +225,22 @@ pub fn run() { advanced_bridge.clone(), install_admission.clone(), ); - // The fixed-port external MCP endpoint is disabled for Beta until - // the product has an authenticated pairing UX. Official Codex - // turns bind their own authenticated per-turn endpoint. + let external_mcp_catalog = external_mcp::ExternalMcpCatalog::load( + &app.path().app_data_dir().map_err(|error| { + std::io::Error::other(format!( + "could not resolve external MCP application data directory: {error}" + )) + })?, + Arc::new(opentake_gen::KeyringStore::new()), + ) + .map_err(std::io::Error::other)?; + let external_mcp_state = external_mcp::ExternalMcpState::new( + core.clone(), + chat_state.external_mcp_components(), + external_mcp_catalog, + ); + // Task 4 owns the explicit enable/pair listener lifecycle. This + // setup only shares the production gate and Agent tool universe. // A global favorite must never silently become a temporary file. // Keep the editor usable if app-data resolution fails, but make all @@ -271,6 +284,7 @@ pub fn run() { } }); app.manage(chat_state); + app.manage(external_mcp_state); app.manage(codex::CodexAuthState::default()); app.manage(MediaState::new_with_admission( engine, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 053a5a1f..f8e740cf 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -22,9 +22,7 @@ use std::io::{Seek, SeekFrom, Write}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::rc::Rc; -#[cfg(test)] use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -#[cfg(test)] use std::sync::Mutex; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -47,7 +45,6 @@ use opentake_agent::mcp::media_bridge::{ }; use opentake_agent::mcp::server::{bind_ephemeral_gated, EphemeralMcpEndpoint, EphemeralMcpError}; use opentake_agent::plugin::registry::PluginRegistry; -#[cfg(test)] use opentake_agent::tools::result::ToolResult; use opentake_core::{ importable_clip_type, AppCore, CoreError, DeferredCoreEvents, ProbedMedia, @@ -392,8 +389,7 @@ pub(crate) async fn spawn( bind_ephemeral_gated(dispatcher, registry, gate).await } -#[cfg(test)] -struct LiveProjectMcpGate { +pub(crate) struct LiveProjectMcpGate { core: AppCore, transition_depth: Arc, identity_generation: Arc, @@ -401,13 +397,11 @@ struct LiveProjectMcpGate { active_dispatches: Arc>>, } -#[cfg(test)] struct LiveDispatchPermit { id: u64, active_dispatches: Arc>>, } -#[cfg(test)] impl Drop for LiveDispatchPermit { fn drop(&mut self) { self.active_dispatches @@ -417,9 +411,8 @@ impl Drop for LiveDispatchPermit { } } -#[cfg(test)] impl LiveProjectMcpGate { - fn new(core: AppCore) -> Arc { + pub(crate) fn new(core: AppCore) -> Arc { let transition_depth = Arc::new(AtomicUsize::new(0)); let identity_generation = Arc::new(AtomicU64::new(0)); let active_dispatches = Arc::new(Mutex::new(HashMap::< @@ -497,14 +490,24 @@ impl LiveProjectMcpGate { cancel: &opentake_media::MediaCancelToken, operation: impl FnOnce() -> T, ) -> Option { - self.with_live_dispatch_after_admission(cancel, || {}, operation) + self.with_live_dispatch_inner(cancel, || {}, operation) } + #[cfg(test)] fn with_live_dispatch_after_admission( &self, cancel: &opentake_media::MediaCancelToken, after_admission: impl FnOnce(), operation: impl FnOnce() -> T, + ) -> Option { + self.with_live_dispatch_inner(cancel, after_admission, operation) + } + + fn with_live_dispatch_inner( + &self, + cancel: &opentake_media::MediaCancelToken, + after_admission: impl FnOnce(), + operation: impl FnOnce() -> T, ) -> Option { let admitted_generation = self.identity_generation.load(Ordering::Acquire); if self.transition_pending() || cancel.is_cancelled() { @@ -539,7 +542,6 @@ impl LiveProjectMcpGate { } } -#[cfg(test)] impl ChatTurnGate for LiveProjectMcpGate { fn timeline(&self, dispatcher: &Dispatcher) -> Option { self.with_live_project(|| dispatcher.timeline()) @@ -2629,7 +2631,7 @@ mod tests { } #[test] - fn persistent_mcp_gate_requires_a_saved_nontransitioning_project() { + fn live_project_gate_requires_a_saved_nontransitioning_project() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); let gate = LiveProjectMcpGate::new(core.clone()); @@ -2649,7 +2651,25 @@ mod tests { } #[test] - fn persistent_mcp_request_admitted_for_old_project_cannot_write_new_project() { + fn live_project_gate_refuses_mutating_calls_without_a_saved_project() { + let core = AppCore::new(); + let gate = LiveProjectMcpGate::new(core.clone()); + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let handle: Arc = Arc::new(AppCoreHandle::new(core.clone())); + let dispatcher = Dispatcher::new(handle, registry); + + assert!(gate + .dispatch( + &dispatcher, + "create_folder", + serde_json::json!({ "name": "must-not-exist" }), + ) + .is_none()); + assert!(core.media().folders.is_empty()); + } + + #[test] + fn live_project_request_admitted_for_old_project_cannot_write_new_project() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); let project_a = fixture.path().join("A.opentake"); @@ -2701,7 +2721,7 @@ mod tests { } #[test] - fn project_transition_cancels_active_persistent_mcp_before_identity_changes() { + fn live_project_transition_cancels_active_request_before_identity_changes() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); core.save_project(Some(fixture.path().join("A.opentake"))) From a6aefced48716f8c57157b5fd6c002fbf7464937 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 19:51:15 +0800 Subject: [PATCH 08/61] test(mcp): verify production session undo isolation --- src-tauri/src/chat.rs | 31 +++- src-tauri/src/external_mcp.rs | 256 ++++++++++++++++++++++++++-------- 2 files changed, 223 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs index 4fbaf789..8441ce18 100644 --- a/src-tauri/src/chat.rs +++ b/src-tauri/src/chat.rs @@ -156,6 +156,29 @@ impl ChatState { } } + fn project_turn_gate( + &self, + project: &ChatProjectContext, + session_key: &SessionKey, + cancel: Arc, + ) -> Arc { + Arc::new(ProjectTurnGate { + state: self.clone(), + project: project.clone(), + cancel, + undo_scope: agent_undo_scope(session_key), + }) + } + + #[cfg(test)] + pub(crate) fn project_turn_gate_for_test(&self, session_id: &str) -> Arc { + let project = self.project_context().expect("saved test project"); + self.put_project_session(&project, ChatSession::new(session_id)) + .expect("persist test chat session"); + let session_key = project.key(session_id); + self.project_turn_gate(&project, &session_key, Arc::new(TurnCancel::new())) + } + #[cfg(test)] pub fn new( core: AppCore, @@ -697,7 +720,6 @@ pub async fn chat_send( ) -> Result<(), String> { let project = state.project_context_for(expected_project_epoch, &expected_project_path)?; let session_key = project.key(&session_id); - let undo_scope = agent_undo_scope(&session_key); let turn_cancel = Arc::new(TurnCancel::new()); let turn_admission = state.reserve_turn(session_key.clone(), turn_cancel.clone())?; let cancel = turn_cancel.requested.clone(); @@ -726,12 +748,7 @@ pub async fn chat_send( project: project.clone(), }; let turn_owner = turn_cancel.clone(); - let gate: Arc = Arc::new(ProjectTurnGate { - state: state_clone.clone(), - project: project.clone(), - cancel: turn_cancel, - undo_scope, - }); + let gate = state_clone.project_turn_gate(&project, &session_key, turn_cancel); let is_codex = chat_provider == "codex"; let mut codex_final: Option<(String, ChatMessage)> = None; let result = if is_codex { diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 1b9eca09..238d19ea 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -746,22 +746,65 @@ mod tests { (chat, external) } - fn shared_dispatch( - state: &ExternalMcpState, - scope: &str, - tool: &str, - args: serde_json::Value, - ) -> opentake_agent::tools::result::ToolResult { - state - .gate - .dispatch_cancellable_scoped( - &state.components.dispatcher, - tool, - args, - scope, - &opentake_media::MediaCancelToken::new(), - ) - .expect("saved live project accepts the dispatch") + async fn initialize_managed_session( + client: &reqwest::Client, + url: &str, + token: &str, + name: &str, + ) -> reqwest::header::HeaderValue { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": name, "version": "0" } + } + })) + .send() + .await + .expect("initialize managed MCP session"); + assert!(response.status().is_success()); + response + .headers() + .get("mcp-session-id") + .expect("managed rmcp session id") + .clone() + } + + async fn call_managed_tool( + client: &reqwest::Client, + url: &str, + token: &str, + session: &reqwest::header::HeaderValue, + id: u64, + name: &str, + arguments: serde_json::Value, + ) -> String { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + })) + .send() + .await + .expect("call managed MCP tool"); + assert!(response.status().is_success()); + response.text().await.expect("read managed MCP result") } #[test] @@ -846,61 +889,160 @@ mod tests { ); } - #[test] - fn shared_dispatcher_isolates_two_mcp_sessions_and_in_app_chat_undo() { + #[tokio::test] + async fn shared_transport_scopes_isolate_two_rmcp_sessions_and_in_app_chat_undo() { let root = catalog_root(); let core = opentake_core::AppCore::new(); core.save_project(Some(root.path().join("Shared.opentake"))) .expect("save shared project"); - let (_chat, external) = shared_state(&root, core.clone()); - let mcp_a = "opentake:mcp:test:a"; - let mcp_b = "opentake:mcp:test:b"; - let chat = "opentake:chat:test"; - - let created = shared_dispatch( - &external, - mcp_a, + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let chat_gate = chat.project_turn_gate_for_test("chat-session"); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let paired = catalog.pair("Claude Desktop").expect("pair test client"); + let external = ExternalMcpState::new(core.clone(), chat.external_mcp_components(), catalog); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed MCP test listener"); + let endpoint = opentake_agent::mcp::server::bind_managed_gated_on( + listener, + external.components.dispatcher.clone(), + external.components.registry.clone(), + external.gate.clone(), + external.authorizer.clone(), + ) + .await + .expect("start managed MCP endpoint"); + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let session_a = + initialize_managed_session(&client, &url, &paired.bearer_token, "rmcp-session-a").await; + let session_b = + initialize_managed_session(&client, &url, &paired.bearer_token, "rmcp-session-b").await; + + let created = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 2, "create_folder", serde_json::json!({ "name": "MCP A" }), - ); - assert!(!created.is_error, "{}", created.text_joined()); - for foreign_scope in [mcp_b, chat] { - let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); - assert!(refused.is_error, "foreign scope consumed MCP A's edit"); - } - let undone = shared_dispatch(&external, mcp_a, "undo", serde_json::json!({})); - assert!(!undone.is_error, "{}", undone.text_joined()); + ) + .await; + assert!(!created.contains("\"isError\":true"), "{created}"); + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 3, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate remains live"); + assert!(chat_undo.is_error, "chat consumed MCP A's edit"); + let owner = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 4, + "undo", + serde_json::json!({}), + ) + .await; + assert!(!owner.contains("\"isError\":true"), "{owner}"); assert!(core.media().folders.is_empty()); - let created = shared_dispatch( - &external, - chat, + let created = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 5, "create_folder", - serde_json::json!({ "name": "Chat" }), - ); - assert!(!created.is_error, "{}", created.text_joined()); - for foreign_scope in [mcp_a, mcp_b] { - let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); - assert!(refused.is_error, "external scope consumed chat's edit"); - } - let undone = shared_dispatch(&external, chat, "undo", serde_json::json!({})); - assert!(!undone.is_error, "{}", undone.text_joined()); + serde_json::json!({ "name": "MCP B" }), + ) + .await; + assert!(!created.contains("\"isError\":true"), "{created}"); + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 6, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate remains live"); + assert!(chat_undo.is_error, "chat consumed MCP B's edit"); + let owner = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 7, + "undo", + serde_json::json!({}), + ) + .await; + assert!(!owner.contains("\"isError\":true"), "{owner}"); assert!(core.media().folders.is_empty()); - let created = shared_dispatch( - &external, - mcp_b, - "create_folder", - serde_json::json!({ "name": "MCP B" }), - ); + let created = chat_gate + .dispatch( + &external.components.dispatcher, + "create_folder", + serde_json::json!({ "name": "Chat" }), + ) + .expect("current chat gate accepts mutation"); assert!(!created.is_error, "{}", created.text_joined()); - for foreign_scope in [mcp_a, chat] { - let refused = shared_dispatch(&external, foreign_scope, "undo", serde_json::json!({})); - assert!(refused.is_error, "foreign scope consumed MCP B's edit"); + for (session, id) in [(&session_a, 8), (&session_b, 9)] { + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + session, + id, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); } - let undone = shared_dispatch(&external, mcp_b, "undo", serde_json::json!({})); - assert!(!undone.is_error, "{}", undone.text_joined()); + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate accepts undo"); + assert!(!chat_undo.is_error, "{}", chat_undo.text_joined()); assert!(core.media().folders.is_empty()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed MCP endpoint"); } #[test] From b18b70bfff8707b0dc6f878a54faf40ed38c9cf5 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 20:53:51 +0800 Subject: [PATCH 09/61] feat(mcp): manage persistent external endpoint lifecycle --- src-tauri/src/external_mcp.rs | 964 +++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 44 +- web/src/lib/api.test.ts | 35 ++ web/src/lib/api.ts | 79 +++ web/src/lib/types.ts | 32 ++ 5 files changed, 1128 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 238d19ea..9221eff4 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -1,17 +1,21 @@ -#![allow(dead_code)] // Task 1 establishes the catalog before listener/UI tasks consume it. - use std::{ + collections::HashMap, fs::{self, OpenOptions}, io::Write, + net::Ipv4Addr, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{atomic::AtomicU64, Arc, RwLock}, time::{SystemTime, UNIX_EPOCH}, }; -use opentake_agent::mcp::{AuthenticatedMcpClient, BearerAuthorizer}; +use opentake_agent::mcp::{ + server::{bind_managed_gated_on, ManagedMcpEndpoint}, + AuthenticatedMcpClient, BearerAuthorizer, +}; use opentake_core::AppCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use tauri::{Emitter, Manager}; use crate::chat::ExternalMcpComponents; use crate::mcp::LiveProjectMcpGate; @@ -21,10 +25,36 @@ const CATALOG_VERSION: u32 = 1; const CATALOG_DIRECTORY: &str = "external-mcp"; const CATALOG_FILE: &str = "clients.json"; const PENDING_FILE: &str = "clients.pending.json"; +const PREFERENCES_FILE: &str = "preferences.json"; const EXTERNAL_MCP_ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; +const EXTERNAL_MCP_STATUS_CHANGED: &str = "external_mcp_status_changed"; +const EXTERNAL_MCP_PORT: u16 = 19_789; const MAX_CLIENT_NAME_CHARS: usize = 128; const TOKEN_BYTES: usize = 32; const TOKEN_DIGEST_HEX_CHARS: usize = 12; +const LAST_USED_WRITE_INTERVAL_SECS: i64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ExternalMcpListenerState { + Disabled, + Starting, + Listening, + PortConflict, + AuthFailure, + Paused, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExternalMcpStatus { + pub(crate) revision: u64, + pub(crate) enabled: bool, + pub(crate) state: ExternalMcpListenerState, + pub(crate) endpoint: String, + pub(crate) clients: Vec, + pub(crate) error: Option, +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -76,6 +106,12 @@ struct PersistedCatalog { clients: Vec, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct ExternalMcpPreferences { + enabled: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] enum PendingSecretState { @@ -111,10 +147,13 @@ pub(crate) struct ExternalMcpCatalog { clients: Vec, secrets: Arc, pending: bool, + last_used_published_at: HashMap, #[cfg(test)] fail_next_rename: std::sync::atomic::AtomicBool, #[cfg(test)] fail_parent_sync_on_call: std::sync::atomic::AtomicUsize, + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize, } struct ExternalMcpCatalogAuthorizer { @@ -123,8 +162,11 @@ struct ExternalMcpCatalogAuthorizer { impl BearerAuthorizer for ExternalMcpCatalogAuthorizer { fn authorize(&self, candidate: &str) -> Option { - let catalog = self.catalog.read().ok()?; + let mut catalog = self.catalog.write().ok()?; let client_id = catalog.verify_candidate(candidate).ok()??; + if let Err(error) = catalog.record_last_used(&client_id) { + eprintln!("[mcp] could not persist external MCP client usage: {error}"); + } Some(AuthenticatedMcpClient { client_id: client_id.into(), credential_generation: credential_generation(candidate), @@ -133,11 +175,23 @@ impl BearerAuthorizer for ExternalMcpCatalogAuthorizer { } pub(crate) struct ExternalMcpState { - core: AppCore, components: ExternalMcpComponents, catalog: Arc>, gate: Arc, authorizer: Arc, + lifecycle: tokio::sync::Mutex, + status_revision: AtomicU64, + status_sink: RwLock>, +} + +type ExternalMcpStatusSink = Arc; + +struct ExternalMcpLifecycle { + enabled: bool, + state: ExternalMcpListenerState, + error: Option, + auth_failure: Option, + endpoint: Option, } impl ExternalMcpState { @@ -152,16 +206,349 @@ impl ExternalMcpState { }); let gate = LiveProjectMcpGate::new(core.clone()); Self { - core, components, catalog, gate, authorizer, + lifecycle: tokio::sync::Mutex::new(ExternalMcpLifecycle { + enabled: false, + state: ExternalMcpListenerState::Disabled, + error: None, + auth_failure: None, + endpoint: None, + }), + status_revision: AtomicU64::new(0), + status_sink: RwLock::new(None), + } + } + + pub(crate) fn load( + core: AppCore, + components: ExternalMcpComponents, + app_data_dir: &Path, + secrets: Arc, + ) -> Self { + let root = app_data_dir.join(CATALOG_DIRECTORY); + let preferences = read_preferences(&root); + match ExternalMcpCatalog::load(app_data_dir, secrets.clone()) { + Ok(catalog) if preferences.is_ok() => { + let preferences = preferences.expect("checked external MCP preferences"); + let mut state = Self::new(core, components, catalog); + state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + enabled: preferences.enabled, + state: if preferences.enabled { + ExternalMcpListenerState::Paused + } else { + ExternalMcpListenerState::Disabled + }, + error: None, + auth_failure: None, + endpoint: None, + }); + state + } + catalog => { + let error = match (catalog.err(), preferences.err()) { + (Some(catalog), _) => catalog, + (None, Some(preferences)) => preferences, + (None, None) => "external MCP authentication is unavailable".to_string(), + }; + let catalog = ExternalMcpCatalog::unavailable(app_data_dir, secrets); + let mut state = Self::new(core, components, catalog); + state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + enabled: false, + state: ExternalMcpListenerState::AuthFailure, + error: Some(sanitize_auth_failure(&error)), + auth_failure: Some(error), + endpoint: None, + }); + state + } + } + } + + pub(crate) fn auth_failure( + core: AppCore, + components: ExternalMcpComponents, + error: String, + ) -> Self { + let root = std::env::temp_dir().join("opentake-unavailable-app-data"); + let catalog = + ExternalMcpCatalog::unavailable(&root, Arc::new(opentake_gen::KeyringStore::new())); + let mut state = Self::new(core, components, catalog); + state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + enabled: false, + state: ExternalMcpListenerState::AuthFailure, + error: Some(sanitize_auth_failure(&error)), + auth_failure: Some(error), + endpoint: None, + }); + state + } + + pub(crate) async fn initialize(&self) { + let mut lifecycle = self.lifecycle.lock().await; + self.reconcile_listener(&mut lifecycle).await; + } + + pub(crate) async fn status(&self) -> ExternalMcpStatus { + let lifecycle = self.lifecycle.lock().await; + self.status_for(&lifecycle) + } + + pub(crate) async fn set_enabled(&self, enabled: bool) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_auth_ready(&lifecycle)?; + persist_preferences(&self.catalog_root(), ExternalMcpPreferences { enabled })?; + lifecycle.enabled = enabled; + self.reconcile_listener(&mut lifecycle).await; + Ok(self.status_for(&lifecycle)) + } + + pub(crate) async fn pair(&self, name: &str) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_auth_ready(&lifecycle)?; + let receipt = match self.with_catalog_write(|catalog| catalog.pair(name)) { + Ok(receipt) => receipt, + Err(error) => { + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + }; + self.reconcile_listener(&mut lifecycle).await; + Ok(receipt) + } + + pub(crate) async fn regenerate( + &self, + client_id: &str, + ) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_auth_ready(&lifecycle)?; + self.stop_listener(&mut lifecycle).await?; + let receipt = match self.with_catalog_write(|catalog| catalog.regenerate(client_id)) { + Ok(receipt) => receipt, + Err(error) => { + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + }; + self.reconcile_listener(&mut lifecycle).await; + Ok(receipt) + } + + pub(crate) async fn revoke(&self, client_id: &str) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_auth_ready(&lifecycle)?; + self.stop_listener(&mut lifecycle).await?; + if let Err(error) = self.with_catalog_write(|catalog| catalog.revoke(client_id)) { + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + self.reconcile_listener(&mut lifecycle).await; + Ok(self.status_for(&lifecycle)) + } + + pub(crate) async fn shutdown(&self) -> Result<(), String> { + let mut lifecycle = self.lifecycle.lock().await; + self.stop_listener(&mut lifecycle).await?; + if lifecycle.auth_failure.is_some() { + lifecycle.state = ExternalMcpListenerState::AuthFailure; + } else if lifecycle.enabled { + lifecycle.state = ExternalMcpListenerState::Paused; + lifecycle.error = None; + } else { + lifecycle.state = ExternalMcpListenerState::Disabled; + lifecycle.error = None; + } + self.emit_status(&lifecycle); + Ok(()) + } + + async fn reconcile_listener(&self, lifecycle: &mut ExternalMcpLifecycle) { + if lifecycle.auth_failure.is_some() { + let _ = self.stop_listener(lifecycle).await; + lifecycle.state = ExternalMcpListenerState::AuthFailure; + lifecycle.error = lifecycle.auth_failure.as_deref().map(sanitize_auth_failure); + self.emit_status(lifecycle); + return; + } + if !lifecycle.enabled { + let stop_error = self.stop_listener(lifecycle).await.err(); + lifecycle.state = ExternalMcpListenerState::Disabled; + lifecycle.error = stop_error; + self.emit_status(lifecycle); + return; + } + if !self.has_active_clients() { + let stop_error = self.stop_listener(lifecycle).await.err(); + lifecycle.state = ExternalMcpListenerState::Paused; + lifecycle.error = stop_error; + self.emit_status(lifecycle); + return; + } + if lifecycle.endpoint.is_some() { + lifecycle.state = ExternalMcpListenerState::Listening; + lifecycle.error = None; + self.emit_status(lifecycle); + return; + } + lifecycle.state = ExternalMcpListenerState::Starting; + lifecycle.error = None; + self.emit_status(lifecycle); + let listener = + match tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)).await { + Ok(listener) => listener, + Err(error) => { + lifecycle.state = ExternalMcpListenerState::PortConflict; + lifecycle.error = Some(format!( + "external MCP port {EXTERNAL_MCP_PORT} is unavailable: {error}" + )); + self.emit_status(lifecycle); + return; + } + }; + match bind_managed_gated_on( + listener, + self.components.dispatcher.clone(), + self.components.registry.clone(), + self.gate.clone(), + self.authorizer.clone(), + ) + .await + { + Ok(endpoint) => { + lifecycle.endpoint = Some(endpoint); + lifecycle.state = ExternalMcpListenerState::Listening; + lifecycle.error = None; + } + Err(error) => { + lifecycle.state = ExternalMcpListenerState::PortConflict; + lifecycle.error = Some(error.to_string()); + } + } + self.emit_status(lifecycle); + } + + async fn handle_catalog_failure(&self, lifecycle: &mut ExternalMcpLifecycle, error: &str) { + let ready = self.catalog.read().is_ok_and(|catalog| !catalog.pending); + if !ready { + lifecycle.auth_failure = Some(error.to_string()); + } + self.reconcile_listener(lifecycle).await; + } + + async fn stop_listener(&self, lifecycle: &mut ExternalMcpLifecycle) -> Result<(), String> { + let Some(endpoint) = lifecycle.endpoint.take() else { + return Ok(()); + }; + endpoint.shutdown(); + let result = endpoint.wait().await.map_err(|error| error.to_string()); + if let Err(error) = &result { + lifecycle.state = ExternalMcpListenerState::Paused; + lifecycle.error = Some(error.clone()); + self.emit_status(lifecycle); + } + result + } + + fn status_for(&self, lifecycle: &ExternalMcpLifecycle) -> ExternalMcpStatus { + let clients = self + .catalog + .read() + .map(|catalog| catalog.clients().to_vec()) + .unwrap_or_default(); + ExternalMcpStatus { + revision: self + .status_revision + .load(std::sync::atomic::Ordering::Acquire), + enabled: lifecycle.enabled, + state: lifecycle.state, + endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), + clients, + error: lifecycle.error.clone(), } } + + fn emit_status(&self, lifecycle: &ExternalMcpLifecycle) { + self.status_revision + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let status = self.status_for(lifecycle); + let sink = self + .status_sink + .read() + .ok() + .and_then(|sink| sink.as_ref().cloned()); + if let Some(sink) = sink { + sink(status); + } + } + + fn ensure_auth_ready(&self, lifecycle: &ExternalMcpLifecycle) -> Result<(), String> { + lifecycle.auth_failure.as_ref().map_or(Ok(()), |_| { + Err("external MCP authentication is unavailable".to_string()) + }) + } + + fn with_catalog_write( + &self, + operation: impl FnOnce(&mut ExternalMcpCatalog) -> Result, + ) -> Result { + let mut catalog = self + .catalog + .write() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())?; + operation(&mut catalog) + } + + fn catalog_root(&self) -> PathBuf { + self.catalog + .read() + .map(|catalog| catalog.root.clone()) + .unwrap_or_default() + } + + fn has_active_clients(&self) -> bool { + self.catalog.read().is_ok_and(|catalog| { + catalog + .clients() + .iter() + .any(|client| client.revoked_at.is_none()) + }) + } + + #[cfg(test)] + fn set_status_sink(&self, sink: ExternalMcpStatusSink) { + *self.status_sink.write().expect("external MCP status sink") = Some(sink); + } + + #[cfg(test)] + fn catalog_publish_count_for_test(&self) -> usize { + self.catalog + .read() + .expect("external MCP catalog") + .publish_count + .load(std::sync::atomic::Ordering::SeqCst) + } } impl ExternalMcpCatalog { + fn unavailable(app_data_dir: &Path, secrets: Arc) -> Self { + Self { + root: app_data_dir.join(CATALOG_DIRECTORY), + clients: Vec::new(), + secrets, + pending: true, + last_used_published_at: HashMap::new(), + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize::new(0), + } + } + pub(crate) fn load( app_data_dir: &Path, secrets: Arc, @@ -171,6 +558,14 @@ impl ExternalMcpCatalog { let clients = read_catalog(&path)?; let mut catalog = Self { root, + last_used_published_at: clients + .iter() + .filter_map(|client| { + client + .last_used_at + .map(|timestamp| (client.id.clone(), timestamp)) + }) + .collect(), clients, secrets, pending: false, @@ -178,6 +573,8 @@ impl ExternalMcpCatalog { fail_next_rename: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] fail_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize::new(0), }; catalog.recover_pending_commit()?; Ok(catalog) @@ -397,6 +794,31 @@ impl ExternalMcpCatalog { Ok(match_id) } + fn record_last_used(&mut self, client_id: &str) -> Result<(), String> { + self.ensure_ready()?; + let index = self.client_index(client_id)?; + let now = unix_timestamp()?; + let previous = self.clients[index].last_used_at; + let should_publish = self + .last_used_published_at + .get(client_id) + .is_none_or(|timestamp| { + now.saturating_sub(*timestamp) >= LAST_USED_WRITE_INTERVAL_SECS + }); + self.clients[index].last_used_at = Some(now); + if !should_publish { + return Ok(()); + } + let next = self.clients.clone(); + if let Err(error) = self.publish_clients(&next) { + self.clients[index].last_used_at = previous; + return Err(error.error); + } + self.last_used_published_at + .insert(client_id.to_string(), now); + Ok(()) + } + fn client_index(&self, client_id: &str) -> Result { self.clients .iter() @@ -456,13 +878,19 @@ impl ExternalMcpCatalog { } fn publish_clients(&self, clients: &[ExternalMcpClientSummary]) -> Result<(), PublishError> { - self.write_json_atomically( + let result = self.write_json_atomically( &self.metadata_path(), &PersistedCatalog { version: CATALOG_VERSION, clients: clients.to_vec(), }, - ) + ); + #[cfg(test)] + if result.is_ok() { + self.publish_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + result } fn write_json_atomically( @@ -586,6 +1014,47 @@ fn read_catalog(path: &Path) -> Result, String> { } } +fn read_preferences(root: &Path) -> Result { + match fs::read(root.join(PREFERENCES_FILE)) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP preferences: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(ExternalMcpPreferences::default()) + } + Err(error) => Err(format!("read external MCP preferences: {error}")), + } +} + +fn persist_preferences(root: &Path, preferences: ExternalMcpPreferences) -> Result<(), String> { + fs::create_dir_all(root) + .map_err(|error| format!("create external MCP preferences directory: {error}"))?; + let bytes = serde_json::to_vec_pretty(&preferences) + .map_err(|error| format!("encode external MCP preferences: {error}"))?; + let staging = root.join(format!(".preferences.{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|error| format!("create external MCP preferences staging file: {error}"))?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("write external MCP preferences staging file: {error}"))?; + fs::rename(&staging, root.join(PREFERENCES_FILE)) + .map_err(|error| format!("publish external MCP preferences: {error}"))?; + sync_parent_directory(root) + .map_err(|error| format!("sync external MCP preferences directory: {error}")) + })(); + if result.is_err() { + let _ = fs::remove_file(staging); + } + result +} + +fn sanitize_auth_failure(_error: &str) -> String { + "external MCP authentication is unavailable".to_string() +} + fn validate_pending(pending: &PendingCatalogCommit) -> Result<(), String> { if pending.target.version != CATALOG_VERSION { return Err("unsupported external MCP pending catalog version".to_string()); @@ -710,15 +1179,89 @@ fn unix_timestamp() -> Result { }) } +pub(crate) fn install_status_emitter( + app: &tauri::AppHandle, + state: &ExternalMcpState, +) { + let handle = app.clone(); + *state + .status_sink + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(move |status| { + if let Err(error) = handle.emit(EXTERNAL_MCP_STATUS_CHANGED, status) { + eprintln!("[mcp] could not emit external MCP status: {error}"); + } + })); +} + +#[tauri::command] +pub(crate) async fn external_mcp_status( + state: tauri::State<'_, ExternalMcpState>, +) -> Result { + Ok(state.status().await) +} + +#[tauri::command] +pub(crate) async fn external_mcp_set_enabled( + enabled: bool, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.set_enabled(enabled).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_pair( + name: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.pair(&name).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_regenerate( + client_id: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.regenerate(&client_id).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_revoke( + client_id: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.revoke(&client_id).await +} + +pub(crate) fn shutdown_on_exit(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let result = tauri::async_runtime::block_on(state.shutdown()); + if let Err(error) = result { + eprintln!("[mcp] external endpoint did not drain cleanly on application exit: {error}"); + } +} + #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use super::*; use crate::chat::ChatState; use crate::secret::{McpSecretStore, MemoryMcpSecretStore}; use opentake_agent::chat::ChatTurnGate; + static LIFECYCLE_PORT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + fn catalog_root() -> tempfile::TempDir { tempfile::tempdir().expect("create temporary application data directory") } @@ -746,6 +1289,407 @@ mod tests { (chat, external) } + fn lifecycle_state( + root: &tempfile::TempDir, + secrets: Arc, + ) -> ExternalMcpState { + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + ExternalMcpState::load(core, chat.external_mcp_components(), root.path(), secrets) + } + + async fn assert_fixed_port_available() { + let listener = + tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)) + .await + .expect("fixed external MCP port is available"); + drop(listener); + } + + #[tokio::test] + async fn lifecycle_disabled_startup_never_binds() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + + state.initialize().await; + + let status = state.status().await; + assert!(!status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::Disabled); + assert!(status.clients.is_empty()); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_disable_stops_an_active_listener_and_persists() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + + let status = state.set_enabled(false).await.expect("disable endpoint"); + + assert_eq!(status.state, ExternalMcpListenerState::Disabled); + assert_fixed_port_available().await; + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + assert_eq!( + restarted.status().await.state, + ExternalMcpListenerState::Disabled + ); + } + + #[tokio::test] + async fn lifecycle_catalog_recovery_failure_is_a_fail_closed_status() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let metadata = root.path().join(CATALOG_DIRECTORY).join(CATALOG_FILE); + std::fs::create_dir_all(metadata.parent().expect("catalog parent")) + .expect("create corrupt catalog directory"); + std::fs::write(&metadata, b"not-json").expect("write corrupt catalog"); + + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.initialize().await; + + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_eq!( + status.error.as_deref(), + Some("external MCP authentication is unavailable") + ); + assert!(status.clients.is_empty()); + assert_fixed_port_available().await; + assert!(state.pair("must fail closed").await.is_err()); + } + + #[tokio::test] + async fn lifecycle_durability_failure_transitions_to_auth_failure() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair first client"); + state + .catalog + .read() + .expect("external MCP catalog") + .fail_parent_sync_during_publish_for_test(); + + let error = state + .regenerate(&first.client.id) + .await + .expect_err("injected durability failure is reported"); + + assert!(error.contains("sync external MCP catalog directory")); + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_eq!( + status.error.as_deref(), + Some("external MCP authentication is unavailable") + ); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_enabled_restart_recovers_the_fixed_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let first = lifecycle_state(&root, secrets.clone()); + first.set_enabled(true).await.expect("persist enablement"); + first.pair("Claude Desktop").await.expect("pair client"); + assert_eq!( + first.status().await.state, + ExternalMcpListenerState::Listening + ); + first.shutdown().await.expect("drain first listener"); + + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + + let status = restarted.status().await; + assert!(status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::Listening); + restarted + .shutdown() + .await + .expect("drain restarted listener"); + } + + #[tokio::test] + async fn lifecycle_enabled_without_clients_stays_paused_and_unbound() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + + let status = state.set_enabled(true).await.expect("enable endpoint"); + + assert_eq!(status.state, ExternalMcpListenerState::Paused); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_port_conflict_is_reported_without_a_fallback_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let occupied = + tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)) + .await + .expect("occupy fixed port"); + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + + state + .pair("Claude Desktop") + .await + .expect("pair despite bind conflict"); + + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::PortConflict); + assert_eq!(status.endpoint, EXTERNAL_MCP_ENDPOINT); + drop(occupied); + state.shutdown().await.expect("shutdown conflicted state"); + } + + #[tokio::test] + async fn lifecycle_emits_starting_before_listening_without_a_token() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = events.clone(); + state.set_status_sink(Arc::new(move |status| { + captured.lock().expect("record status").push(status); + })); + state.set_enabled(true).await.expect("enable endpoint"); + + let receipt = state.pair("Claude Desktop").await.expect("pair and start"); + + let events = events.lock().expect("read statuses"); + let transitions = events.iter().map(|status| status.state).collect::>(); + assert!(transitions.windows(2).any(|states| { + states + == [ + ExternalMcpListenerState::Starting, + ExternalMcpListenerState::Listening, + ] + })); + let serialized = serde_json::to_string(&*events).expect("serialize status events"); + assert!(!serialized.contains(&receipt.bearer_token)); + assert!(events + .windows(2) + .all(|statuses| { statuses[1].revision == statuses[0].revision.saturating_add(1) })); + drop(events); + state.shutdown().await.expect("drain listener"); + } + + #[test] + fn lifecycle_pairing_receipt_is_the_only_dto_that_serializes_a_token() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let receipt = catalog.pair("Cursor").expect("pair client"); + let state = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + let lifecycle = state.lifecycle.blocking_lock(); + + let receipt_json = serde_json::to_string(&receipt).expect("serialize receipt"); + let status_json = serde_json::to_string(&state.status_for(&lifecycle)) + .expect("serialize sanitized status"); + + assert!(receipt_json.contains(&receipt.bearer_token)); + assert!(!status_json.contains(&receipt.bearer_token)); + assert!(!status_json.contains("bearerToken")); + } + + #[tokio::test] + async fn lifecycle_pair_while_enabled_starts_the_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + + let receipt = state.pair("Cursor").await.expect("pair client"); + + assert_eq!(receipt.endpoint, EXTERNAL_MCP_ENDPOINT); + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::Listening + ); + state.shutdown().await.expect("drain listener"); + } + + #[tokio::test] + async fn lifecycle_revoking_the_final_client_stops_the_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let receipt = state.pair("Cursor").await.expect("pair client"); + + let status = state + .revoke(&receipt.client.id) + .await + .expect("revoke client"); + + assert_eq!(status.state, ExternalMcpListenerState::Paused); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_revoke_cancels_the_revoked_rmcp_session() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair first client"); + let survivor = state.pair("Claude").await.expect("pair survivor"); + let client = reqwest::Client::new(); + let revoked_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &first.bearer_token, + "revoked-session", + ) + .await; + + state.revoke(&first.client.id).await.expect("revoke client"); + + let stale = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(&survivor.bearer_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", revoked_session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request with revoked rmcp session"); + assert!(!stale.status().is_success()); + let _survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; + state.shutdown().await.expect("drain listener"); + } + + #[tokio::test] + async fn lifecycle_regeneration_cancels_the_old_rmcp_session() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair client"); + let client = reqwest::Client::new(); + let old_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &first.bearer_token, + "old-session", + ) + .await; + + let regenerated = state + .regenerate(&first.client.id) + .await + .expect("regenerate credential"); + + let stale = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(®enerated.bearer_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", old_session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request with stale rmcp session"); + assert!(!stale.status().is_success()); + let _new_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + ®enerated.bearer_token, + "new-session", + ) + .await; + state.shutdown().await.expect("drain regenerated listener"); + } + + #[tokio::test] + async fn lifecycle_application_shutdown_drains_and_releases_the_port() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + + state.shutdown().await.expect("application exit drain"); + + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_last_used_updates_are_persisted_once_per_coalescing_window() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + let receipt = state.pair("Cursor").await.expect("pair client"); + let writes_before = state.catalog_publish_count_for_test(); + let client = reqwest::Client::new(); + + for name in ["first", "second", "third"] { + let _session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &receipt.bearer_token, + name, + ) + .await; + } + + assert!(state.status().await.clients[0].last_used_at.is_some()); + assert_eq!(state.catalog_publish_count_for_test(), writes_before + 1); + let reloaded = load_catalog(&root, secrets); + let persisted = reloaded.clients()[0] + .last_used_at + .expect("last-used timestamp persisted"); + let in_memory = state.status().await.clients[0] + .last_used_at + .expect("last-used timestamp visible"); + assert!(in_memory >= persisted); + state.shutdown().await.expect("drain listener"); + } + async fn initialize_managed_session( client: &reqwest::Client, url: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96f25595..a473edb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -225,22 +225,19 @@ pub fn run() { advanced_bridge.clone(), install_admission.clone(), ); - let external_mcp_catalog = external_mcp::ExternalMcpCatalog::load( - &app.path().app_data_dir().map_err(|error| { - std::io::Error::other(format!( - "could not resolve external MCP application data directory: {error}" - )) - })?, - Arc::new(opentake_gen::KeyringStore::new()), - ) - .map_err(std::io::Error::other)?; - let external_mcp_state = external_mcp::ExternalMcpState::new( - core.clone(), - chat_state.external_mcp_components(), - external_mcp_catalog, - ); - // Task 4 owns the explicit enable/pair listener lifecycle. This - // setup only shares the production gate and Agent tool universe. + let external_mcp_state = match app.path().app_data_dir() { + Ok(data_dir) => external_mcp::ExternalMcpState::load( + core.clone(), + chat_state.external_mcp_components(), + &data_dir, + Arc::new(opentake_gen::KeyringStore::new()), + ), + Err(error) => external_mcp::ExternalMcpState::auth_failure( + core.clone(), + chat_state.external_mcp_components(), + format!("could not resolve external MCP application data directory: {error}"), + ), + }; // A global favorite must never silently become a temporary file. // Keep the editor usable if app-data resolution fails, but make all @@ -285,6 +282,13 @@ pub fn run() { }); app.manage(chat_state); app.manage(external_mcp_state); + external_mcp::install_status_emitter( + app.handle(), + &app.state::(), + ); + tauri::async_runtime::block_on( + app.state::().initialize(), + ); app.manage(codex::CodexAuthState::default()); app.manage(MediaState::new_with_admission( engine, @@ -446,6 +450,11 @@ pub fn run() { chat::chat_sessions, chat::chat_session_set_open, chat::chat_cancel, + external_mcp::external_mcp_status, + external_mcp::external_mcp_set_enabled, + external_mcp::external_mcp_pair, + external_mcp::external_mcp_regenerate, + external_mcp::external_mcp_revoke, transcribe::transcribe_model_status, transcribe::download_transcribe_model, transcribe::transcribe_media, @@ -483,6 +492,9 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_app, _event| { + if matches!(&_event, RunEvent::Exit) { + external_mcp::shutdown_on_exit(_app); + } // A user-driven Quit must not interrupt bundle replacement. The // updater's own restart has a programmatic exit code and remains // allowed after both save barriers succeed. diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index f4b342c2..30909eb8 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -8,11 +8,46 @@ import { decodePlaybackCommandError, decodePlaybackFrameEvent, decodePrewarmResult, + externalMcpPair, + externalMcpRegenerate, + externalMcpRevoke, + externalMcpSetEnabled, + externalMcpStatus, getTimeline, + onExternalMcpStatusChanged, projectNew, projectOpen, } from "./api"; +describe("browser external MCP safety defaults", () => { + it("reports the endpoint disabled without inventing clients or credentials", async () => { + await expect(externalMcpStatus()).resolves.toEqual({ + revision: 0, + enabled: false, + state: "disabled", + endpoint: "http://127.0.0.1:19789/mcp", + clients: [], + error: null, + }); + }); + + it("rejects every durable pairing mutation outside the desktop shell", async () => { + await expect(externalMcpSetEnabled(true)).rejects.toThrow("desktop app"); + await expect(externalMcpPair("Cursor")).rejects.toThrow("desktop app"); + await expect(externalMcpRegenerate("client-1")).rejects.toThrow("desktop app"); + await expect(externalMcpRevoke("client-1")).rejects.toThrow("desktop app"); + }); + + it("returns a harmless listener disposer outside the desktop shell", async () => { + let calls = 0; + const dispose = await onExternalMcpStatusChanged(() => { + calls += 1; + }); + dispose(); + expect(calls).toBe(0); + }); +}); + describe("browser account scaffold defaults", () => { it("stays offline and performs no login outside the desktop shell", async () => { await expect(accountGetBackendUrl()).resolves.toBeNull(); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1b6f0deb..4c2c0f46 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -19,6 +19,8 @@ import type { ClipType, EditRequest, EditResult, + ExternalMcpPairingReceipt, + ExternalMcpStatus, GenerateCaptionsResult, GenerationLog, MediaList, @@ -117,6 +119,83 @@ async function ensureTauri(): Promise { // MARK: - Commands +const externalMcpBrowserStatus: ExternalMcpStatus = { + revision: 0, + enabled: false, + state: "disabled", + endpoint: "http://127.0.0.1:19789/mcp", + clients: [], + error: null, +}; + +function requireExternalMcpDesktop(): InvokeFn { + if (!invokeImpl) throw new Error("External MCP pairing requires the desktop app"); + return invokeImpl; +} + +export async function externalMcpStatus(): Promise { + await ensureTauri(); + if (invokeImpl) return invokeImpl("external_mcp_status"); + return externalMcpBrowserStatus; +} + +export async function externalMcpSetEnabled(enabled: boolean): Promise { + await ensureTauri(); + return requireExternalMcpDesktop()("external_mcp_set_enabled", { enabled }); +} + +export async function externalMcpPair(name: string): Promise { + await ensureTauri(); + return requireExternalMcpDesktop()("external_mcp_pair", { name }); +} + +export async function externalMcpRegenerate( + clientId: string, +): Promise { + await ensureTauri(); + return requireExternalMcpDesktop()("external_mcp_regenerate", { + clientId, + }); +} + +export async function externalMcpRevoke(clientId: string): Promise { + await ensureTauri(); + return requireExternalMcpDesktop()("external_mcp_revoke", { clientId }); +} + +/** + * Subscribe before fetching a fresh snapshot. Events cover future transitions; + * the status command closes the registration gap if an event was missed. + */ +export async function onExternalMcpStatusChanged( + handler: (status: ExternalMcpStatus) => void, +): Promise<() => void> { + await ensureTauri(); + if (!listenImpl || !invokeImpl) return () => {}; + let disposed = false; + let latestRevision = -1; + const publish = (status: ExternalMcpStatus) => { + if (!disposed && status.revision >= latestRevision) { + latestRevision = status.revision; + handler(status); + } + }; + const unlisten = await listenImpl("external_mcp_status_changed", (event) => { + publish(event.payload as ExternalMcpStatus); + }); + try { + const current = await invokeImpl("external_mcp_status"); + publish(current); + } catch (error) { + unlisten(); + throw error; + } + return () => { + disposed = true; + unlisten(); + }; +} + /** Check OpenTake's pinned GitHub release channel for a signed update. */ export async function checkForAppUpdate(): Promise { await ensureTauri(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d62c36f1..bb383756 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,6 +9,38 @@ export type ClipType = "video" | "audio" | "image" | "text" | "lottie"; export type Interpolation = "linear" | "hold" | "smooth"; export type TransitionKind = "crossDissolve"; +export type ExternalMcpListenerState = + | "disabled" + | "starting" + | "listening" + | "portConflict" + | "authFailure" + | "paused"; + +export interface ExternalMcpClientSummary { + id: string; + name: string; + tokenDigest: string; + createdAt: number; + lastUsedAt: number | null; + revokedAt: number | null; +} + +export interface ExternalMcpStatus { + revision: number; + enabled: boolean; + state: ExternalMcpListenerState; + endpoint: string; + clients: ExternalMcpClientSummary[]; + error: string | null; +} + +export interface ExternalMcpPairingReceipt { + client: ExternalMcpClientSummary; + endpoint: string; + bearerToken: string; +} + export interface Transition { fromClipId: string; toClipId: string; From 1ec4d9942d7fa4d975969183aac660ce1ec7ed22 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 21:50:15 +0800 Subject: [PATCH 10/61] feat(ui): add shared disclosure motion --- web/src/components/ui/Reveal.test.tsx | 221 ++++++++++++++++++++++++++ web/src/components/ui/Reveal.tsx | 164 +++++++++++++++++++ web/src/styles/components.css | 26 +++ web/src/styles/tokens.css | 8 + 4 files changed, 419 insertions(+) create mode 100644 web/src/components/ui/Reveal.test.tsx create mode 100644 web/src/components/ui/Reveal.tsx create mode 100644 web/src/styles/components.css diff --git a/web/src/components/ui/Reveal.test.tsx b/web/src/components/ui/Reveal.test.tsx new file mode 100644 index 00000000..bb697835 --- /dev/null +++ b/web/src/components/ui/Reveal.test.tsx @@ -0,0 +1,221 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Reveal } from "./Reveal"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +type ObserverRecord = { + callback: ResizeObserverCallback; + target: Element | null; +}; + +const observerRecords = new Set(); +let animationFrames: FrameRequestCallback[] = []; +let container: HTMLDivElement; +let root: Root; + +class TestResizeObserver implements ResizeObserver { + private readonly record: ObserverRecord; + + constructor(callback: ResizeObserverCallback) { + this.record = { callback, target: null }; + observerRecords.add(this.record); + } + + observe(target: Element) { + this.record.target = target; + this.emit(); + } + + unobserve() { + this.record.target = null; + } + + disconnect() { + observerRecords.delete(this.record); + this.record.target = null; + } + + emit() { + const target = this.record.target; + if (!target) return; + const height = (target as HTMLElement).scrollHeight; + this.record.callback( + [ + { + target, + contentRect: { height } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this, + ); + } +} + +function renderReveal( + open: boolean, + options: { height?: number; onExited?: () => void } = {}, +) { + const height = options.height ?? 48; + act(() => { + root.render( + + + , + ); + }); +} + +function flushAnimationFrame() { + const callbacks = animationFrames; + animationFrames = []; + act(() => callbacks.forEach((callback) => callback(0))); +} + +function setReducedMotion(matches: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation(() => ({ + matches, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + setReducedMotion(false); + vi.stubGlobal("ResizeObserver", TestResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function () { + return Number(this.firstElementChild?.getAttribute("data-height") ?? 0); + }); + document.documentElement.style.setProperty("--motion-disclosure-duration", "180ms"); + + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + observerRecords.clear(); + animationFrames = []; + document.documentElement.style.removeProperty("--motion-disclosure-duration"); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Reveal", () => { + it("mounts open content with the requested semantics", () => { + renderReveal(true); + + const disclosure = container.querySelector(".reveal"); + expect(disclosure?.id).toBe("details"); + expect(disclosure?.getAttribute("role")).toBe("status"); + expect(disclosure?.dataset.state).toBe("open"); + expect(disclosure?.textContent).toContain("More details"); + }); + + it("retains measured content for the exit and unmounts after the duration", async () => { + const onExited = vi.fn(); + renderReveal(true, { onExited }); + + renderReveal(false, { onExited }); + const closing = container.querySelector(".reveal"); + expect(closing?.dataset.state).toBe("closed"); + expect(closing?.style.getPropertyValue("--reveal-block-size")).toBe("48px"); + expect(closing?.textContent).toContain("More details"); + + await act(async () => vi.advanceTimersByTimeAsync(179)); + expect(container.querySelector(".reveal")).not.toBeNull(); + + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(container.querySelector(".reveal")).toBeNull(); + expect(onExited).toHaveBeenCalledTimes(1); + }); + + it("cancels a pending unmount when reopened rapidly", async () => { + const onExited = vi.fn(); + renderReveal(true, { onExited }); + renderReveal(false, { onExited }); + + await act(async () => vi.advanceTimersByTimeAsync(90)); + renderReveal(true, { onExited }); + flushAnimationFrame(); + await act(async () => vi.advanceTimersByTimeAsync(180)); + + expect(container.querySelector(".reveal")?.dataset.state).toBe("open"); + expect(container.textContent).toContain("More details"); + expect(onExited).not.toHaveBeenCalled(); + }); + + it("keeps a numeric measured block size and updates it when content resizes", () => { + renderReveal(true, { height: 48 }); + const disclosure = container.querySelector(".reveal")!; + const content = disclosure.querySelector(".reveal__content")!; + + expect(disclosure.style.getPropertyValue("--reveal-block-size")).toBe("48px"); + expect(disclosure.style.getPropertyValue("--reveal-block-size")).not.toBe("auto"); + expect(disclosure.style.getPropertyValue("--reveal-block-size")).not.toBe("0px"); + + content.firstElementChild?.setAttribute("data-height", "76"); + act(() => observerRecords.forEach((record) => record.target === content && record.callback( + [{ target: content, contentRect: { height: 76 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ))); + + expect(disclosure.style.getPropertyValue("--reveal-block-size")).toBe("76px"); + }); + + it("removes focus and interaction from content as soon as it is hidden", () => { + renderReveal(true); + const button = container.querySelector("button")!; + button.focus(); + expect(document.activeElement).toBe(button); + + renderReveal(false); + + const disclosure = container.querySelector(".reveal")!; + expect(document.activeElement).not.toBe(button); + expect(disclosure.getAttribute("aria-hidden")).toBe("true"); + expect(disclosure.hasAttribute("inert")).toBe(true); + }); + + it("unmounts synchronously when reduced motion is requested", () => { + setReducedMotion(true); + const onExited = vi.fn(); + renderReveal(true, { onExited }); + + renderReveal(false, { onExited }); + + expect(container.querySelector(".reveal")).toBeNull(); + expect(onExited).toHaveBeenCalledTimes(1); + }); + + it("opens synchronously when reduced motion is requested", () => { + setReducedMotion(true); + renderReveal(false); + + renderReveal(true); + + expect(container.querySelector(".reveal")?.dataset.state).toBe("open"); + expect(animationFrames).toHaveLength(0); + }); +}); diff --git a/web/src/components/ui/Reveal.tsx b/web/src/components/ui/Reveal.tsx new file mode 100644 index 00000000..ee25b540 --- /dev/null +++ b/web/src/components/ui/Reveal.tsx @@ -0,0 +1,164 @@ +import { + type AriaRole, + type CSSProperties, + type ReactNode, + useLayoutEffect, + useRef, + useState, +} from "react"; +import "../../styles/components.css"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; +const FALLBACK_DISCLOSURE_DURATION_MS = 180; + +type RevealStyle = CSSProperties & { + "--reveal-block-size": string; +}; + +export interface RevealProps { + open: boolean; + children: ReactNode; + id?: string; + role?: AriaRole; + onExited?: () => void; +} + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +function disclosureDurationMs(): number { + if (typeof window === "undefined") return FALLBACK_DISCLOSURE_DURATION_MS; + const value = window + .getComputedStyle(document.documentElement) + .getPropertyValue("--motion-disclosure-duration") + .trim(); + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return FALLBACK_DISCLOSURE_DURATION_MS; + return value.endsWith("ms") ? parsed : value.endsWith("s") ? parsed * 1_000 : parsed; +} + +export function Reveal({ open, children, id, role, onExited }: RevealProps) { + const [present, setPresent] = useState(open); + const [expanded, setExpanded] = useState(open); + const [blockSize, setBlockSize] = useState(0); + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const exitTimerRef = useRef(null); + const frameRef = useRef(null); + const onExitedRef = useRef(onExited); + onExitedRef.current = onExited; + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + if (open) wrapper.removeAttribute("inert"); + else wrapper.setAttribute("inert", ""); + }, [open, present]); + + useLayoutEffect(() => { + if (!present) return; + const content = contentRef.current; + if (!content) return; + + const measure = (height?: number) => { + const measured = Math.max(height ?? 0, content.scrollHeight); + setBlockSize((current) => (current === measured ? current : measured)); + }; + + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(([entry]) => measure(entry?.contentRect.height)); + observer.observe(content); + return () => observer.disconnect(); + }, [present]); + + useLayoutEffect(() => { + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = null; + } + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (open) { + if (!present) { + setExpanded(false); + setPresent(true); + return; + } + if (!expanded) { + if (prefersReducedMotion()) { + setExpanded(true); + } else { + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + setExpanded(true); + }); + } + } + return; + } + + if (!present) return; + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && contentRef.current?.contains(activeElement)) { + activeElement.blur(); + } + setExpanded(false); + + const duration = prefersReducedMotion() ? 0 : disclosureDurationMs(); + if (duration <= 0) { + setPresent(false); + onExitedRef.current?.(); + return; + } + + exitTimerRef.current = window.setTimeout(() => { + exitTimerRef.current = null; + setPresent(false); + onExitedRef.current?.(); + }, duration); + + return () => { + if (exitTimerRef.current !== null) { + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = null; + } + }; + }, [expanded, open, present]); + + useLayoutEffect( + () => () => { + if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current); + if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current); + }, + [], + ); + + if (!present) return null; + + const style: RevealStyle = { + "--reveal-block-size": `${blockSize}px`, + }; + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/web/src/styles/components.css b/web/src/styles/components.css new file mode 100644 index 00000000..bca8a89b --- /dev/null +++ b/web/src/styles/components.css @@ -0,0 +1,26 @@ +.reveal { + overflow: hidden; + block-size: 0; + opacity: 0; + translate: 0 -4px; + transition: + block-size var(--motion-disclosure-duration) var(--motion-disclosure-ease), + opacity var(--motion-disclosure-duration) var(--motion-disclosure-ease), + translate var(--motion-disclosure-duration) var(--motion-disclosure-ease); +} + +.reveal[data-state="open"] { + block-size: var(--reveal-block-size, 0); + opacity: 1; + translate: 0 0; +} + +.reveal__content { + min-block-size: 0; +} + +@media (prefers-reduced-motion: reduce) { + .reveal { + transition: none; + } +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 5045219f..dfeac429 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -145,6 +145,8 @@ /* §1.12 Animation (AppTheme.swift:282-285) */ --anim-hover: 150ms; --anim-transition: 200ms; + --motion-disclosure-duration: 180ms; + --motion-disclosure-ease: cubic-bezier(0.22, 1, 0.36, 1); --ease-out: cubic-bezier(0, 0, 0.58, 1); --ease-in-out: cubic-bezier(0.42, 0, 0.58, 1); @@ -203,3 +205,9 @@ --home-panel-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); --home-stage-inset: 8px; } + +@media (prefers-reduced-motion: reduce) { + :root { + --motion-disclosure-duration: 0ms; + } +} From 344e73eea1958097f56951091f82562718f62f6a Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 21:54:33 +0800 Subject: [PATCH 11/61] fix(mcp): harden external client lifecycle --- crates/opentake-agent/src/mcp/server.rs | 495 ++++++++++++-- src-tauri/src/external_mcp.rs | 851 +++++++++++++++++++++--- 2 files changed, 1234 insertions(+), 112 deletions(-) diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index a76c21e1..2f68c38c 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -13,6 +13,7 @@ //! - [`serve`] binds the loopback listener and runs the server. use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -84,11 +85,12 @@ impl DispatchAuthority { fn try_enter( &self, request_cancel: opentake_media::MediaCancelToken, + client: Option, ) -> Result, McpError> { match self { Self::Direct => Ok(None), Self::Gated { activity, .. } => activity - .try_enter(request_cancel) + .try_enter(request_cancel, client) .map(Some) .ok_or_else(turn_inactive_error), } @@ -126,7 +128,13 @@ struct DispatchActivity { struct DispatchActivityState { accepting: bool, active: usize, - request_cancellations: Vec, + invalidated: HashSet, + requests: Vec, +} + +struct ActiveDispatch { + client: Option, + cancel: opentake_media::MediaCancelToken, } impl DispatchActivity { @@ -135,7 +143,8 @@ impl DispatchActivity { state: Mutex::new(DispatchActivityState { accepting: true, active: 0, - request_cancellations: Vec::new(), + invalidated: HashSet::new(), + requests: Vec::new(), }), changed: tokio::sync::Notify::new(), }) @@ -144,16 +153,24 @@ impl DispatchActivity { fn try_enter( self: &Arc, request_cancel: opentake_media::MediaCancelToken, + client: Option, ) -> Option { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !state.accepting { + if !state.accepting + || client + .as_ref() + .is_some_and(|client| state.invalidated.contains(client)) + { return None; } state.active = state.active.saturating_add(1); - state.request_cancellations.push(request_cancel.clone()); + state.requests.push(ActiveDispatch { + client, + cancel: request_cancel.clone(), + }); Some(DispatchPermit { activity: self.clone(), request_cancel, @@ -181,8 +198,8 @@ impl DispatchActivity { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.accepting = false; - for request_cancel in &state.request_cancellations { - request_cancel.cancel(); + for request in &state.requests { + request.cancel.cancel(); } if state.active == 0 { self.changed.notify_one(); @@ -204,6 +221,51 @@ impl DispatchActivity { } } + fn invalidate_client(&self, client: &AuthenticatedMcpClient) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.invalidated.insert(client.clone()); + for request in &state.requests { + if request.client.as_ref() == Some(client) { + request.cancel.cancel(); + } + } + if !state + .requests + .iter() + .any(|request| request.client.as_ref() == Some(client)) + { + self.changed.notify_waiters(); + } + } + + fn restore_client(&self, client: &AuthenticatedMcpClient) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .invalidated + .remove(client); + } + + async fn wait_client_zero(&self, client: &AuthenticatedMcpClient) { + loop { + let changed = self.changed.notified(); + let active = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .requests + .iter() + .any(|request| request.client.as_ref() == Some(client)); + if !active { + return; + } + changed.await; + } + } + #[cfg(test)] fn active(&self) -> usize { self.state @@ -227,15 +289,13 @@ impl Drop for DispatchPermit { .unwrap_or_else(std::sync::PoisonError::into_inner); state.active = state.active.saturating_sub(1); if let Some(index) = state - .request_cancellations + .requests .iter() - .position(|tracked| tracked.same_instance(&self.request_cancel)) + .position(|tracked| tracked.cancel.same_instance(&self.request_cancel)) { - state.request_cancellations.swap_remove(index); - } - if state.active == 0 { - self.activity.changed.notify_one(); + state.requests.swap_remove(index); } + self.activity.changed.notify_waiters(); } } @@ -459,6 +519,7 @@ impl McpServer { name: String, args: Value, request_cancelled: CancellationToken, + client: Option, ) -> Result { if request_cancelled.is_cancelled() && matches!(&self.authority, DispatchAuthority::Gated { .. }) @@ -470,7 +531,7 @@ impl McpServer { .admission .try_enter(dispatch_admission_class(&name, &args))?; let cancel = opentake_media::MediaCancelToken::new(); - let permit = self.authority.try_enter(cancel.clone())?; + let permit = self.authority.try_enter(cancel.clone(), client)?; let dispatcher = self.dispatcher.clone(); let authority = self.authority.clone(); let worker_cancel = cancel.clone(); @@ -534,7 +595,12 @@ impl ServerHandler for McpServer { // rmcp cancels `context.ct` for the protocol's explicit // `notifications/cancelled`. This does not claim raw TCP disconnect // detection; it is the MCP cancellation semantic exposed by rmcp. - self.dispatch_tool(name, args, context.ct).await + let client = context + .extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .cloned(); + self.dispatch_tool(name, args, context.ct, client).await } } @@ -651,7 +717,7 @@ async fn localhost_guard( /// The external client authenticated for an MCP HTTP request. Credential /// generations distinguish a freshly regenerated long-lived credential from a /// prior credential for the same client identity. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct AuthenticatedMcpClient { pub client_id: Arc, pub credential_generation: u64, @@ -723,17 +789,154 @@ fn authentication_required() -> axum::response::Response { /// Authenticate every route before any MCP session is created. This boundary /// parses the bearer syntax once, delegates credential matching, and adds only /// the authenticated public identity to the request extensions. +#[derive(Clone)] +struct ManagedAuthorizationState { + authorizer: Arc, + sessions: Option>, +} + +struct ManagedClientSessions { + state: Mutex, + manager: Arc, +} + +#[derive(Default)] +struct ManagedClientSessionsState { + owners: HashMap, AuthenticatedMcpClient>, + invalidated: HashSet, +} + +impl ManagedClientSessions { + fn new( + manager: Arc, + ) -> Arc { + Arc::new(Self { + state: Mutex::new(ManagedClientSessionsState::default()), + manager, + }) + } + + fn permits(&self, session_id: &str, client: &AuthenticatedMcpClient) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + !state.invalidated.contains(client) + && state + .owners + .get(session_id) + .is_some_and(|owner| owner == client) + } + + fn bind(&self, session_id: Arc, client: AuthenticatedMcpClient) -> bool { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.invalidated.contains(&client) { + return false; + } + state.owners.insert(session_id, client); + true + } + + fn remove(&self, session_id: &str) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .owners + .remove(session_id); + } + + fn invalidate(&self, client: &AuthenticatedMcpClient) -> Vec> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.invalidated.insert(client.clone()); + let sessions = state + .owners + .iter() + .filter_map(|(session, owner)| (owner == client).then(|| session.clone())) + .collect::>(); + for session in &sessions { + state.owners.remove(session); + } + sessions + } + + fn restore(&self, client: &AuthenticatedMcpClient) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .invalidated + .remove(client); + } + + async fn close(&self, session_id: &Arc) -> Result<(), String> { + use rmcp::transport::streamable_http_server::session::SessionManager as _; + self.manager + .close_session(session_id) + .await + .map_err(|error| error.to_string()) + } +} + +fn unknown_managed_session() -> axum::response::Response { + use axum::response::IntoResponse; + ( + axum::http::StatusCode::NOT_FOUND, + "Not Found: Session not found", + ) + .into_response() +} + async fn bearer_authorization_guard( - axum::extract::State(authorizer): axum::extract::State>, + axum::extract::State(state): axum::extract::State, mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - if let Some(client) = - bearer_candidate(request.headers()).and_then(|token| authorizer.authorize(token)) - { - request.extensions_mut().insert(client); - next.run(request).await + let Some(client) = + bearer_candidate(request.headers()).and_then(|token| state.authorizer.authorize(token)) + else { + return authentication_required(); + }; + let session_id = request + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(Arc::::from); + if let (Some(sessions), Some(session_id)) = (&state.sessions, &session_id) { + if !sessions.permits(session_id, &client) { + return unknown_managed_session(); + } + } + let deleting = request.method() == axum::http::Method::DELETE; + request.extensions_mut().insert(client.clone()); + let response = next.run(request).await; + let Some(sessions) = state.sessions else { + return response; + }; + if let Some(session_id) = session_id { + if (deleting && response.status().is_success()) + || response.status() == axum::http::StatusCode::NOT_FOUND + { + sessions.remove(&session_id); + } + return response; + } + let Some(session_id) = response + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(Arc::::from) + else { + return response; + }; + if sessions.bind(session_id.clone(), client) { + response } else { + let _ = sessions.close(&session_id).await; authentication_required() } } @@ -1020,6 +1223,7 @@ fn build_gated_router_for_port( shutdown: CancellationToken, expected_port: u16, authorizer: Option>, + managed_sessions: Option>, ) -> axum::Router { use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{ @@ -1031,6 +1235,10 @@ fn build_gated_router_for_port( let mut config = StreamableHttpServerConfig::default(); config.cancellation_token = shutdown; let admission = DispatchAdmission::new(); + let session_manager = managed_sessions.as_ref().map_or_else( + || Arc::new(LocalSessionManager::default()), + |sessions| sessions.manager.clone(), + ); let service = StreamableHttpService::new( move || { Ok(McpServer::from_gated_dispatcher( @@ -1041,7 +1249,7 @@ fn build_gated_router_for_port( admission.clone(), )) }, - Arc::new(LocalSessionManager::default()), + session_manager, config, ); let service = ServiceBuilder::new() @@ -1063,7 +1271,10 @@ fn build_gated_router_for_port( )); match authorizer { Some(authorizer) => router.layer(axum::middleware::from_fn_with_state( - authorizer, + ManagedAuthorizationState { + authorizer, + sessions: managed_sessions, + }, bearer_authorization_guard, )), None => router, @@ -1188,6 +1399,7 @@ pub struct ManagedMcpEndpoint { shutdown: CancellationToken, activity: Arc, cancel_gate: Arc, + client_sessions: Arc, join: Option>>, closed: bool, } @@ -1205,6 +1417,32 @@ impl ManagedMcpEndpoint { self.shutdown.cancel(); } + /// Invalidate one exact credential generation, terminate only its rmcp + /// sessions, cancel only its admitted dispatch workers, and await their + /// drain without interrupting unrelated clients or listener admission. + pub async fn cancel_client( + &self, + client: &AuthenticatedMcpClient, + ) -> Result<(), ManagedMcpError> { + self.activity.invalidate_client(client); + let sessions = self.client_sessions.invalidate(client); + self.activity.wait_client_zero(client).await; + for session in sessions { + self.client_sessions + .close(&session) + .await + .map_err(|error| ManagedMcpError::Serve(std::io::Error::other(error)))?; + } + Ok(()) + } + + /// Re-admit a generation when durable credential mutation failed before + /// publication. Its prior sessions remain terminated; fresh ones may start. + pub fn restore_client(&self, client: &AuthenticatedMcpClient) { + self.client_sessions.restore(client); + self.activity.restore_client(client); + } + /// Complete shutdown safely after [`Self::shutdown`] has stopped admission. pub async fn wait(mut self) -> Result<(), ManagedMcpError> { self.shutdown(); @@ -1319,6 +1557,10 @@ pub async fn bind_managed_gated_on( .unwrap_or_default(); let activity = DispatchActivity::new(); let shutdown = CancellationToken::new(); + let session_manager = Arc::new( + rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(), + ); + let client_sessions = ManagedClientSessions::new(session_manager); let cancel_gate = gate.clone(); let router = build_gated_router_for_port( dispatcher, @@ -1328,6 +1570,7 @@ pub async fn bind_managed_gated_on( shutdown.clone(), bound_addr.port(), Some(authorizer), + Some(client_sessions.clone()), ); let listener_shutdown = shutdown.clone(); let join = tokio::spawn(async move { @@ -1340,6 +1583,7 @@ pub async fn bind_managed_gated_on( shutdown, activity, cancel_gate, + client_sessions, join: Some(join), closed: false, }) @@ -1399,6 +1643,7 @@ async fn bind_ephemeral_gated_on( shutdown.clone(), bound_addr.port(), Some(Arc::new(SingleBearerAuthorizer::new(bearer_token.clone()))), + None, ); let listener_shutdown = shutdown.clone(); let listener_stopped = stopped.clone(); @@ -1451,6 +1696,7 @@ pub async fn serve_gated_dispatcher( CancellationToken::new(), bound_addr.port(), None, + None, ); tracing::info!("MCP server listening on http://{bound_addr}/mcp"); axum::serve(listener, router).await @@ -2069,6 +2315,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect("authorized gate result"); @@ -2081,6 +2328,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect_err("stale gate must fail closed"); @@ -2101,7 +2349,12 @@ mod tests { let worker_cancel = request_cancel.clone(); let task = tokio::spawn(async move { worker_server - .dispatch_tool("get_timeline".into(), serde_json::json!({}), worker_cancel) + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + worker_cancel, + None, + ) .await }); @@ -2123,12 +2376,12 @@ mod tests { async fn stopping_admission_rejects_new_calls_and_waits_for_active_permit() { let activity = DispatchActivity::new(); let permit = activity - .try_enter(opentake_media::MediaCancelToken::new()) + .try_enter(opentake_media::MediaCancelToken::new(), None) .expect("first dispatch admitted"); activity.stop_accepting(); assert!( activity - .try_enter(opentake_media::MediaCancelToken::new()) + .try_enter(opentake_media::MediaCancelToken::new(), None) .is_none(), "new dispatch must be rejected" ); @@ -2183,6 +2436,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -2192,6 +2446,7 @@ mod tests { "get_media".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -2208,6 +2463,7 @@ mod tests { "list_folders".into(), serde_json::json!({}), CancellationToken::new(), + None, ), ) .await @@ -2263,6 +2519,7 @@ mod tests { "add_clips".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -2277,6 +2534,7 @@ mod tests { "remove_clips".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect_err("a second mutation must fail busy"); @@ -2289,6 +2547,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect("read admitted beside mutation"); @@ -2615,6 +2874,162 @@ mod tests { endpoint.wait().await.expect("stop managed endpoint"); } + #[tokio::test] + async fn managed_session_is_owned_by_the_authenticated_client_generation() { + let authorizer = Arc::new(TestBearerAuthorizer { + credentials: RwLock::new(vec![ + ( + "client-a-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("client-a"), + credential_generation: 1, + }, + ), + ( + "client-b-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("client-b"), + credential_generation: 1, + }, + ), + ]), + }); + let endpoint = bind_managed_test_endpoint(authorizer).await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let initialized = client + .post(&url) + .bearer_auth("client-a-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize client A session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("client A session id") + .clone(); + + let foreign = client + .post(&url) + .bearer_auth("client-b-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("attempt foreign session reuse"); + assert_eq!(foreign.status(), reqwest::StatusCode::NOT_FOUND); + + let owner = client + .post(&url) + .bearer_auth("client-a-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("use owned session"); + assert!(owner.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_cancel_client_terminates_only_target_sessions() { + let authorizer = Arc::new(TestBearerAuthorizer { + credentials: RwLock::new(vec![ + ( + "target-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("target"), + credential_generation: 1, + }, + ), + ( + "survivor-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("survivor"), + credential_generation: 1, + }, + ), + ]), + }); + let endpoint = bind_managed_test_endpoint(authorizer).await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let mut sessions = Vec::new(); + for token in ["target-token", "survivor-token"] { + let response = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize managed session"); + sessions.push( + response + .headers() + .get("mcp-session-id") + .expect("managed session id") + .clone(), + ); + } + + endpoint + .cancel_client(&AuthenticatedMcpClient { + client_id: Arc::from("target"), + credential_generation: 1, + }) + .await + .expect("cancel target client"); + + for (token, session, expected) in [ + ("target-token", &sessions[0], reqwest::StatusCode::NOT_FOUND), + ("survivor-token", &sessions[1], reqwest::StatusCode::OK), + ] { + let response = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("use managed session after selective cancellation"); + assert_eq!(response.status(), expected); + } + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + #[tokio::test] async fn managed_endpoint_keeps_loopback_origin_and_host_guards() { let token = "managed-loopback-credential"; @@ -2861,11 +3276,14 @@ mod tests { let router = axum::Router::new() .route("/identity", axum::routing::get(identity)) .layer(axum::middleware::from_fn_with_state( - Arc::new(TestBearerAuthorizer::with_credential( - "extension-token", - "external", - 9, - )) as Arc, + ManagedAuthorizationState { + authorizer: Arc::new(TestBearerAuthorizer::with_credential( + "extension-token", + "external", + 9, + )), + sessions: None, + }, bearer_authorization_guard, )); let response = router @@ -2892,11 +3310,14 @@ mod tests { let router = axum::Router::new() .route("/", axum::routing::get(|| async { "authorized" })) .layer(axum::middleware::from_fn_with_state( - Arc::new(TestBearerAuthorizer::with_credential( - "active-token", - "external", - 1, - )) as Arc, + ManagedAuthorizationState { + authorizer: Arc::new(TestBearerAuthorizer::with_credential( + "active-token", + "external", + 1, + )), + sessions: None, + }, bearer_authorization_guard, )); let subscriber = CapturingSubscriber::default(); diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 9221eff4..7f4fecd4 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -5,7 +5,7 @@ use std::{ net::Ipv4Addr, path::{Path, PathBuf}, sync::{atomic::AtomicU64, Arc, RwLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use opentake_agent::mcp::{ @@ -26,6 +26,7 @@ const CATALOG_DIRECTORY: &str = "external-mcp"; const CATALOG_FILE: &str = "clients.json"; const PENDING_FILE: &str = "clients.pending.json"; const PREFERENCES_FILE: &str = "preferences.json"; +const PREFERENCES_PENDING_FILE: &str = "preferences.pending.json"; const EXTERNAL_MCP_ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; const EXTERNAL_MCP_STATUS_CHANGED: &str = "external_mcp_status_changed"; const EXTERNAL_MCP_PORT: u16 = 19_789; @@ -157,31 +158,142 @@ pub(crate) struct ExternalMcpCatalog { } struct ExternalMcpCatalogAuthorizer { - catalog: Arc>, + credentials: RwLock>>, + last_use: Arc, +} + +struct CachedCredential { + token: String, + client: AuthenticatedMcpClient, +} + +#[derive(Default)] +struct LastUseTracker { + entries: std::sync::Mutex>, + changed: tokio::sync::Notify, +} + +#[derive(Clone)] +struct LastUseEntry { + latest: i64, + dirty: bool, + last_flushed: Option, +} + +struct LastUseWorker { + shutdown: tokio::sync::oneshot::Sender<()>, + join: tokio::task::JoinHandle>, +} + +struct ExternalMcpStatusBroadcaster { + revision: AtomicU64, + sink: RwLock>, + latest: RwLock>, } impl BearerAuthorizer for ExternalMcpCatalogAuthorizer { fn authorize(&self, candidate: &str) -> Option { - let mut catalog = self.catalog.write().ok()?; - let client_id = catalog.verify_candidate(candidate).ok()??; - if let Err(error) = catalog.record_last_used(&client_id) { - eprintln!("[mcp] could not persist external MCP client usage: {error}"); + let credentials = self.credentials.read().ok()?.clone(); + let client = credentials.iter().find_map(|credential| { + constant_time_eq(credential.token.as_bytes(), candidate.as_bytes()) + .then(|| credential.client.clone()) + })?; + if let Ok(now) = unix_timestamp() { + self.last_use.record(&client.client_id, now); } - Some(AuthenticatedMcpClient { - client_id: client_id.into(), - credential_generation: credential_generation(candidate), + Some(client) + } +} + +impl LastUseTracker { + fn record(&self, client_id: &str, now: i64) { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let entry = entries.entry(client_id.to_owned()).or_insert(LastUseEntry { + latest: now, + dirty: true, + last_flushed: None, + }); + entry.latest = entry.latest.max(now); + entry.dirty = true; + self.changed.notify_one(); + } + + fn due(&self, force: bool) -> HashMap { + let now = Instant::now(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter_map(|(client_id, entry)| { + (entry.dirty + && (force + || entry.last_flushed.is_none_or(|flushed| { + now.duration_since(flushed) + >= Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64) + }))) + .then(|| (client_id.clone(), entry.latest)) + }) + .collect() + } + + fn mark_flushed(&self, persisted: &HashMap) { + let now = Instant::now(); + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for (client_id, timestamp) in persisted { + if let Some(entry) = entries.get_mut(client_id) { + if entry.latest <= *timestamp { + entry.dirty = false; + } + entry.last_flushed = Some(now); + } + } + } +} + +impl ExternalMcpStatusBroadcaster { + fn new() -> Arc { + Arc::new(Self { + revision: AtomicU64::new(0), + sink: RwLock::new(None), + latest: RwLock::new(None), }) } + + fn publish(&self, mut status: ExternalMcpStatus) { + status.revision = self + .revision + .fetch_add(1, std::sync::atomic::Ordering::AcqRel) + + 1; + *self + .latest + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(status.clone()); + let sink = self + .sink + .read() + .ok() + .and_then(|sink| sink.as_ref().cloned()); + if let Some(sink) = sink { + sink(status); + } + } } pub(crate) struct ExternalMcpState { components: ExternalMcpComponents, catalog: Arc>, gate: Arc, - authorizer: Arc, + authorizer: Arc, + last_use_worker: tokio::sync::Mutex>, lifecycle: tokio::sync::Mutex, - status_revision: AtomicU64, - status_sink: RwLock>, + status: Arc, + preference_parent_sync_on_call: std::sync::atomic::AtomicUsize, } type ExternalMcpStatusSink = Arc; @@ -200,9 +312,12 @@ impl ExternalMcpState { components: ExternalMcpComponents, catalog: ExternalMcpCatalog, ) -> Self { + let credentials = catalog.load_active_credentials().unwrap_or_default(); let catalog = Arc::new(RwLock::new(catalog)); + let last_use = Arc::new(LastUseTracker::default()); let authorizer = Arc::new(ExternalMcpCatalogAuthorizer { - catalog: catalog.clone(), + credentials: RwLock::new(Arc::new(credentials)), + last_use, }); let gate = LiveProjectMcpGate::new(core.clone()); Self { @@ -210,6 +325,7 @@ impl ExternalMcpState { catalog, gate, authorizer, + last_use_worker: tokio::sync::Mutex::new(None), lifecycle: tokio::sync::Mutex::new(ExternalMcpLifecycle { enabled: false, state: ExternalMcpListenerState::Disabled, @@ -217,8 +333,8 @@ impl ExternalMcpState { auth_failure: None, endpoint: None, }), - status_revision: AtomicU64::new(0), - status_sink: RwLock::new(None), + status: ExternalMcpStatusBroadcaster::new(), + preference_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), } } @@ -230,6 +346,11 @@ impl ExternalMcpState { ) -> Self { let root = app_data_dir.join(CATALOG_DIRECTORY); let preferences = read_preferences(&root); + let enabled = preferences + .as_ref() + .map(|value| value.enabled) + .unwrap_or(false); + let preference_error = preferences.as_ref().err().cloned(); match ExternalMcpCatalog::load(app_data_dir, secrets.clone()) { Ok(catalog) if preferences.is_ok() => { let preferences = preferences.expect("checked external MCP preferences"); @@ -248,7 +369,7 @@ impl ExternalMcpState { state } catalog => { - let error = match (catalog.err(), preferences.err()) { + let error = match (catalog.err(), preference_error) { (Some(catalog), _) => catalog, (None, Some(preferences)) => preferences, (None, None) => "external MCP authentication is unavailable".to_string(), @@ -256,7 +377,7 @@ impl ExternalMcpState { let catalog = ExternalMcpCatalog::unavailable(app_data_dir, secrets); let mut state = Self::new(core, components, catalog); state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { - enabled: false, + enabled, state: ExternalMcpListenerState::AuthFailure, error: Some(sanitize_auth_failure(&error)), auth_failure: Some(error), @@ -287,6 +408,7 @@ impl ExternalMcpState { } pub(crate) async fn initialize(&self) { + self.ensure_last_use_worker().await; let mut lifecycle = self.lifecycle.lock().await; self.reconcile_listener(&mut lifecycle).await; } @@ -297,10 +419,24 @@ impl ExternalMcpState { } pub(crate) async fn set_enabled(&self, enabled: bool) -> Result { + self.ensure_last_use_worker().await; let mut lifecycle = self.lifecycle.lock().await; - self.ensure_auth_ready(&lifecycle)?; - persist_preferences(&self.catalog_root(), ExternalMcpPreferences { enabled })?; - lifecycle.enabled = enabled; + if enabled { + self.ensure_auth_ready(&lifecycle)?; + } + match persist_preferences( + &self.catalog_root(), + ExternalMcpPreferences { enabled }, + &self.preference_parent_sync_on_call, + ) { + Ok(()) => lifecycle.enabled = enabled, + Err(error) if error.published => { + lifecycle.enabled = enabled; + self.reconcile_listener(&mut lifecycle).await; + return Err(error.error); + } + Err(error) => return Err(error.error), + } self.reconcile_listener(&mut lifecycle).await; Ok(self.status_for(&lifecycle)) } @@ -315,6 +451,11 @@ impl ExternalMcpState { return Err(error); } }; + if let Err(error) = self.refresh_credentials() { + lifecycle.auth_failure = Some(error.clone()); + self.reconcile_listener(&mut lifecycle).await; + return Err(error); + } self.reconcile_listener(&mut lifecycle).await; Ok(receipt) } @@ -325,14 +466,26 @@ impl ExternalMcpState { ) -> Result { let mut lifecycle = self.lifecycle.lock().await; self.ensure_auth_ready(&lifecycle)?; - self.stop_listener(&mut lifecycle).await?; + let previous = self.active_client(client_id)?; + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint + .cancel_client(&previous) + .await + .map_err(|error| error.to_string())?; + } let receipt = match self.with_catalog_write(|catalog| catalog.regenerate(client_id)) { Ok(receipt) => receipt, Err(error) => { + if self.catalog.read().is_ok_and(|catalog| !catalog.pending) { + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint.restore_client(&previous); + } + } self.handle_catalog_failure(&mut lifecycle, &error).await; return Err(error); } }; + self.refresh_credentials()?; self.reconcile_listener(&mut lifecycle).await; Ok(receipt) } @@ -340,11 +493,23 @@ impl ExternalMcpState { pub(crate) async fn revoke(&self, client_id: &str) -> Result { let mut lifecycle = self.lifecycle.lock().await; self.ensure_auth_ready(&lifecycle)?; - self.stop_listener(&mut lifecycle).await?; + let previous = self.active_client(client_id)?; + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint + .cancel_client(&previous) + .await + .map_err(|error| error.to_string())?; + } if let Err(error) = self.with_catalog_write(|catalog| catalog.revoke(client_id)) { + if self.catalog.read().is_ok_and(|catalog| !catalog.pending) { + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint.restore_client(&previous); + } + } self.handle_catalog_failure(&mut lifecycle, &error).await; return Err(error); } + self.refresh_credentials()?; self.reconcile_listener(&mut lifecycle).await; Ok(self.status_for(&lifecycle)) } @@ -352,6 +517,9 @@ impl ExternalMcpState { pub(crate) async fn shutdown(&self) -> Result<(), String> { let mut lifecycle = self.lifecycle.lock().await; self.stop_listener(&mut lifecycle).await?; + drop(lifecycle); + self.stop_last_use_worker().await?; + let mut lifecycle = self.lifecycle.lock().await; if lifecycle.auth_failure.is_some() { lifecycle.state = ExternalMcpListenerState::AuthFailure; } else if lifecycle.enabled { @@ -387,6 +555,14 @@ impl ExternalMcpState { self.emit_status(lifecycle); return; } + if let Err(error) = self.refresh_credentials() { + let _ = self.stop_listener(lifecycle).await; + lifecycle.auth_failure = Some(error); + lifecycle.state = ExternalMcpListenerState::AuthFailure; + lifecycle.error = Some("external MCP authentication is unavailable".to_string()); + self.emit_status(lifecycle); + return; + } if lifecycle.endpoint.is_some() { lifecycle.state = ExternalMcpListenerState::Listening; lifecycle.error = None; @@ -460,7 +636,8 @@ impl ExternalMcpState { .unwrap_or_default(); ExternalMcpStatus { revision: self - .status_revision + .status + .revision .load(std::sync::atomic::Ordering::Acquire), enabled: lifecycle.enabled, state: lifecycle.state, @@ -471,17 +648,7 @@ impl ExternalMcpState { } fn emit_status(&self, lifecycle: &ExternalMcpLifecycle) { - self.status_revision - .fetch_add(1, std::sync::atomic::Ordering::AcqRel); - let status = self.status_for(lifecycle); - let sink = self - .status_sink - .read() - .ok() - .and_then(|sink| sink.as_ref().cloned()); - if let Some(sink) = sink { - sink(status); - } + self.status.publish(self.status_for(lifecycle)); } fn ensure_auth_ready(&self, lifecycle: &ExternalMcpLifecycle) -> Result<(), String> { @@ -517,9 +684,72 @@ impl ExternalMcpState { }) } + fn refresh_credentials(&self) -> Result<(), String> { + let credentials = self + .catalog + .read() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())? + .load_active_credentials()?; + *self + .authorizer + .credentials + .write() + .map_err(|_| "external MCP credential snapshot lock is unavailable".to_string())? = + Arc::new(credentials); + Ok(()) + } + + fn active_client(&self, client_id: &str) -> Result { + self.authorizer + .credentials + .read() + .map_err(|_| "external MCP credential snapshot lock is unavailable".to_string())? + .iter() + .find(|credential| credential.client.client_id.as_ref() == client_id) + .map(|credential| credential.client.clone()) + .ok_or_else(|| "external MCP client is unavailable".to_string()) + } + + async fn ensure_last_use_worker(&self) { + let mut worker = self.last_use_worker.lock().await; + if worker.is_some() { + return; + } + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let join = spawn_last_use_worker( + self.catalog.clone(), + self.authorizer.last_use.clone(), + self.status.clone(), + shutdown_rx, + ); + *worker = Some(LastUseWorker { shutdown, join }); + } + + async fn stop_last_use_worker(&self) -> Result<(), String> { + let Some(worker) = self.last_use_worker.lock().await.take() else { + return self.flush_last_use(true).await; + }; + let _ = worker.shutdown.send(()); + worker + .join + .await + .map_err(|error| format!("join external MCP last-use worker: {error}"))??; + Ok(()) + } + + async fn flush_last_use(&self, force: bool) -> Result<(), String> { + flush_last_use_blocking( + self.catalog.clone(), + self.authorizer.last_use.clone(), + self.status.clone(), + force, + ) + .await + } + #[cfg(test)] fn set_status_sink(&self, sink: ExternalMcpStatusSink) { - *self.status_sink.write().expect("external MCP status sink") = Some(sink); + *self.status.sink.write().expect("external MCP status sink") = Some(sink); } #[cfg(test)] @@ -530,6 +760,80 @@ impl ExternalMcpState { .publish_count .load(std::sync::atomic::Ordering::SeqCst) } + + #[cfg(test)] + async fn record_last_used_at_for_test( + &self, + client_id: &str, + timestamp: i64, + ) -> Result<(), String> { + self.authorizer.last_use.record(client_id, timestamp); + self.flush_last_use(false).await + } + + #[cfg(test)] + fn fail_preference_parent_sync_after_publish_for_test(&self) { + self.preference_parent_sync_on_call + .store(2, std::sync::atomic::Ordering::SeqCst); + } +} + +fn spawn_last_use_worker( + catalog: Arc>, + tracker: Arc, + status: Arc, + mut shutdown: tokio::sync::oneshot::Receiver<()>, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let mut interval = + tokio::time::interval(Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + interval.tick().await; + loop { + tokio::select! { + _ = &mut shutdown => { + return flush_last_use_blocking(catalog, tracker, status, true).await; + } + () = tracker.changed.notified() => {} + _ = interval.tick() => {} + } + flush_last_use_blocking(catalog.clone(), tracker.clone(), status.clone(), false) + .await?; + } + }) +} + +async fn flush_last_use_blocking( + catalog: Arc>, + tracker: Arc, + status: Arc, + force: bool, +) -> Result<(), String> { + let due = tracker.due(force); + if due.is_empty() { + return Ok(()); + } + let persisted = due.clone(); + let clients = tokio::task::spawn_blocking(move || { + let mut catalog = catalog + .write() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())?; + catalog.persist_last_used(&due)?; + Ok::<_, String>(catalog.clients().to_vec()) + }) + .await + .map_err(|error| format!("join external MCP last-use publication: {error}"))??; + tracker.mark_flushed(&persisted); + let latest = status + .latest + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(mut latest) = latest { + latest.clients = clients; + status.publish(latest); + } + Ok(()) } impl ExternalMcpCatalog { @@ -772,6 +1076,7 @@ impl ExternalMcpCatalog { Ok(()) } + #[cfg(test)] pub(crate) fn verify_candidate(&self, candidate: &str) -> Result, String> { self.ensure_ready()?; let mut match_id = None; @@ -794,28 +1099,51 @@ impl ExternalMcpCatalog { Ok(match_id) } - fn record_last_used(&mut self, client_id: &str) -> Result<(), String> { + fn load_active_credentials(&self) -> Result, String> { self.ensure_ready()?; - let index = self.client_index(client_id)?; - let now = unix_timestamp()?; - let previous = self.clients[index].last_used_at; - let should_publish = self - .last_used_published_at - .get(client_id) - .is_none_or(|timestamp| { - now.saturating_sub(*timestamp) >= LAST_USED_WRITE_INTERVAL_SECS - }); - self.clients[index].last_used_at = Some(now); - if !should_publish { - return Ok(()); + self.clients + .iter() + .filter(|client| client.revoked_at.is_none()) + .map(|client| { + let token = self + .secrets + .load_mcp_secret(&secret_account(&client.id))? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + if token_digest(&token) != client.token_digest { + return Err("external MCP client credential is invalid".to_string()); + } + Ok(CachedCredential { + client: AuthenticatedMcpClient { + client_id: client.id.clone().into(), + credential_generation: credential_generation(&token), + }, + token, + }) + }) + .collect() + } + + fn persist_last_used(&mut self, updates: &HashMap) -> Result<(), String> { + self.ensure_ready()?; + let previous = self.clients.clone(); + for (client_id, timestamp) in updates { + let index = self.client_index(client_id)?; + self.clients[index].last_used_at = Some( + self.clients[index] + .last_used_at + .unwrap_or(*timestamp) + .max(*timestamp), + ); } let next = self.clients.clone(); if let Err(error) = self.publish_clients(&next) { - self.clients[index].last_used_at = previous; + self.clients = previous; return Err(error.error); } - self.last_used_published_at - .insert(client_id.to_string(), now); + for (client_id, timestamp) in updates { + self.last_used_published_at + .insert(client_id.clone(), *timestamp); + } Ok(()) } @@ -1015,6 +1343,28 @@ fn read_catalog(path: &Path) -> Result, String> { } fn read_preferences(root: &Path) -> Result { + let pending = root.join(PREFERENCES_PENDING_FILE); + match fs::read(&pending) { + Ok(bytes) => { + let target: ExternalMcpPreferences = serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP pending preferences: {error}"))?; + let current = read_preferences_file(root)?; + if current != target { + persist_preferences(root, target, &std::sync::atomic::AtomicUsize::new(0)) + .map_err(|error| error.error)?; + } + fs::remove_file(&pending) + .map_err(|error| format!("clear external MCP pending preferences: {error}"))?; + sync_parent_directory(root) + .map_err(|error| format!("sync external MCP preferences directory: {error}"))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read external MCP pending preferences: {error}")), + } + read_preferences_file(root) +} + +fn read_preferences_file(root: &Path) -> Result { match fs::read(root.join(PREFERENCES_FILE)) { Ok(bytes) => serde_json::from_slice(&bytes) .map_err(|error| format!("read external MCP preferences: {error}")), @@ -1025,25 +1375,91 @@ fn read_preferences(root: &Path) -> Result { } } -fn persist_preferences(root: &Path, preferences: ExternalMcpPreferences) -> Result<(), String> { - fs::create_dir_all(root) - .map_err(|error| format!("create external MCP preferences directory: {error}"))?; - let bytes = serde_json::to_vec_pretty(&preferences) - .map_err(|error| format!("encode external MCP preferences: {error}"))?; +fn persist_preferences( + root: &Path, + preferences: ExternalMcpPreferences, + fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, +) -> Result<(), PublishError> { + fs::create_dir_all(root).map_err(|error| PublishError { + error: format!("create external MCP preferences directory: {error}"), + published: false, + })?; + let bytes = serde_json::to_vec_pretty(&preferences).map_err(|error| PublishError { + error: format!("encode external MCP preferences: {error}"), + published: false, + })?; + let pending = root.join(PREFERENCES_PENDING_FILE); + write_preference_file(root, &pending, &bytes, fail_parent_sync_on_call, false)?; let staging = root.join(format!(".preferences.{}.tmp", uuid::Uuid::new_v4())); - let result = (|| { + let result: Result<(), PublishError> = (|| { let mut file = OpenOptions::new() .write(true) .create_new(true) .open(&staging) - .map_err(|error| format!("create external MCP preferences staging file: {error}"))?; + .map_err(|error| PublishError { + error: format!("create external MCP preferences staging file: {error}"), + published: false, + })?; file.write_all(&bytes) .and_then(|()| file.sync_all()) - .map_err(|error| format!("write external MCP preferences staging file: {error}"))?; - fs::rename(&staging, root.join(PREFERENCES_FILE)) - .map_err(|error| format!("publish external MCP preferences: {error}"))?; - sync_parent_directory(root) - .map_err(|error| format!("sync external MCP preferences directory: {error}")) + .map_err(|error| PublishError { + error: format!("write external MCP preferences staging file: {error}"), + published: false, + })?; + fs::rename(&staging, root.join(PREFERENCES_FILE)).map_err(|error| PublishError { + error: format!("publish external MCP preferences: {error}"), + published: false, + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + })?; + fs::remove_file(&pending).map_err(|error| PublishError { + error: format!("clear external MCP pending preferences: {error}"), + published: true, + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(staging); + } + result +} + +fn write_preference_file( + root: &Path, + destination: &Path, + bytes: &[u8], + fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, + published: bool, +) -> Result<(), PublishError> { + let staging = root.join(format!(".preferences.{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|error| PublishError { + error: format!("create external MCP preferences staging file: {error}"), + published, + })?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| PublishError { + error: format!("write external MCP preferences staging file: {error}"), + published, + })?; + fs::rename(&staging, destination).map_err(|error| PublishError { + error: format!("publish external MCP preferences: {error}"), + published, + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + }) })(); if result.is_err() { let _ = fs::remove_file(staging); @@ -1051,6 +1467,26 @@ fn persist_preferences(root: &Path, preferences: ExternalMcpPreferences) -> Resu result } +fn sync_preference_parent( + root: &Path, + _fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, +) -> std::io::Result<()> { + #[cfg(test)] + if _fail_parent_sync_on_call + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |remaining| remaining.checked_sub(1), + ) + .is_ok_and(|remaining| remaining == 1) + { + return Err(std::io::Error::other( + "injected preference parent sync failure", + )); + } + sync_parent_directory(root) +} + fn sanitize_auth_failure(_error: &str) -> String { "external MCP authentication is unavailable".to_string() } @@ -1185,7 +1621,8 @@ pub(crate) fn install_status_emitter( ) { let handle = app.clone(); *state - .status_sink + .status + .sink .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(move |status| { if let Err(error) = handle.emit(EXTERNAL_MCP_STATUS_CHANGED, status) { @@ -1253,7 +1690,10 @@ pub(crate) fn shutdown_on_exit(app: &tauri::AppHandle) { #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; + use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }; use super::*; use crate::chat::ChatState; @@ -1262,13 +1702,65 @@ mod tests { static LIFECYCLE_PORT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[derive(Default)] + struct InstrumentedSecretStore { + values: Mutex>, + loads: AtomicUsize, + fail_loads: AtomicBool, + } + + impl InstrumentedSecretStore { + fn reset_loads(&self) { + self.loads.store(0, Ordering::SeqCst); + } + + fn load_count(&self) -> usize { + self.loads.load(Ordering::SeqCst) + } + + fn fail_loads(&self) { + self.fail_loads.store(true, Ordering::SeqCst); + } + } + + impl McpSecretStore for InstrumentedSecretStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.values + .lock() + .expect("instrumented secret values") + .insert(account.to_owned(), value.to_owned()); + Ok(()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + self.loads.fetch_add(1, Ordering::SeqCst); + if self.fail_loads.load(Ordering::SeqCst) { + return Err("injected secret-store load failure".to_string()); + } + Ok(self + .values + .lock() + .expect("instrumented secret values") + .get(account) + .cloned()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.values + .lock() + .expect("instrumented secret values") + .remove(account); + Ok(()) + } + } + fn catalog_root() -> tempfile::TempDir { tempfile::tempdir().expect("create temporary application data directory") } fn load_catalog( root: &tempfile::TempDir, - secrets: Arc, + secrets: Arc, ) -> ExternalMcpCatalog { ExternalMcpCatalog::load(root.path(), secrets) .expect("load catalog against the in-memory secret store") @@ -1291,7 +1783,7 @@ mod tests { fn lifecycle_state( root: &tempfile::TempDir, - secrets: Arc, + secrets: Arc, ) -> ExternalMcpState { let core = opentake_core::AppCore::new(); let chat = ChatState::new( @@ -1424,6 +1916,51 @@ mod tests { .expect("drain restarted listener"); } + #[tokio::test] + async fn lifecycle_missing_active_credential_fails_closed_before_bind() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let first = lifecycle_state(&root, secrets.clone()); + first.set_enabled(true).await.expect("enable endpoint"); + let paired = first.pair("Cursor").await.expect("pair client"); + first.shutdown().await.expect("stop first endpoint"); + secrets + .delete_mcp_secret(&secret_account(&paired.client.id)) + .expect("remove active credential"); + + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + + let status = restarted.status().await; + assert!(status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_secret_store_failure_drains_a_previously_listening_endpoint() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(InstrumentedSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::Listening + ); + secrets.fail_loads(); + + state.initialize().await; + + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + assert_fixed_port_available().await; + } + #[tokio::test] async fn lifecycle_enabled_without_clients_stays_paused_and_unbound() { let _port = LIFECYCLE_PORT.lock().await; @@ -1565,7 +2102,13 @@ mod tests { "revoked-session", ) .await; - + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; state.revoke(&first.client.id).await.expect("revoke client"); let stale = client @@ -1585,11 +2128,12 @@ mod tests { .await .expect("send request with revoked rmcp session"); assert!(!stale.status().is_success()); - let _survivor_session = initialize_managed_session( + assert_managed_session_usable( &client, EXTERNAL_MCP_ENDPOINT, &survivor.bearer_token, - "survivor-session", + &survivor_session, + 3, ) .await; state.shutdown().await.expect("drain listener"); @@ -1602,6 +2146,7 @@ mod tests { let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); state.set_enabled(true).await.expect("enable endpoint"); let first = state.pair("Cursor").await.expect("pair client"); + let survivor = state.pair("Claude").await.expect("pair survivor"); let client = reqwest::Client::new(); let old_session = initialize_managed_session( &client, @@ -1610,6 +2155,13 @@ mod tests { "old-session", ) .await; + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; let regenerated = state .regenerate(&first.client.id) @@ -1640,6 +2192,14 @@ mod tests { "new-session", ) .await; + assert_managed_session_usable( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + &survivor_session, + 3, + ) + .await; state.shutdown().await.expect("drain regenerated listener"); } @@ -1690,6 +2250,120 @@ mod tests { state.shutdown().await.expect("drain listener"); } + #[tokio::test] + async fn lifecycle_shutdown_flushes_the_latest_coalesced_last_used_value() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + let paired = state.pair("Cursor").await.expect("pair client"); + let t0 = 1_800_000_000; + state + .record_last_used_at_for_test(&paired.client.id, t0) + .await + .expect("record leading last-used value"); + state + .record_last_used_at_for_test(&paired.client.id, t0 + 30) + .await + .expect("record trailing last-used value"); + + state.shutdown().await.expect("flush and stop lifecycle"); + + let reloaded = load_catalog(&root, secrets); + assert_eq!(reloaded.clients()[0].last_used_at, Some(t0 + 30)); + } + + #[test] + fn lifecycle_authorizer_uses_only_the_validated_in_memory_snapshot() { + let root = catalog_root(); + let secrets = Arc::new(InstrumentedSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let paired = catalog.pair("Cursor").expect("pair client"); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let state = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + secrets.reset_loads(); + let writes_before = state.catalog_publish_count_for_test(); + + for _ in 0..3 { + assert!(state.authorizer.authorize(&paired.bearer_token).is_some()); + } + + assert_eq!(secrets.load_count(), 0, "authorization read the keychain"); + assert_eq!( + state.catalog_publish_count_for_test(), + writes_before, + "authorization synchronously published the catalog" + ); + } + + #[tokio::test] + async fn lifecycle_auth_failure_preserves_enabled_and_allows_persisted_disable() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let catalog_dir = root.path().join(CATALOG_DIRECTORY); + std::fs::create_dir_all(&catalog_dir).expect("create external MCP directory"); + persist_preferences( + &catalog_dir, + ExternalMcpPreferences { enabled: true }, + &std::sync::atomic::AtomicUsize::new(0), + ) + .expect("persist enabled preference"); + std::fs::write(catalog_dir.join(CATALOG_FILE), b"not-json").expect("write corrupt catalog"); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + + let failed = lifecycle_state(&root, secrets.clone()); + failed.initialize().await; + assert!(failed.status().await.enabled); + assert_eq!( + failed.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + assert!(failed.set_enabled(true).await.is_err()); + assert!(failed.pair("blocked").await.is_err()); + + let disabled = failed + .set_enabled(false) + .await + .expect("disable remains available while authentication is failed"); + assert!(!disabled.enabled); + assert_eq!(disabled.state, ExternalMcpListenerState::AuthFailure); + + let restarted = lifecycle_state(&root, secrets); + assert!(!restarted.status().await.enabled); + assert_eq!( + restarted.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + } + + #[tokio::test] + async fn lifecycle_preference_post_rename_sync_failure_converges_now_and_on_restart() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.fail_preference_parent_sync_after_publish_for_test(); + + let result = state.set_enabled(true).await; + assert!(result.is_err(), "injected durability failure is reported"); + assert!( + state.status().await.enabled, + "runtime follows published file" + ); + + let restarted = lifecycle_state(&root, secrets); + assert!( + restarted.status().await.enabled, + "restart recovers published target" + ); + } + async fn initialize_managed_session( client: &reqwest::Client, url: &str, @@ -1722,6 +2396,35 @@ mod tests { .clone() } + async fn assert_managed_session_usable( + client: &reqwest::Client, + url: &str, + token: &str, + session: &reqwest::header::HeaderValue, + id: u64, + ) { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request through surviving rmcp session"); + assert!( + response.status().is_success(), + "surviving session was cancelled" + ); + } + async fn call_managed_tool( client: &reqwest::Client, url: &str, @@ -1794,8 +2497,8 @@ mod tests { assert!(core.media().folders.is_empty()); } - #[test] - fn shared_catalog_authorizer_tracks_regenerated_credentials_without_exporting_them() { + #[tokio::test] + async fn shared_catalog_authorizer_tracks_regenerated_credentials_without_exporting_them() { let root = catalog_root(); let core = opentake_core::AppCore::new(); let chat = ChatState::new( @@ -1816,10 +2519,8 @@ mod tests { assert!(external.authorizer.authorize("wrong credential").is_none()); let regenerated = external - .catalog - .write() - .expect("write catalog") .regenerate(&first.client.id) + .await .expect("regenerate credential"); assert!(external.authorizer.authorize(&first.bearer_token).is_none()); let regenerated_client = external From a7e787d62854e1aae664f9ae5dafa295d342d3fc Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 22:39:26 +0800 Subject: [PATCH 12/61] fix(settings): keep only stable dark window layouts --- .../task-2-report.md | 58 +++++ web/src/App.lifecycle.test.tsx | 16 +- web/src/App.tsx | 3 +- .../SettingsView.interaction.test.tsx | 27 ++- web/src/components/settings/SettingsView.tsx | 138 +++++------ .../settings/SettingsView.visual.test.ts | 58 ++++- web/src/components/ui/Dropdown.tsx | 2 +- web/src/i18n/dict.ts | 20 +- web/src/store/settingsStore.test.ts | 227 ++++++++++++++++++ web/src/store/settingsStore.ts | 133 +++++----- 10 files changed, 528 insertions(+), 154 deletions(-) create mode 100644 .superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md create mode 100644 web/src/store/settingsStore.test.ts diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md new file mode 100644 index 00000000..31777875 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md @@ -0,0 +1,58 @@ +# Task 2 implementer report + +Status: DONE + +## Scope + +- Removed the persisted `Theme` setting, actions, startup initializer, Appearance theme control, and obsolete `Dropdown` theme reference. +- Startup now removes legacy and versioned theme keys, and retains the compatibility `data-theme="dark"` marker. +- Replaced Appearance with the two requested dark-layout radio cells: `深色 · 标准` and `深色 · 紧凑`. They are equal-width, contain no selected-state icon, and preserve label geometry. +- Made window resize transactional: state updates for immediate feedback, persistence commits after native success, failures restore the previous selection and show a toast. +- Serialized startup and user native resize requests. A stale request cannot overwrite a later selection; a post-size positioning failure restores the original native size and position. +- Preserved unrelated audit and external-MCP worktree changes. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts +``` + +Observed result: exit 1. The legacy `theme` value remained, `setWindowSize` returned `undefined` and persisted optimistically, failure did not restore the standard choice, and Appearance contained no radiogroup. + +### GREEN + +Commands: + +```text +pnpm -C web exec vitest run src/store/settingsStore.test.ts src/components/settings/SettingsView.visual.test.ts +pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts +pnpm -C web exec vitest run src/App.lifecycle.test.tsx +pnpm -C web build +git diff --check +``` + +Observed results: + +- Focused store/visual tests: 2 files, 16 tests passed. +- Web test command: 144 files, 1230 tests passed. The package script forwards selectors after `--` to Vitest, which executes the full suite. +- App lifecycle: 1 file, 12 tests passed. +- Production TypeScript/Vite build: exit 0. +- `git diff --check`: exit 0. + +## Self-review + +- Review found and this task fixed two native-geometry race/partial-failure issues: stale native operations are serialized, and a `setPosition` failure restores the original geometry. +- Tests cover migration, native-success persistence timing, failed resize rollback/toast, size/position partial failure, stale/later choices, startup-versus-selection serialization, keyboard radiogroup behavior, and visual geometry. +- The build retains existing ineffective-dynamic-import and >500 kB chunk warnings; no new build failure occurred. + +## Commit + +`fix(settings): keep only stable dark window layouts` + +## Concerns + +None blocking. diff --git a/web/src/App.lifecycle.test.tsx b/web/src/App.lifecycle.test.tsx index c5df4fe9..ac45d807 100644 --- a/web/src/App.lifecycle.test.tsx +++ b/web/src/App.lifecycle.test.tsx @@ -45,11 +45,14 @@ vi.mock("./components/preview/nativePlaybackSession", () => ({ stopNativePlaybackForProjectBoundary: srv.stopNativePlayback, })); vi.mock("./i18n", () => ({ initI18n: vi.fn() })); -vi.mock("./store/settingsStore", () => ({ +const settings = vi.hoisted(() => ({ initProxyPlayback: vi.fn(), - initTheme: vi.fn(), initWindowSize: vi.fn(), })); +vi.mock("./store/settingsStore", () => ({ + initProxyPlayback: settings.initProxyPlayback, + initWindowSize: settings.initWindowSize, +})); vi.mock("./hooks/useKeyboardShortcuts", () => ({ useKeyboardShortcuts: vi.fn() })); vi.mock("./components/preview/previewEngine", () => ({ useTimelinePlaybackEngine: vi.fn() })); vi.mock("./hooks/useAutosave", () => ({ useAutosave: vi.fn() })); @@ -131,6 +134,8 @@ describe("App lifecycle listeners", () => { srv.stopLibrarySync.mockReset(); srv.onGoHome.mockReset().mockResolvedValue(vi.fn()); srv.stopNativePlayback.mockReset().mockResolvedValue(undefined); + settings.initProxyPlayback.mockReset(); + settings.initWindowSize.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -166,6 +171,13 @@ describe("App lifecycle listeners", () => { expect(srv.stopMediaSync).toHaveBeenCalledOnce(); }); + it("initializes persisted dark-window preferences without a theme initializer", async () => { + await act(async () => root?.render()); + + expect(settings.initWindowSize).toHaveBeenCalledOnce(); + expect(settings.initProxyPlayback).toHaveBeenCalledOnce(); + }); + it("ignores an old go-home callback after its owning effect is disposed", async () => { const registration = deferred<() => void>(); let goHome: (() => void) | null = null; diff --git a/web/src/App.tsx b/web/src/App.tsx index 4e275c37..9d9f97ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,7 +18,7 @@ import { startMediaSync, stopMediaSync } from "./store/mediaStore"; import { startLibrarySync, stopLibrarySync } from "./store/libraryStore"; import { useEditorUiStore } from "./store/uiStore"; import { initI18n } from "./i18n"; -import { initProxyPlayback, initTheme, initWindowSize } from "./store/settingsStore"; +import { initProxyPlayback, initWindowSize } from "./store/settingsStore"; import { isTauri, onGoHome } from "./lib/api"; import { stopNativePlaybackForProjectBoundary } from "./components/preview/nativePlaybackSession"; import { useUpdateStore } from "./store/updateStore"; @@ -101,7 +101,6 @@ export default function App() { useEffect(() => { initI18n(); - initTheme(); initWindowSize(); initProxyPlayback(); const stopUpdateScheduler = isTauri diff --git a/web/src/components/settings/SettingsView.interaction.test.tsx b/web/src/components/settings/SettingsView.interaction.test.tsx index bafd6ac3..6f332fdc 100644 --- a/web/src/components/settings/SettingsView.interaction.test.tsx +++ b/web/src/components/settings/SettingsView.interaction.test.tsx @@ -56,7 +56,7 @@ function Harness() { beforeEach(() => { vi.clearAllMocks(); useEditorUiStore.setState({ settingsOpen: false, settingsPane: "general" }); - useSettingsStore.setState({ byokProvider: "anthropic" }); + useSettingsStore.setState({ byokProvider: "anthropic", windowSize: "standard" }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -178,6 +178,31 @@ it("keeps the compact proxy switch inside a 24px pointer target", async () => { expect(proxySwitch.style.height).toBe("16px"); }); +it("switches dark window layouts with an accessible radio group", async () => { + useEditorUiStore.setState({ settingsPane: "appearance" }); + await act(async () => root.render()); + await act(async () => container.querySelector("button")!.click()); + + const group = container.querySelector('[role="radiogroup"]'); + const choices = [...container.querySelectorAll('[role="radio"]')]; + expect(group?.getAttribute("aria-label")).toBe(t("settings.windowSize")); + expect(choices.map((choice) => choice.textContent?.trim())).toEqual(["深色 · 标准", "深色 · 紧凑"]); + expect(choices[0]?.getAttribute("aria-checked")).toBe("true"); + expect(choices[1]?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + choices[0]!.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + })); + }); + + expect(useSettingsStore.getState().windowSize).toBe("compact"); + expect(choices[1]?.getAttribute("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(choices[1]); +}); + it("keeps unauthenticated external MCP fail-closed while preserving official Codex guidance", async () => { useEditorUiStore.setState({ settingsPane: "mcp" }); await act(async () => root.render()); diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx index 66d33cdc..a6344f29 100644 --- a/web/src/components/settings/SettingsView.tsx +++ b/web/src/components/settings/SettingsView.tsx @@ -1,7 +1,7 @@ /** * Settings view. Reachable from both the Home sidebar and the editor title bar. * Panes (single scrollable page in this phase): General (language), Appearance - * (theme), Import, AI (BYOK), MCP, optional Account, and About. + * (dark window layout), Import, AI (BYOK), MCP, optional Account, and About. * Preferences persist via `settingsStore` / `i18nStore`; * the BYOK key is stored in the OS keychain via the `secret_*` Tauri commands * (see `lib/api.ts`) — the plaintext key never reaches this component's @@ -11,7 +11,6 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; import { Bot, - Check, Copy, Download, ExternalLink, @@ -30,7 +29,6 @@ import { Dropdown } from "../ui/Dropdown"; import { useT, useI18nStore, LOCALES } from "../../i18n"; import { useSettingsStore, - type Theme, type ByokProvider, type WindowSizeOpt, } from "../../store/settingsStore"; @@ -386,55 +384,6 @@ function Field({ ); } -/** Segmented control used for enum settings (language/theme). */ -function Segmented({ - value, - options, - onChange, -}: { - value: T; - options: Array<{ id: T; label: string }>; - onChange: (id: T) => void; -}) { - return ( -
- {options.map((opt) => { - const active = opt.id === value; - return ( - - ); - })} -
- ); -} - function GeneralPane() { const t = useT(); const locale = useI18nStore((s) => s.locale); @@ -485,39 +434,74 @@ function GeneralPane() { function AppearancePane() { const t = useT(); - const theme = useSettingsStore((s) => s.theme); - const setTheme = useSettingsStore((s) => s.setTheme); const windowSize = useSettingsStore((s) => s.windowSize); const setWindowSize = useSettingsStore((s) => s.setWindowSize); + const options: Array<{ id: WindowSizeOpt; label: string }> = [ + { id: "standard", label: t("settings.darkLayout.standard") }, + { id: "compact", label: t("settings.darkLayout.compact") }, + ]; + + const select = (size: WindowSizeOpt) => { + void setWindowSize(size); + }; return (
- value={theme} - options={[ - { id: "dark", label: t("settings.theme.dark") }, - { id: "light", label: t("settings.theme.light") }, - ]} - onChange={setTheme} - /> - } - /> - - value={windowSize} - options={[ - { id: "standard", label: t("settings.windowSize.standard") }, - { id: "compact", label: t("settings.windowSize.compact") }, - ]} - onChange={setWindowSize} - /> +
+ {options.map((option, index) => { + const active = option.id === windowSize; + return ( + + ); + })} +
} />
diff --git a/web/src/components/settings/SettingsView.visual.test.ts b/web/src/components/settings/SettingsView.visual.test.ts index 39dcd09c..9dffe922 100644 --- a/web/src/components/settings/SettingsView.visual.test.ts +++ b/web/src/components/settings/SettingsView.visual.test.ts @@ -1,7 +1,46 @@ +// @vitest-environment happy-dom + import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; import { describe, expect, it } from "vitest"; +import { useI18nStore } from "../../i18n"; +import { useSettingsStore } from "../../store/settingsStore"; +import { useEditorUiStore } from "../../store/uiStore"; +import { SettingsView } from "./SettingsView"; + +const settingsSource = readFileSync( + resolve(process.cwd(), "src/components/settings/SettingsView.tsx"), + "utf8", +); -const settingsSource = readFileSync(new URL("./SettingsView.tsx", import.meta.url), "utf8"); +function appearanceChoices(windowSize: "standard" | "compact"): Array<{ + text: string; + flex: string; + labelStyle: string; + svgCount: number; +}> { + useI18nStore.setState({ locale: "zh-CN" }); + useEditorUiStore.setState({ settingsPane: "appearance" }); + useSettingsStore.setState({ windowSize }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + act(() => root.render(createElement(SettingsView))); + const choices = [...container.querySelectorAll('[role="radio"]')].map((choice) => ({ + text: choice.textContent?.trim() ?? "", + flex: choice.style.flex, + labelStyle: choice.querySelector("span")?.getAttribute("style") ?? "", + svgCount: choice.querySelectorAll("svg").length, + })); + act(() => root.unmount()); + container.remove(); + return choices; +} + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; describe("SettingsView minimal embedded visual direction", () => { it("uses one unified settings surface without header divider", () => { @@ -15,9 +54,20 @@ describe("SettingsView minimal embedded visual direction", () => { expect(settingsSource).not.toContain("border: \"var(--bw-thin) solid var(--border-primary)\","); }); - it("keeps settings controls restrained instead of heavy bordered blocks", () => { - expect(settingsSource).toContain("settingsControlStyle"); - expect(settingsSource).toContain("background: \"var(--home-hover)\""); + it("keeps dark layout choices equal-sized with stable label geometry", () => { + const standard = appearanceChoices("standard"); + const compact = appearanceChoices("compact"); + + expect(standard).toHaveLength(2); + expect(standard.map((choice) => choice.text)).toEqual([ + "深色 · 标准", + "深色 · 紧凑", + ]); + expect(standard.map((choice) => choice.flex)).toEqual(["1 1 0px", "1 1 0px"]); + expect(standard.map((choice) => choice.svgCount)).toEqual([0, 0]); + expect(standard.map((choice) => choice.labelStyle)).toEqual( + compact.map((choice) => choice.labelStyle), + ); }); it("uses a wide settings window with a left sidebar", () => { diff --git a/web/src/components/ui/Dropdown.tsx b/web/src/components/ui/Dropdown.tsx index cca89012..e893cb61 100644 --- a/web/src/components/ui/Dropdown.tsx +++ b/web/src/components/ui/Dropdown.tsx @@ -6,7 +6,7 @@ * the caller (i18n language pack), so adding a locale needs no change here. * * Closes on outside click or Escape. Generic over the option id type so callers - * keep their narrow union (`Locale`, `Theme`, …). + * keep their narrow union (for example, `Locale`). */ import { useCallback, useEffect, useId, useRef, useState } from "react"; diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index d74b86b0..7831e79b 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -894,14 +894,12 @@ const zh: Dict = { "settings.languageDesc": "界面显示语言。", "settings.proxyPlayback": "优先使用代理媒体播放", "settings.proxyPlaybackDesc": "预览使用已生成的低分辨率副本;导出始终使用原始素材。", - "settings.theme": "主题", - "settings.themeDesc": "应用配色方案。", - "settings.theme.dark": "深色", - "settings.theme.light": "浅色", + "settings.darkLayout": "深色布局", + "settings.darkLayout.standard": "深色 · 标准", + "settings.darkLayout.compact": "深色 · 紧凑", "settings.windowSize": "窗口大小", "settings.windowSizeDesc": "应用窗口尺寸(缩小至 2/3 大小更适合小屏)。", - "settings.windowSize.standard": "标准 (1600x1000)", - "settings.windowSize.compact": "紧凑 (1066x666)", + "settings.windowSizeFailed": "无法调整窗口大小:{error}", "settings.defaultImportFolder": "默认导入文件夹", "settings.defaultImportFolderDesc": "导入对话框默认打开的位置。", "settings.chooseFolder": "选择…", @@ -1977,14 +1975,12 @@ const en: Dict = { "settings.languageDesc": "Interface display language.", "settings.proxyPlayback": "Prefer proxy media for playback", "settings.proxyPlaybackDesc": "Preview uses generated low-resolution copies; export always uses originals.", - "settings.theme": "Theme", - "settings.themeDesc": "Application color scheme.", - "settings.theme.dark": "Dark", - "settings.theme.light": "Light", + "settings.darkLayout": "Dark layout", + "settings.darkLayout.standard": "Dark · Standard", + "settings.darkLayout.compact": "Dark · Compact", "settings.windowSize": "Window Size", "settings.windowSizeDesc": "Application window dimension (shrink to 2/3 for smaller screens).", - "settings.windowSize.standard": "Standard (1600x1000)", - "settings.windowSize.compact": "Compact (1066x666)", + "settings.windowSizeFailed": "Could not resize the window: {error}", "settings.defaultImportFolder": "Default Import Folder", "settings.defaultImportFolderDesc": "Location the import dialog opens to by default.", "settings.chooseFolder": "Choose…", diff --git a/web/src/store/settingsStore.test.ts b/web/src/store/settingsStore.test.ts new file mode 100644 index 00000000..af3ac016 --- /dev/null +++ b/web/src/store/settingsStore.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const native = vi.hoisted(() => ({ + scaleFactor: vi.fn(), + innerSize: vi.fn(), + outerPosition: vi.fn(), + setPosition: vi.fn(), + setSize: vi.fn(), + setProxyPlaybackEnabled: vi.fn(), +})); + +vi.mock("../lib/api", () => ({ + isTauri: true, + setProxyPlaybackEnabled: native.setProxyPlaybackEnabled, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + scaleFactor: native.scaleFactor, + innerSize: native.innerSize, + outerPosition: native.outerPosition, + setPosition: native.setPosition, + setSize: native.setSize, + }), +})); + +vi.mock("@tauri-apps/api/dpi", () => ({ + LogicalSize: class LogicalSize { + constructor(public width: number, public height: number) {} + }, + LogicalPosition: class LogicalPosition { + constructor(public x: number, public y: number) {} + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +async function loadStores() { + vi.resetModules(); + const settings = await import("./settingsStore"); + const ui = await import("./uiStore"); + return { ...settings, ...ui }; +} + +beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + native.scaleFactor.mockReset().mockResolvedValue(1); + native.innerSize.mockReset().mockResolvedValue({ + toLogical: () => ({ width: 1600, height: 1000 }), + }); + native.outerPosition.mockReset().mockResolvedValue({ + toLogical: () => ({ x: 40, y: 60 }), + }); + native.setPosition.mockReset().mockResolvedValue(undefined); + native.setSize.mockReset().mockResolvedValue(undefined); + native.setProxyPlaybackEnabled.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("removes legacy and versioned theme preferences while retaining the dark token root", async () => { + localStorage.setItem("theme", "light"); + localStorage.setItem("theme:v1", "dark"); + localStorage.setItem("opentake.theme.v2", "light"); + document.documentElement.dataset.theme = "light"; + + const { useSettingsStore } = await loadStores(); + + expect(localStorage.getItem("theme")).toBeNull(); + expect(localStorage.getItem("theme:v1")).toBeNull(); + expect(localStorage.getItem("opentake.theme.v2")).toBeNull(); + expect(useSettingsStore.getState()).not.toHaveProperty("theme"); + expect(document.documentElement.dataset.theme).toBe("dark"); +}); + +it("awaits a native resize before persisting the selected dark compact layout", async () => { + const resize = deferred(); + native.setSize.mockReturnValueOnce(resize.promise); + const { useSettingsStore } = await loadStores(); + + const operation = useSettingsStore.getState().setWindowSize("compact"); + + expect(operation).toBeInstanceOf(Promise); + expect(useSettingsStore.getState().windowSize).toBe("compact"); + expect(localStorage.getItem("windowSize")).toBeNull(); + await vi.waitFor(() => expect(native.setSize).toHaveBeenCalledOnce()); + expect(native.setSize).toHaveBeenCalledWith(expect.objectContaining({ width: 1066, height: 666 })); + + resize.resolve(); + await operation; + + expect(localStorage.getItem("windowSize")).toBe("compact"); +}); + +it("restores the previous layout and reports a resize failure", async () => { + localStorage.setItem("windowSize", "standard"); + native.setSize.mockRejectedValueOnce(new Error("resize denied")); + const { useSettingsStore, useEditorUiStore } = await loadStores(); + + const operation = useSettingsStore.getState().setWindowSize("compact"); + + expect(useSettingsStore.getState().windowSize).toBe("compact"); + await operation; + + expect(useSettingsStore.getState().windowSize).toBe("standard"); + expect(localStorage.getItem("windowSize")).toBe("standard"); + expect(useEditorUiStore.getState().toast?.message).toContain("resize denied"); +}); + +it("does not move the native window when its size is rejected", async () => { + native.setSize.mockRejectedValueOnce(new Error("resize denied")); + const { useSettingsStore } = await loadStores(); + + await useSettingsStore.getState().setWindowSize("compact"); + + expect(native.setPosition).not.toHaveBeenCalled(); +}); + +it("restores native geometry when positioning a resized window fails", async () => { + native.setPosition + .mockRejectedValueOnce(new Error("position denied")) + .mockResolvedValueOnce(undefined); + const { useSettingsStore } = await loadStores(); + + await useSettingsStore.getState().setWindowSize("compact"); + + expect(native.setSize).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ width: 1066, height: 666 }), + ); + expect(native.setSize).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ width: 1600, height: 1000 }), + ); + expect(native.setPosition).toHaveBeenLastCalledWith(expect.objectContaining({ x: 40, y: 60 })); + expect(useSettingsStore.getState().windowSize).toBe("standard"); +}); + +it("serializes a later layout choice after an earlier native resize", async () => { + const firstResize = deferred(); + native.setSize + .mockReturnValueOnce(firstResize.promise) + .mockResolvedValueOnce(undefined); + const { useSettingsStore, useEditorUiStore } = await loadStores(); + + const first = useSettingsStore.getState().setWindowSize("compact"); + expect(first).toBeInstanceOf(Promise); + await vi.waitFor(() => expect(native.setSize).toHaveBeenCalledOnce()); + + const second = useSettingsStore.getState().setWindowSize("standard"); + expect(native.setSize).toHaveBeenCalledOnce(); + firstResize.resolve(); + await first; + await second; + + expect(useSettingsStore.getState().windowSize).toBe("standard"); + expect(localStorage.getItem("windowSize")).toBe("standard"); + expect(useEditorUiStore.getState().toast).toBeNull(); + expect(native.setSize).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ width: 1066, height: 666 }), + ); + expect(native.setSize).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ width: 1600, height: 1000 }), + ); +}); + +it("serializes a user layout selection after startup resize", async () => { + const startupResize = deferred(); + const startupPosition = deferred(); + native.setSize + .mockReturnValueOnce(startupResize.promise) + .mockResolvedValueOnce(undefined); + native.setPosition + .mockReturnValueOnce(startupPosition.promise) + .mockResolvedValueOnce(undefined); + const { initWindowSize, useSettingsStore } = await loadStores(); + + initWindowSize(); + await vi.waitFor(() => expect(native.setSize).toHaveBeenCalledOnce()); + const selection = useSettingsStore.getState().setWindowSize("compact"); + + expect(native.setSize).toHaveBeenCalledOnce(); + startupResize.resolve(); + await vi.waitFor(() => expect(native.setPosition).toHaveBeenCalledOnce()); + expect(native.setSize).toHaveBeenCalledOnce(); + startupPosition.resolve(); + await selection; + + expect(native.setSize).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ width: 1066, height: 666 }), + ); +}); + +it("ignores a stale resize failure while applying the later layout choice", async () => { + const firstResize = deferred(); + native.setSize + .mockReturnValueOnce(firstResize.promise) + .mockResolvedValueOnce(undefined); + const { useSettingsStore, useEditorUiStore } = await loadStores(); + + const first = useSettingsStore.getState().setWindowSize("compact"); + await vi.waitFor(() => expect(native.setSize).toHaveBeenCalledOnce()); + const second = useSettingsStore.getState().setWindowSize("standard"); + firstResize.reject(new Error("stale resize denied")); + await first; + await second; + + expect(useSettingsStore.getState().windowSize).toBe("standard"); + expect(localStorage.getItem("windowSize")).toBe("standard"); + expect(useEditorUiStore.getState().toast).toBeNull(); +}); diff --git a/web/src/store/settingsStore.ts b/web/src/store/settingsStore.ts index 4e5dd09d..7bb87d8b 100644 --- a/web/src/store/settingsStore.ts +++ b/web/src/store/settingsStore.ts @@ -1,6 +1,6 @@ /** * App-level settings (UI preferences only — never editing truth). Persisted to - * localStorage so they survive restarts: theme, the default folder the import + * localStorage so they survive restarts: the default folder the import * dialog opens to, and the BYOK provider choice. Only the *provider choice* is * stored here — the API key itself never touches this store or localStorage; it * lives in the OS keychain via the `secret_*` Tauri commands (see @@ -9,8 +9,9 @@ import { create } from "zustand"; import { isTauri, setProxyPlaybackEnabled as setProxyPlaybackEnabledNative } from "../lib/api"; +import { t } from "../i18n"; +import { useEditorUiStore } from "./uiStore"; -export type Theme = "dark" | "light"; export type ByokProvider = | "codex" | "anthropic" @@ -22,16 +23,26 @@ export type ByokProvider = export type WindowSizeOpt = "standard" | "compact"; const LS = { - theme: "theme", defaultImportFolder: "defaultImportFolder", byokProvider: "byokProvider", windowSize: "windowSize", proxyPlaybackEnabled: "proxyPlaybackEnabled", } as const; -function loadTheme(): Theme { - if (typeof localStorage === "undefined") return "dark"; - return localStorage.getItem(LS.theme) === "light" ? "light" : "dark"; +function isLegacyThemeKey(key: string): boolean { + return /^(?:opentake[._:-])?theme(?:[._:-]*(?:v(?:ersion)?|version)?[._:-]*\d+)?$/i.test(key); +} + +/** Remove obsolete theme preferences; the only shipped token set is dark. */ +export function migrateLegacyThemePreferences(): void { + if (typeof localStorage !== "undefined") { + const legacyKeys = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)) + .filter((key): key is string => key !== null && isLegacyThemeKey(key)); + legacyKeys.forEach((key) => localStorage.removeItem(key)); + } + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = "dark"; + } } function loadString(key: string): string | null { if (typeof localStorage === "undefined") return null; @@ -63,29 +74,32 @@ function persist(key: string, value: string | null) { } interface SettingsState { - theme: Theme; defaultImportFolder: string | null; byokProvider: ByokProvider; windowSize: WindowSizeOpt; proxyPlaybackEnabled: boolean; - setTheme: (theme: Theme) => void; setDefaultImportFolder: (path: string | null) => void; setByokProvider: (provider: ByokProvider) => void; - setWindowSize: (size: WindowSizeOpt) => void; + setWindowSize: (size: WindowSizeOpt) => Promise; setProxyPlaybackEnabled: (enabled: boolean) => void; } -export const useSettingsStore = create((set) => ({ - theme: loadTheme(), +let windowSizeRequest = 0; +let windowSizeQueue = Promise.resolve(); + +function enqueueWindowResize(size: WindowSizeOpt): Promise { + const operation = windowSizeQueue.then(() => applyWindowSize(size)); + windowSizeQueue = operation.catch(() => undefined); + return operation; +} + +migrateLegacyThemePreferences(); + +export const useSettingsStore = create((set, get) => ({ defaultImportFolder: loadString(LS.defaultImportFolder), byokProvider: loadProvider(), windowSize: loadWindowSize(), proxyPlaybackEnabled: loadProxyPlaybackEnabled(), - setTheme: (theme) => { - persist(LS.theme, theme); - applyTheme(theme); - set({ theme }); - }, setDefaultImportFolder: (defaultImportFolder) => { persist(LS.defaultImportFolder, defaultImportFolder); set({ defaultImportFolder }); @@ -94,10 +108,23 @@ export const useSettingsStore = create((set) => ({ persist(LS.byokProvider, byokProvider); set({ byokProvider }); }, - setWindowSize: (windowSize) => { - persist(LS.windowSize, windowSize); - void applyWindowSize(windowSize); + setWindowSize: async (windowSize) => { + const previousWindowSize = get().windowSize; + const request = ++windowSizeRequest; set({ windowSize }); + return enqueueWindowResize(windowSize).then( + () => { + if (request !== windowSizeRequest) return; + persist(LS.windowSize, windowSize); + }, + (error) => { + if (request !== windowSizeRequest) return; + set({ windowSize: previousWindowSize }); + persist(LS.windowSize, previousWindowSize); + const message = error instanceof Error ? error.message : String(error); + useEditorUiStore.getState().pushToast(t("settings.windowSizeFailed", { error: message })); + }, + ); }, setProxyPlaybackEnabled: (proxyPlaybackEnabled) => { persist(LS.proxyPlaybackEnabled, String(proxyPlaybackEnabled)); @@ -106,51 +133,47 @@ export const useSettingsStore = create((set) => ({ }, })); -/** Reflect the theme onto the document root so tokens can switch on it. */ -export function applyTheme(theme: Theme): void { - if (typeof document !== "undefined") { - document.documentElement.dataset.theme = theme; - } -} - /** Apply the window size (width: 1600x1000 or 1066x666 centered) dynamically in Tauri. */ export async function applyWindowSize(size: WindowSizeOpt): Promise { if (!isTauri) return; + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const { LogicalSize, LogicalPosition } = await import("@tauri-apps/api/dpi"); + const win = getCurrentWindow(); + const factor = await win.scaleFactor(); + + const targetWidth = size === "compact" ? 1066 : 1600; + const targetHeight = size === "compact" ? 666 : 1000; + + const physicalSize = await win.innerSize(); + const logicalSize = physicalSize.toLogical(factor); + + const physicalPos = await win.outerPosition(); + const logicalPos = physicalPos.toLogical(factor); + + const dw = logicalSize.width - targetWidth; + const dh = logicalSize.height - targetHeight; + + const newX = logicalPos.x + dw / 2; + const newY = logicalPos.y + dh / 2; + + await win.setSize(new LogicalSize(targetWidth, targetHeight)); try { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); - const { LogicalSize, LogicalPosition } = await import("@tauri-apps/api/dpi"); - const win = getCurrentWindow(); - const factor = await win.scaleFactor(); - - const targetWidth = size === "compact" ? 1066 : 1600; - const targetHeight = size === "compact" ? 666 : 1000; - - const physicalSize = await win.innerSize(); - const logicalSize = physicalSize.toLogical(factor); - - const physicalPos = await win.outerPosition(); - const logicalPos = physicalPos.toLogical(factor); - - const dw = logicalSize.width - targetWidth; - const dh = logicalSize.height - targetHeight; - - const newX = logicalPos.x + dw / 2; - const newY = logicalPos.y + dh / 2; - await win.setPosition(new LogicalPosition(newX, newY)); - await win.setSize(new LogicalSize(targetWidth, targetHeight)); - } catch (e) { - console.error("Failed to apply window size:", e); + } catch (error) { + try { + await win.setSize(new LogicalSize(logicalSize.width, logicalSize.height)); + await win.setPosition(new LogicalPosition(logicalPos.x, logicalPos.y)); + } catch (rollbackError) { + console.error("Failed to restore window geometry:", rollbackError); + } + throw error; } } -/** Apply the persisted theme and window size at startup. */ -export function initTheme(): void { - applyTheme(useSettingsStore.getState().theme); -} - export function initWindowSize(): void { - void applyWindowSize(useSettingsStore.getState().windowSize); + void enqueueWindowResize(useSettingsStore.getState().windowSize).catch((error) => { + console.error("Failed to apply window size:", error); + }); } export function initProxyPlayback(): void { From be077694dad76ce83b8db61e795511663a9ce955 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 22:45:09 +0800 Subject: [PATCH 13/61] fix(settings): remove legacy theme marker --- .../2026-08-13-beta5-interface-polish/task-2-report.md | 8 +++++++- web/src/App.lifecycle.test.tsx | 2 +- web/src/store/settingsStore.test.ts | 4 ++-- web/src/store/settingsStore.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md index 31777875..a15a7301 100644 --- a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md @@ -5,7 +5,7 @@ Status: DONE ## Scope - Removed the persisted `Theme` setting, actions, startup initializer, Appearance theme control, and obsolete `Dropdown` theme reference. -- Startup now removes legacy and versioned theme keys, and retains the compatibility `data-theme="dark"` marker. +- Startup now removes legacy and versioned theme keys without setting a document theme marker; dark tokens are defined directly at `:root`. - Replaced Appearance with the two requested dark-layout radio cells: `深色 · 标准` and `深色 · 紧凑`. They are equal-width, contain no selected-state icon, and preserve label geometry. - Made window resize transactional: state updates for immediate feedback, persistence commits after native success, failures restore the previous selection and show a toast. - Serialized startup and user native resize requests. A stale request cannot overwrite a later selection; a post-size positioning failure restores the original native size and position. @@ -56,3 +56,9 @@ Observed results: ## Concerns None blocking. + +## Follow-up review — legacy marker removal + +- A scoped re-review found the `data-theme="dark"` compatibility marker had no CSS consumer. Removed the write rather than retaining dead state. +- Updated migration and App lifecycle test naming to assert no document theme marker or initializer remains. +- Follow-up verification is recorded in commit `fix(settings): remove legacy theme marker`. diff --git a/web/src/App.lifecycle.test.tsx b/web/src/App.lifecycle.test.tsx index ac45d807..4482a4dc 100644 --- a/web/src/App.lifecycle.test.tsx +++ b/web/src/App.lifecycle.test.tsx @@ -171,7 +171,7 @@ describe("App lifecycle listeners", () => { expect(srv.stopMediaSync).toHaveBeenCalledOnce(); }); - it("initializes persisted dark-window preferences without a theme initializer", async () => { + it("initializes persisted window preferences without a theme initializer", async () => { await act(async () => root?.render()); expect(settings.initWindowSize).toHaveBeenCalledOnce(); diff --git a/web/src/store/settingsStore.test.ts b/web/src/store/settingsStore.test.ts index af3ac016..1fe1ebdd 100644 --- a/web/src/store/settingsStore.test.ts +++ b/web/src/store/settingsStore.test.ts @@ -71,7 +71,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -it("removes legacy and versioned theme preferences while retaining the dark token root", async () => { +it("removes legacy and versioned theme preferences without a document theme marker", async () => { localStorage.setItem("theme", "light"); localStorage.setItem("theme:v1", "dark"); localStorage.setItem("opentake.theme.v2", "light"); @@ -83,7 +83,7 @@ it("removes legacy and versioned theme preferences while retaining the dark toke expect(localStorage.getItem("theme:v1")).toBeNull(); expect(localStorage.getItem("opentake.theme.v2")).toBeNull(); expect(useSettingsStore.getState()).not.toHaveProperty("theme"); - expect(document.documentElement.dataset.theme).toBe("dark"); + expect(document.documentElement.dataset.theme).toBeUndefined(); }); it("awaits a native resize before persisting the selected dark compact layout", async () => { diff --git a/web/src/store/settingsStore.ts b/web/src/store/settingsStore.ts index 7bb87d8b..6a3d9a7a 100644 --- a/web/src/store/settingsStore.ts +++ b/web/src/store/settingsStore.ts @@ -41,7 +41,7 @@ export function migrateLegacyThemePreferences(): void { legacyKeys.forEach((key) => localStorage.removeItem(key)); } if (typeof document !== "undefined") { - document.documentElement.dataset.theme = "dark"; + delete document.documentElement.dataset.theme; } } function loadString(key: string): string | null { From a1d510017644d6b8b237d8e8abb8de8f0e1e99a7 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 22:54:36 +0800 Subject: [PATCH 14/61] fix(settings): animate model removal confirmation --- .../task-3-report.md | 58 ++++++ .../components/settings/StoragePane.test.tsx | 127 +++++++++++- web/src/components/settings/StoragePane.tsx | 193 +++++++++++------- web/src/styles/components.css | 24 +++ 4 files changed, 313 insertions(+), 89 deletions(-) create mode 100644 .superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md new file mode 100644 index 00000000..cd7615f7 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md @@ -0,0 +1,58 @@ +# Task 3 implementer report + +Status: DONE + +## Scope + +- Replaced direct model-confirmation insertion with the approved shared `Reveal` inside the fixed models row. +- Kept the normal clear control mounted and disabled while confirmation/deletion is active. +- Preserved disclosure content through its exit lifecycle; cancel restores focus to the model clear control and successful deletion focuses the next available clear control. +- Kept model-specific backend errors visible inside the still-open confirmation; successful/cancelled flows close through `Reveal`, including synchronous reduced-motion closure. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/components/settings/StoragePane.test.tsx +``` + +Observed result: exit 1 with five expected interaction/lifecycle failures. The pre-change direct conditional confirmation had no model-row disclosure wrapper, did not retain copy for exit, did not restore focus after cancel, and had no reduced-motion lifecycle. + +### GREEN + +Command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx src/components/ui/Reveal.test.tsx +``` + +Observed result: exit 0; 2 test files and 20 tests passed. + +Command: + +```text +pnpm -C web build +``` + +Observed result: exit 0. Existing dynamic-import and bundle-size warnings remain unchanged. + +Command: + +```text +git diff --check +``` + +Observed result: exit 0, no whitespace errors. + +## Self-review + +- The disclosure is a child of the models category row, so sibling category structure remains stable and the measured wrapper owns vertical movement. +- Clear, remove, and cancel actions all obey the in-flight lock; failed model deletion leaves the confirmation active for an actionable retry. +- The tests cover disclosure placement/no duplicate confirmation, exit retention, cancel and success closure, deletion locking, error placement, focus behavior, and reduced motion. + +## Commit + +`b4268fa fix(settings): animate model removal confirmation` diff --git a/web/src/components/settings/StoragePane.test.tsx b/web/src/components/settings/StoragePane.test.tsx index 9d650b2f..3b6c7973 100644 --- a/web/src/components/settings/StoragePane.test.tsx +++ b/web/src/components/settings/StoragePane.test.tsx @@ -54,6 +54,8 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); }); const render = async () => { @@ -63,6 +65,17 @@ const render = async () => { const clearButton = (category: string) => container.querySelector(`[data-category="${category}"][data-action="clear"]`)!; +const modelRow = () => container.querySelector('[data-storage-row="models"]')!; + +const reveal = () => modelRow().querySelector(".reveal"); + +const confirmationButton = (action: "confirm-remove" | "confirm-cancel") => + container.querySelector(`[data-category="models"][data-action="${action}"]`)!; + +const openModelConfirmation = async () => { + await act(async () => clearButton("models").click()); +}; + it("shows a loading state while the usage report is in flight", async () => { api.usage.mockReturnValue(new Promise(() => undefined)); await render(); @@ -103,32 +116,124 @@ it("clears a non-model category immediately and adopts the fresh snapshot", asyn expect(clearButton("thumbnails").disabled).toBe(true); }); +it("reveals one model confirmation inside its row so sibling layout follows the disclosure", async () => { + api.usage.mockResolvedValue(usageFixture()); + await render(); + await act(async () => undefined); + + const siblingRow = modelRow().nextElementSibling; + await openModelConfirmation(); + + expect(api.clear).not.toHaveBeenCalled(); + expect(reveal()?.textContent).toContain(t("storage.clearConfirmTitle")); + expect(reveal()?.textContent).toContain(t("storage.clearConfirmBody")); + expect(modelRow().contains(reveal())).toBe(true); + expect(modelRow().nextElementSibling).toBe(siblingRow); + expect(siblingRow?.getAttribute("data-storage-row")).toBe("other"); + + await act(async () => clearButton("models").click()); + expect(modelRow().querySelectorAll('[data-action="confirm-remove"]')).toHaveLength(1); +}); + +it("keeps confirmation copy mounted through the cancel exit and restores focus to clear", async () => { + vi.useFakeTimers(); + api.usage.mockResolvedValue(usageFixture()); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + const cancel = confirmationButton("confirm-cancel"); + cancel.focus(); + await act(async () => cancel.click()); + + expect(document.activeElement).toBe(clearButton("models")); + expect(reveal()?.dataset.state).toBe("closed"); + expect(reveal()?.textContent).toContain(t("storage.clearConfirmBody")); + + await act(async () => vi.advanceTimersByTimeAsync(179)); + expect(reveal()).not.toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(reveal()).toBeNull(); +}); + +it("closes after successful model deletion and moves focus to the next available clear action", async () => { + vi.useFakeTimers(); + api.usage.mockResolvedValue(usageFixture()); + let resolveClear: (usage: UsageFixture) => void; + api.clear.mockReturnValue(new Promise((resolve) => { + resolveClear = resolve; + })); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + await act(async () => confirmationButton("confirm-remove").click()); + expect(confirmationButton("confirm-remove").disabled).toBe(true); + + await act(async () => { + resolveClear({ + ...usageFixture(), + categories: usageFixture().categories.map((category) => + category.id === "models" ? { ...category, bytes: 0 } : category, + ), + totalBytes: 460, + }); + }); + + expect(api.clear).toHaveBeenCalledWith(["models"], true); + expect(reveal()?.dataset.state).toBe("closed"); + expect(document.activeElement).toBe(clearButton("other")); + await act(async () => vi.advanceTimersByTimeAsync(180)); + expect(reveal()).toBeNull(); +}); + +it("keeps a model-clear backend failure visible in the open confirmation", async () => { + api.usage.mockResolvedValue(usageFixture()); + api.clear.mockRejectedValue("model removal failed"); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + await act(async () => confirmationButton("confirm-remove").click()); + + expect(reveal()?.dataset.state).toBe("open"); + expect(reveal()?.querySelector('[role="alert"]')?.textContent).toContain("model removal failed"); +}); + +it("removes the confirmation synchronously when reduced motion is requested", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation(() => ({ matches: true })), + ); + api.usage.mockResolvedValue(usageFixture()); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + expect(reveal()?.dataset.state).toBe("open"); + await act(async () => confirmationButton("confirm-cancel").click()); + expect(reveal()).toBeNull(); +}); + it("requires an explicit confirm step before clearing models", async () => { api.usage.mockResolvedValue(usageFixture()); api.clear.mockResolvedValue({ ...usageFixture(), totalBytes: 460 }); await render(); await act(async () => undefined); - await act(async () => clearButton("models").click()); + await openModelConfirmation(); expect(api.clear).not.toHaveBeenCalled(); - expect(container.textContent).toContain(t("storage.clearConfirmTitle")); - expect(container.textContent).toContain(t("storage.clearConfirmBody")); // Cancelling leaves the models untouched. await act(async () => { - container - .querySelector('[data-category="models"][data-action="confirm-cancel"]')! - .click(); + confirmationButton("confirm-cancel").click(); }); expect(api.clear).not.toHaveBeenCalled(); - expect(container.textContent).not.toContain(t("storage.clearConfirmTitle")); // Confirming removes them with the gate flag set. - await act(async () => clearButton("models").click()); + await openModelConfirmation(); await act(async () => { - container - .querySelector('[data-category="models"][data-action="confirm-remove"]')! - .click(); + confirmationButton("confirm-remove").click(); }); expect(api.clear).toHaveBeenCalledWith(["models"], true); }); diff --git a/web/src/components/settings/StoragePane.tsx b/web/src/components/settings/StoragePane.tsx index 7e42e890..1e0c3164 100644 --- a/web/src/components/settings/StoragePane.tsx +++ b/web/src/components/settings/StoragePane.tsx @@ -14,13 +14,14 @@ * an honest empty report and this pane renders its unsupported state. */ -import { useEffect, useState, type CSSProperties } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import { HardDrive } from "lucide-react"; import { useT } from "../../i18n"; import { storageClear, storageUsage } from "../../lib/api"; import type { StorageCategoryId, StorageUsage } from "../../lib/types"; import { formatBytes } from "../../lib/storageFormat"; import { Icon } from "../ui/Icon"; +import { Reveal } from "../ui/Reveal"; const controlStyle: CSSProperties = { background: "var(--home-hover)", @@ -75,6 +76,8 @@ export function StoragePane() { const [error, setError] = useState(null); const [clearing, setClearing] = useState(null); const [confirming, setConfirming] = useState(null); + const [focusTarget, setFocusTarget] = useState<"model" | "next" | null>(null); + const clearButtonRefs = useRef>>({}); useEffect(() => { let alive = true; @@ -92,19 +95,38 @@ export function StoragePane() { }; }, []); + useEffect(() => { + if (!focusTarget) return; + + if (focusTarget === "model") { + clearButtonRefs.current.models?.focus(); + } else { + const modelIndex = CATEGORY_ORDER.indexOf("models"); + const next = CATEGORY_ORDER.slice(modelIndex + 1) + .map((id) => clearButtonRefs.current[id]) + .find((button): button is HTMLButtonElement => Boolean(button && !button.disabled)); + next?.focus(); + } + + setFocusTarget(null); + }, [focusTarget, usage, clearing, confirming]); + const runClear = async (categories: StorageCategoryId[], modelsConfirmed: boolean) => { if (clearing) return; const inFlight = categories[0]!; + let succeeded = false; setClearing(inFlight); setError(null); try { const next = await storageClear(categories, modelsConfirmed); setUsage(next); + succeeded = true; + if (inFlight === "models") setFocusTarget("next"); } catch (reason) { setError(t("storage.error", { error: errorMessage(reason) })); } finally { setClearing(null); - setConfirming(null); + if (succeeded || inFlight !== "models") setConfirming(null); } }; @@ -113,13 +135,18 @@ export function StoragePane() { if (category === "models" && confirming !== "models") { // Models are re-downloads: the confirm step is mandatory (and the Rust // command independently rejects an unconfirmed models clear). + setError(null); setConfirming("models"); return; } void runClear([category], category === "models"); }; - const cancelConfirm = () => setConfirming(null); + const cancelConfirm = () => { + setError(null); + setConfirming(null); + setFocusTarget("model"); + }; if (usage === null && error === null) { return ( @@ -205,8 +232,9 @@ export function StoragePane() { const isClearingThis = clearing === id; const showConfirm = confirming === id; return ( -
+
- {showConfirm ? ( -
-
- - {t("storage.clearConfirmTitle")} - - - -
-
- {t("storage.clearConfirmBody")} -
-
- ) : ( - - )} +
+ {id === "models" && ( + +
+
+ + {t("storage.clearConfirmTitle")} + + + +
+
+ {t("storage.clearConfirmBody")} +
+ {error && ( +
+ {error} +
+ )} +
+
+ )}
); })} - {error && ( + {error && confirming !== "models" && (
{error}
diff --git a/web/src/styles/components.css b/web/src/styles/components.css index bca8a89b..3f303664 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -19,6 +19,30 @@ min-block-size: 0; } +.storage-category-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.storage-category-row__main { + min-block-size: 28px; +} + +.storage-model-confirmation { + display: flex; + flex-direction: column; + gap: var(--space-xs); + align-items: flex-end; + padding-block-start: var(--space-xs); +} + +.storage-model-confirmation__actions { + display: inline-flex; + gap: var(--space-xs); + align-items: center; +} + @media (prefers-reduced-motion: reduce) { .reveal { transition: none; From a6097e7f3d2fc566351c6a368f13ead4c2321575 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 22:55:32 +0800 Subject: [PATCH 15/61] fix(mcp): close external lifecycle races --- crates/opentake-agent/src/mcp/server.rs | 77 +-- src-tauri/src/external_mcp.rs | 700 ++++++++++++++++++++---- 2 files changed, 643 insertions(+), 134 deletions(-) diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index 2f68c38c..756587a5 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -757,18 +757,14 @@ impl BearerAuthorizer for SingleBearerAuthorizer { fn bearer_candidate(headers: &axum::http::HeaderMap) -> Option<&str> { let mut values = headers.get_all(axum::http::header::AUTHORIZATION).iter(); - let Some(value) = values.next() else { - return None; - }; + let value = values.next()?; if values.next().is_some() { return None; } let Ok(value) = value.to_str() else { return None; }; - let Some((scheme, supplied)) = value.split_once(' ') else { - return None; - }; + let (scheme, supplied) = value.split_once(' ')?; (scheme.eq_ignore_ascii_case("bearer") && !supplied.is_empty() && !supplied.chars().any(char::is_whitespace)) @@ -857,7 +853,8 @@ impl ManagedClientSessions { let sessions = state .owners .iter() - .filter_map(|(session, owner)| (owner == client).then(|| session.clone())) + .filter(|(_, owner)| *owner == client) + .map(|(session, _)| session.clone()) .collect::>(); for session in &sessions { state.owners.remove(session); @@ -1215,15 +1212,18 @@ pub fn build_router_with_all_capability_bridges_for_port( )) } +struct GatedRouterTransport { + shutdown: CancellationToken, + expected_port: u16, + authorization: Option, +} + fn build_gated_router_for_port( dispatcher: Arc, instructions: String, gate: Arc, activity: Arc, - shutdown: CancellationToken, - expected_port: u16, - authorizer: Option>, - managed_sessions: Option>, + transport: GatedRouterTransport, ) -> axum::Router { use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{ @@ -1233,11 +1233,16 @@ fn build_gated_router_for_port( use tower_http::limit::RequestBodyLimitLayer; let mut config = StreamableHttpServerConfig::default(); - config.cancellation_token = shutdown; + config.cancellation_token = transport.shutdown; let admission = DispatchAdmission::new(); - let session_manager = managed_sessions.as_ref().map_or_else( + let session_manager = transport.authorization.as_ref().map_or_else( || Arc::new(LocalSessionManager::default()), - |sessions| sessions.manager.clone(), + |authorization| { + authorization.sessions.as_ref().map_or_else( + || Arc::new(LocalSessionManager::default()), + |sessions| sessions.manager.clone(), + ) + }, ); let service = StreamableHttpService::new( move || { @@ -1266,15 +1271,12 @@ fn build_gated_router_for_port( .layer(axum::middleware::from_fn(content_type_guard)) .layer(axum::middleware::from_fn(protocol_version_guard)) .layer(axum::middleware::from_fn_with_state( - expected_port, + transport.expected_port, localhost_guard, )); - match authorizer { - Some(authorizer) => router.layer(axum::middleware::from_fn_with_state( - ManagedAuthorizationState { - authorizer, - sessions: managed_sessions, - }, + match transport.authorization { + Some(authorization) => router.layer(axum::middleware::from_fn_with_state( + authorization, bearer_authorization_guard, )), None => router, @@ -1567,10 +1569,14 @@ pub async fn bind_managed_gated_on( instructions, gate, activity.clone(), - shutdown.clone(), - bound_addr.port(), - Some(authorizer), - Some(client_sessions.clone()), + GatedRouterTransport { + shutdown: shutdown.clone(), + expected_port: bound_addr.port(), + authorization: Some(ManagedAuthorizationState { + authorizer, + sessions: Some(client_sessions.clone()), + }), + }, ); let listener_shutdown = shutdown.clone(); let join = tokio::spawn(async move { @@ -1640,10 +1646,14 @@ async fn bind_ephemeral_gated_on( instructions, gate, activity.clone(), - shutdown.clone(), - bound_addr.port(), - Some(Arc::new(SingleBearerAuthorizer::new(bearer_token.clone()))), - None, + GatedRouterTransport { + shutdown: shutdown.clone(), + expected_port: bound_addr.port(), + authorization: Some(ManagedAuthorizationState { + authorizer: Arc::new(SingleBearerAuthorizer::new(bearer_token.clone())), + sessions: None, + }), + }, ); let listener_shutdown = shutdown.clone(); let listener_stopped = stopped.clone(); @@ -1693,10 +1703,11 @@ pub async fn serve_gated_dispatcher( instructions, gate, DispatchActivity::new(), - CancellationToken::new(), - bound_addr.port(), - None, - None, + GatedRouterTransport { + shutdown: CancellationToken::new(), + expected_port: bound_addr.port(), + authorization: None, + }, ); tracing::info!("MCP server listening on http://{bound_addr}/mcp"); axum::serve(listener, router).await diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs index 7f4fecd4..8f13a5ab 100644 --- a/src-tauri/src/external_mcp.rs +++ b/src-tauri/src/external_mcp.rs @@ -5,7 +5,7 @@ use std::{ net::Ipv4Addr, path::{Path, PathBuf}, sync::{atomic::AtomicU64, Arc, RwLock}, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use opentake_agent::mcp::{ @@ -57,7 +57,7 @@ pub(crate) struct ExternalMcpStatus { pub(crate) error: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub(crate) struct ExternalMcpClientSummary { pub(crate) id: String, @@ -68,19 +68,6 @@ pub(crate) struct ExternalMcpClientSummary { pub(crate) revoked_at: Option, } -impl Default for ExternalMcpClientSummary { - fn default() -> Self { - Self { - id: String::new(), - name: String::new(), - token_digest: String::new(), - created_at: 0, - last_used_at: None, - revoked_at: None, - } - } -} - #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ExternalMcpPairingReceipt { @@ -177,7 +164,7 @@ struct LastUseTracker { struct LastUseEntry { latest: i64, dirty: bool, - last_flushed: Option, + last_flushed: Option, } struct LastUseWorker { @@ -222,25 +209,40 @@ impl LastUseTracker { } fn due(&self, force: bool) -> HashMap { - let now = Instant::now(); + let now = tokio::time::Instant::now(); self.entries .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() - .filter_map(|(client_id, entry)| { - (entry.dirty + .filter(|(_, entry)| { + entry.dirty && (force || entry.last_flushed.is_none_or(|flushed| { now.duration_since(flushed) >= Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64) - }))) - .then(|| (client_id.clone(), entry.latest)) + })) }) + .map(|(client_id, entry)| (client_id.clone(), entry.latest)) .collect() } + fn next_deadline(&self) -> Option { + let now = tokio::time::Instant::now(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .filter(|entry| entry.dirty) + .map(|entry| { + entry.last_flushed.map_or(now, |flushed| { + flushed + Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64) + }) + }) + .min() + } + fn mark_flushed(&self, persisted: &HashMap) { - let now = Instant::now(); + let now = tokio::time::Instant::now(); let mut entries = self .entries .lock() @@ -288,10 +290,10 @@ impl ExternalMcpStatusBroadcaster { pub(crate) struct ExternalMcpState { components: ExternalMcpComponents, catalog: Arc>, - gate: Arc, + gate: Arc, authorizer: Arc, last_use_worker: tokio::sync::Mutex>, - lifecycle: tokio::sync::Mutex, + lifecycle: Arc>, status: Arc, preference_parent_sync_on_call: std::sync::atomic::AtomicUsize, } @@ -299,6 +301,7 @@ pub(crate) struct ExternalMcpState { type ExternalMcpStatusSink = Arc; struct ExternalMcpLifecycle { + admission: ExternalMcpAdmission, enabled: bool, state: ExternalMcpListenerState, error: Option, @@ -306,6 +309,13 @@ struct ExternalMcpLifecycle { endpoint: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalMcpAdmission { + Running, + ShuttingDown, + Stopped, +} + impl ExternalMcpState { pub(crate) fn new( core: AppCore, @@ -326,13 +336,14 @@ impl ExternalMcpState { gate, authorizer, last_use_worker: tokio::sync::Mutex::new(None), - lifecycle: tokio::sync::Mutex::new(ExternalMcpLifecycle { + lifecycle: Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, enabled: false, state: ExternalMcpListenerState::Disabled, error: None, auth_failure: None, endpoint: None, - }), + })), status: ExternalMcpStatusBroadcaster::new(), preference_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), } @@ -355,7 +366,8 @@ impl ExternalMcpState { Ok(catalog) if preferences.is_ok() => { let preferences = preferences.expect("checked external MCP preferences"); let mut state = Self::new(core, components, catalog); - state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, enabled: preferences.enabled, state: if preferences.enabled { ExternalMcpListenerState::Paused @@ -365,7 +377,7 @@ impl ExternalMcpState { error: None, auth_failure: None, endpoint: None, - }); + })); state } catalog => { @@ -376,13 +388,14 @@ impl ExternalMcpState { }; let catalog = ExternalMcpCatalog::unavailable(app_data_dir, secrets); let mut state = Self::new(core, components, catalog); - state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, enabled, state: ExternalMcpListenerState::AuthFailure, error: Some(sanitize_auth_failure(&error)), auth_failure: Some(error), endpoint: None, - }); + })); state } } @@ -397,19 +410,23 @@ impl ExternalMcpState { let catalog = ExternalMcpCatalog::unavailable(&root, Arc::new(opentake_gen::KeyringStore::new())); let mut state = Self::new(core, components, catalog); - state.lifecycle = tokio::sync::Mutex::new(ExternalMcpLifecycle { + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, enabled: false, state: ExternalMcpListenerState::AuthFailure, error: Some(sanitize_auth_failure(&error)), auth_failure: Some(error), endpoint: None, - }); + })); state } pub(crate) async fn initialize(&self) { - self.ensure_last_use_worker().await; let mut lifecycle = self.lifecycle.lock().await; + if lifecycle.admission != ExternalMcpAdmission::Running { + return; + } + self.ensure_last_use_worker().await; self.reconcile_listener(&mut lifecycle).await; } @@ -419,8 +436,9 @@ impl ExternalMcpState { } pub(crate) async fn set_enabled(&self, enabled: bool) -> Result { - self.ensure_last_use_worker().await; let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; + self.ensure_last_use_worker().await; if enabled { self.ensure_auth_ready(&lifecycle)?; } @@ -443,6 +461,7 @@ impl ExternalMcpState { pub(crate) async fn pair(&self, name: &str) -> Result { let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; self.ensure_auth_ready(&lifecycle)?; let receipt = match self.with_catalog_write(|catalog| catalog.pair(name)) { Ok(receipt) => receipt, @@ -465,6 +484,7 @@ impl ExternalMcpState { client_id: &str, ) -> Result { let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; self.ensure_auth_ready(&lifecycle)?; let previous = self.active_client(client_id)?; if let Some(endpoint) = lifecycle.endpoint.as_ref() { @@ -492,6 +512,7 @@ impl ExternalMcpState { pub(crate) async fn revoke(&self, client_id: &str) -> Result { let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; self.ensure_auth_ready(&lifecycle)?; let previous = self.active_client(client_id)?; if let Some(endpoint) = lifecycle.endpoint.as_ref() { @@ -516,21 +537,21 @@ impl ExternalMcpState { pub(crate) async fn shutdown(&self) -> Result<(), String> { let mut lifecycle = self.lifecycle.lock().await; - self.stop_listener(&mut lifecycle).await?; + if lifecycle.admission == ExternalMcpAdmission::Stopped { + return Ok(()); + } + if lifecycle.admission == ExternalMcpAdmission::ShuttingDown { + return Err("external MCP shutdown is already in progress".to_string()); + } + lifecycle.admission = ExternalMcpAdmission::ShuttingDown; + let listener_result = self.stop_listener(&mut lifecycle).await; + self.set_terminal_listener_state(&mut lifecycle, listener_result.is_ok()); drop(lifecycle); - self.stop_last_use_worker().await?; + let worker_result = self.stop_last_use_worker().await; let mut lifecycle = self.lifecycle.lock().await; - if lifecycle.auth_failure.is_some() { - lifecycle.state = ExternalMcpListenerState::AuthFailure; - } else if lifecycle.enabled { - lifecycle.state = ExternalMcpListenerState::Paused; - lifecycle.error = None; - } else { - lifecycle.state = ExternalMcpListenerState::Disabled; - lifecycle.error = None; - } + lifecycle.admission = ExternalMcpAdmission::Stopped; self.emit_status(&lifecycle); - Ok(()) + listener_result.and(worker_result) } async fn reconcile_listener(&self, lifecycle: &mut ExternalMcpLifecycle) { @@ -634,17 +655,7 @@ impl ExternalMcpState { .read() .map(|catalog| catalog.clients().to_vec()) .unwrap_or_default(); - ExternalMcpStatus { - revision: self - .status - .revision - .load(std::sync::atomic::Ordering::Acquire), - enabled: lifecycle.enabled, - state: lifecycle.state, - endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), - clients, - error: lifecycle.error.clone(), - } + status_from_parts(lifecycle, clients, &self.status) } fn emit_status(&self, lifecycle: &ExternalMcpLifecycle) { @@ -657,6 +668,29 @@ impl ExternalMcpState { }) } + fn ensure_running(&self, lifecycle: &ExternalMcpLifecycle) -> Result<(), String> { + (lifecycle.admission == ExternalMcpAdmission::Running) + .then_some(()) + .ok_or_else(|| "external MCP lifecycle has stopped".to_string()) + } + + fn set_terminal_listener_state( + &self, + lifecycle: &mut ExternalMcpLifecycle, + listener_stopped_cleanly: bool, + ) { + if lifecycle.auth_failure.is_some() { + lifecycle.state = ExternalMcpListenerState::AuthFailure; + } else if lifecycle.enabled { + lifecycle.state = ExternalMcpListenerState::Paused; + } else { + lifecycle.state = ExternalMcpListenerState::Disabled; + } + if listener_stopped_cleanly { + lifecycle.error = None; + } + } + fn with_catalog_write( &self, operation: impl FnOnce(&mut ExternalMcpCatalog) -> Result, @@ -719,6 +753,7 @@ impl ExternalMcpState { let join = spawn_last_use_worker( self.catalog.clone(), self.authorizer.last_use.clone(), + self.lifecycle.clone(), self.status.clone(), shutdown_rx, ); @@ -741,6 +776,7 @@ impl ExternalMcpState { flush_last_use_blocking( self.catalog.clone(), self.authorizer.last_use.clone(), + self.lifecycle.clone(), self.status.clone(), force, ) @@ -778,27 +814,58 @@ impl ExternalMcpState { } } +fn status_from_parts( + lifecycle: &ExternalMcpLifecycle, + clients: Vec, + status: &ExternalMcpStatusBroadcaster, +) -> ExternalMcpStatus { + ExternalMcpStatus { + revision: status.revision.load(std::sync::atomic::Ordering::Acquire), + enabled: lifecycle.enabled, + state: lifecycle.state, + endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), + clients, + error: lifecycle.error.clone(), + } +} + fn spawn_last_use_worker( catalog: Arc>, tracker: Arc, + lifecycle: Arc>, status: Arc, mut shutdown: tokio::sync::oneshot::Receiver<()>, ) -> tokio::task::JoinHandle> { tokio::spawn(async move { - let mut interval = - tokio::time::interval(Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - interval.tick().await; loop { - tokio::select! { - _ = &mut shutdown => { - return flush_last_use_blocking(catalog, tracker, status, true).await; - } - () = tracker.changed.notified() => {} - _ = interval.tick() => {} + let deadline = tracker.next_deadline(); + match deadline { + Some(deadline) => tokio::select! { + _ = &mut shutdown => { + return flush_last_use_blocking( + catalog, tracker, lifecycle, status, true + ).await; + } + () = tracker.changed.notified() => {} + _ = tokio::time::sleep_until(deadline) => {} + }, + None => tokio::select! { + _ = &mut shutdown => { + return flush_last_use_blocking( + catalog, tracker, lifecycle, status, true + ).await; + } + () = tracker.changed.notified() => {} + }, } - flush_last_use_blocking(catalog.clone(), tracker.clone(), status.clone(), false) - .await?; + flush_last_use_blocking( + catalog.clone(), + tracker.clone(), + lifecycle.clone(), + status.clone(), + false, + ) + .await?; } }) } @@ -806,6 +873,7 @@ fn spawn_last_use_worker( async fn flush_last_use_blocking( catalog: Arc>, tracker: Arc, + lifecycle: Arc>, status: Arc, force: bool, ) -> Result<(), String> { @@ -814,25 +882,24 @@ async fn flush_last_use_blocking( return Ok(()); } let persisted = due.clone(); - let clients = tokio::task::spawn_blocking(move || { - let mut catalog = catalog + let catalog_for_write = catalog.clone(); + tokio::task::spawn_blocking(move || { + let mut catalog = catalog_for_write .write() .map_err(|_| "external MCP catalog lock is unavailable".to_string())?; catalog.persist_last_used(&due)?; - Ok::<_, String>(catalog.clients().to_vec()) + Ok::<_, String>(()) }) .await .map_err(|error| format!("join external MCP last-use publication: {error}"))??; tracker.mark_flushed(&persisted); - let latest = status - .latest + let lifecycle = lifecycle.lock().await; + let clients = catalog .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - if let Some(mut latest) = latest { - latest.clients = clients; - status.publish(latest); - } + .map_err(|_| "external MCP catalog lock is unavailable".to_string())? + .clients() + .to_vec(); + status.publish(status_from_parts(&lifecycle, clients, &status)); Ok(()) } @@ -1290,7 +1357,7 @@ impl ExternalMcpCatalog { { return Err("publish external MCP catalog: injected rename failure".to_string()); } - fs::rename(temp, destination) + replace_file_atomically(temp, destination) .map_err(|error| format!("publish external MCP catalog: {error}")) } @@ -1312,6 +1379,44 @@ impl ExternalMcpCatalog { } } +#[cfg(not(windows))] +fn replace_file_atomically(staging: &Path, destination: &Path) -> std::io::Result<()> { + fs::rename(staging, destination) +} + +#[cfg(windows)] +fn replace_file_atomically(staging: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let staging = staging + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are owned, NUL-terminated UTF-16 paths and remain + // alive for the duration of this synchronous Win32 call. + let replaced = unsafe { + MoveFileExW( + staging.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + #[cfg(unix)] fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { fs::File::open(parent)?.sync_all() @@ -1406,9 +1511,11 @@ fn persist_preferences( error: format!("write external MCP preferences staging file: {error}"), published: false, })?; - fs::rename(&staging, root.join(PREFERENCES_FILE)).map_err(|error| PublishError { - error: format!("publish external MCP preferences: {error}"), - published: false, + replace_file_atomically(&staging, &root.join(PREFERENCES_FILE)).map_err(|error| { + PublishError { + error: format!("publish external MCP preferences: {error}"), + published: false, + } })?; sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { error: format!("sync external MCP preferences directory: {error}"), @@ -1452,7 +1559,7 @@ fn write_preference_file( error: format!("write external MCP preferences staging file: {error}"), published, })?; - fs::rename(&staging, destination).map_err(|error| PublishError { + replace_file_atomically(&staging, destination).map_err(|error| PublishError { error: format!("publish external MCP preferences: {error}"), published, })?; @@ -1702,6 +1809,68 @@ mod tests { static LIFECYCLE_PORT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + struct TwoClientBlockingGate { + entered: tokio::sync::mpsc::UnboundedSender, + release_survivor: AtomicBool, + survivor_cancelled: AtomicBool, + } + + impl TwoClientBlockingGate { + fn new(entered: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + entered, + release_survivor: AtomicBool::new(false), + survivor_cancelled: AtomicBool::new(false), + } + } + + fn release_survivor(&self) { + self.release_survivor.store(true, Ordering::SeqCst); + } + } + + impl ChatTurnGate for TwoClientBlockingGate { + fn timeline( + &self, + dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + ) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + _name: &str, + _args: serde_json::Value, + ) -> Option { + panic!("managed blocking test must use request-local cancellation") + } + + fn dispatch_cancellable( + &self, + _dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + name: &str, + _args: serde_json::Value, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + let _ = self.entered.send(name.to_owned()); + if name == "get_media" { + while !self.release_survivor.load(Ordering::SeqCst) + && !request_cancel.is_cancelled() + { + std::thread::yield_now(); + } + self.survivor_cancelled + .store(request_cancel.is_cancelled(), Ordering::SeqCst); + } else { + while !request_cancel.is_cancelled() { + std::thread::yield_now(); + } + } + Some(opentake_agent::tools::result::ToolResult::ok("released")) + } + } + #[derive(Default)] struct InstrumentedSecretStore { values: Mutex>, @@ -1795,6 +1964,26 @@ mod tests { ExternalMcpState::load(core, chat.external_mcp_components(), root.path(), secrets) } + fn lifecycle_state_with_gate( + root: &tempfile::TempDir, + secrets: Arc, + gate: Arc, + ) -> ExternalMcpState { + let mut state = lifecycle_state(root, secrets); + state.gate = gate; + state + } + + async fn wait_for_publish_count(state: &ExternalMcpState, expected: usize) { + for _ in 0..100 { + if state.catalog_publish_count_for_test() >= expected { + return; + } + tokio::task::yield_now().await; + } + assert_eq!(state.catalog_publish_count_for_test(), expected); + } + async fn assert_fixed_port_available() { let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)) @@ -2010,21 +2199,22 @@ mod tests { let receipt = state.pair("Claude Desktop").await.expect("pair and start"); - let events = events.lock().expect("read statuses"); - let transitions = events.iter().map(|status| status.state).collect::>(); - assert!(transitions.windows(2).any(|states| { - states - == [ - ExternalMcpListenerState::Starting, - ExternalMcpListenerState::Listening, - ] - })); - let serialized = serde_json::to_string(&*events).expect("serialize status events"); - assert!(!serialized.contains(&receipt.bearer_token)); - assert!(events - .windows(2) - .all(|statuses| { statuses[1].revision == statuses[0].revision.saturating_add(1) })); - drop(events); + { + let events = events.lock().expect("read statuses"); + let transitions = events.iter().map(|status| status.state).collect::>(); + assert!(transitions.windows(2).any(|states| { + states + == [ + ExternalMcpListenerState::Starting, + ExternalMcpListenerState::Listening, + ] + })); + let serialized = serde_json::to_string(&*events).expect("serialize status events"); + assert!(!serialized.contains(&receipt.bearer_token)); + assert!(events.windows(2).all(|statuses| { + statuses[1].revision == statuses[0].revision.saturating_add(1) + })); + } state.shutdown().await.expect("drain listener"); } @@ -2203,6 +2393,144 @@ mod tests { state.shutdown().await.expect("drain regenerated listener"); } + async fn exercise_two_active_request_mutation(regenerate: bool) { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let gate = Arc::new(TwoClientBlockingGate::new(entered_tx)); + let state = lifecycle_state_with_gate( + &root, + Arc::new(MemoryMcpSecretStore::default()), + gate.clone(), + ); + state.set_enabled(true).await.expect("enable endpoint"); + let target = state.pair("target").await.expect("pair target"); + let survivor = state.pair("survivor").await.expect("pair survivor"); + let client = reqwest::Client::new(); + let target_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &target.bearer_token, + "target-session", + ) + .await; + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; + + let spawn_call = + |token: String, session: reqwest::header::HeaderValue, name: &'static str, id: u64| { + let client = client.clone(); + tokio::spawn(async move { + let response = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": {} } + })) + .send() + .await + .expect("send blocking tool request"); + let status = response.status(); + let body = response.text().await.expect("consume blocking SSE body"); + (status, body) + }) + }; + let target_call = spawn_call( + target.bearer_token.clone(), + target_session, + "get_timeline", + 2, + ); + let mut survivor_call = spawn_call( + survivor.bearer_token.clone(), + survivor_session.clone(), + "get_media", + 3, + ); + let mut entered = vec![ + tokio::time::timeout(Duration::from_secs(2), entered_rx.recv()) + .await + .expect("first request entry timed out") + .expect("first request entered"), + tokio::time::timeout(Duration::from_secs(2), entered_rx.recv()) + .await + .expect("second request entry timed out") + .expect("second request entered"), + ]; + entered.sort(); + assert_eq!(entered, ["get_media", "get_timeline"]); + + if regenerate { + tokio::time::timeout(Duration::from_secs(2), state.regenerate(&target.client.id)) + .await + .expect("regenerate must not wait for survivor") + .expect("regenerate target"); + } else { + tokio::time::timeout(Duration::from_secs(2), state.revoke(&target.client.id)) + .await + .expect("revoke must not wait for survivor") + .expect("revoke target"); + } + let (target_status, _) = tokio::time::timeout(Duration::from_secs(2), target_call) + .await + .expect("target request must terminate") + .expect("join target request"); + assert!(target_status.is_success()); + assert!( + !survivor_call.is_finished(), + "survivor request was terminated with target" + ); + assert!(!gate.survivor_cancelled.load(Ordering::SeqCst)); + + gate.release_survivor(); + let (survivor_status, _) = tokio::time::timeout(Duration::from_secs(2), &mut survivor_call) + .await + .expect("survivor request must finish after release") + .expect("join survivor request"); + assert!(survivor_status.is_success()); + assert_managed_session_usable( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + &survivor_session, + 4, + ) + .await; + state.shutdown().await.expect("stop endpoint"); + } + + #[tokio::test] + async fn lifecycle_regenerate_cancels_target_active_request_and_preserves_survivor_session() { + tokio::time::timeout( + Duration::from_secs(10), + exercise_two_active_request_mutation(true), + ) + .await + .expect("regenerate transport regression timed out"); + } + + #[tokio::test] + async fn lifecycle_revoke_cancels_target_active_request_and_preserves_survivor_session() { + tokio::time::timeout( + Duration::from_secs(10), + exercise_two_active_request_mutation(false), + ) + .await + .expect("revoke transport regression timed out"); + } + #[tokio::test] async fn lifecycle_application_shutdown_drains_and_releases_the_port() { let _port = LIFECYCLE_PORT.lock().await; @@ -2273,6 +2601,145 @@ mod tests { assert_eq!(reloaded.clients()[0].last_used_at, Some(t0 + 30)); } + #[tokio::test(start_paused = true)] + async fn lifecycle_dirty_last_used_flushes_at_its_exact_deadline() { + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + let paired = state.pair("Cursor").await.expect("pair client"); + tokio::time::advance(Duration::from_secs(20)).await; + let before = state.catalog_publish_count_for_test(); + + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + state + .flush_last_use(false) + .await + .expect("flush leading value"); + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_030); + state.initialize().await; + + tokio::time::advance(Duration::from_secs(59)).await; + tokio::task::yield_now().await; + assert_eq!(state.catalog_publish_count_for_test(), before + 1); + tokio::time::advance(Duration::from_secs(1)).await; + wait_for_publish_count(&state, before + 2).await; + + state.shutdown().await.expect("stop last-use worker"); + } + + #[tokio::test] + async fn lifecycle_shutdown_is_terminal_for_initialize_enable_and_pair_races() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = Arc::new(lifecycle_state( + &root, + Arc::new(MemoryMcpSecretStore::default()), + )); + state.set_enabled(true).await.expect("enable endpoint"); + let paired = state.pair("Cursor").await.expect("pair first client"); + let (held_tx, held_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let catalog = state.catalog.clone(); + let catalog_barrier = std::thread::spawn(move || { + let _catalog = catalog.write().expect("hold catalog publication"); + held_tx.send(()).expect("signal held catalog"); + release_rx.recv().expect("release catalog publication"); + }); + held_rx.recv().expect("catalog publication is held"); + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + let shutting_down = { + let state = state.clone(); + tokio::spawn(async move { state.shutdown().await }) + }; + loop { + let lifecycle = state.lifecycle.lock().await; + let admission = lifecycle.admission; + if admission == ExternalMcpAdmission::ShuttingDown { + assert_ne!( + lifecycle.state, + ExternalMcpListenerState::Listening, + "shutdown retained listening after the endpoint was drained" + ); + break; + } + drop(lifecycle); + tokio::task::yield_now().await; + } + let initialize = state.initialize(); + let enable = state.set_enabled(true); + let pair = state.pair("late client"); + let ((), enable, pair) = tokio::join!(initialize, enable, pair); + + assert!( + enable.is_err(), + "enable was admitted after terminal shutdown" + ); + assert!(pair.is_err(), "pair was admitted after terminal shutdown"); + release_tx.send(()).expect("release held catalog"); + catalog_barrier.join().expect("join catalog barrier"); + shutting_down + .await + .expect("join shutdown") + .expect("shutdown endpoint"); + assert_eq!( + state.lifecycle.lock().await.admission, + ExternalMcpAdmission::Stopped + ); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_last_use_status_publish_merges_current_lifecycle_under_barrier() { + let root = catalog_root(); + let state = Arc::new(lifecycle_state( + &root, + Arc::new(MemoryMcpSecretStore::default()), + )); + let paired = state.pair("Cursor").await.expect("pair client"); + state.initialize().await; + let before = state.catalog_publish_count_for_test(); + let mut lifecycle = state.lifecycle.lock().await; + lifecycle.enabled = true; + lifecycle.state = ExternalMcpListenerState::Paused; + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + let flushing = { + let state = state.clone(); + tokio::spawn(async move { state.flush_last_use(true).await }) + }; + wait_for_publish_count(&state, before + 1).await; + assert!( + !flushing.is_finished(), + "last-use status publication bypassed the lifecycle serialization barrier" + ); + drop(lifecycle); + flushing + .await + .expect("join last-use flush") + .expect("flush last-use status"); + + let latest = state + .status + .latest + .read() + .expect("latest status") + .clone() + .expect("published status"); + assert!(latest.enabled); + assert_eq!(latest.state, ExternalMcpListenerState::Paused); + state.shutdown().await.expect("stop lifecycle"); + } + #[test] fn lifecycle_authorizer_uses_only_the_validated_in_memory_snapshot() { let root = catalog_root(); @@ -2720,6 +3187,37 @@ mod tests { assert!(serialized.contains(&receipt.client.token_digest)); } + #[test] + fn atomic_replace_overwrites_existing_catalog_preferences_and_journal_targets() { + let root = catalog_root(); + for name in [CATALOG_FILE, PREFERENCES_FILE, PREFERENCES_PENDING_FILE] { + let destination = root.path().join(name); + let staging = root.path().join(format!("{name}.staging")); + std::fs::write(&destination, b"old").expect("write existing target"); + std::fs::write(&staging, b"new").expect("write replacement"); + + replace_file_atomically(&staging, &destination).expect("replace existing target"); + + assert_eq!(std::fs::read(&destination).expect("read target"), b"new"); + assert!(!staging.exists()); + } + } + + #[test] + fn atomic_replace_failure_keeps_existing_target_recoverable() { + let root = catalog_root(); + let destination = root.path().join(CATALOG_FILE); + let missing_staging = root.path().join("missing.staging"); + std::fs::write(&destination, b"old").expect("write existing target"); + + assert!(replace_file_atomically(&missing_staging, &destination).is_err()); + + assert_eq!( + std::fs::read(destination).expect("read retained target"), + b"old" + ); + } + #[test] fn catalog_restart_reloads_metadata_and_retrieves_secret_from_fake_store() { let root = catalog_root(); @@ -2729,7 +3227,7 @@ mod tests { catalog.pair("Claude Desktop").expect("pair client") }; let catalog = load_catalog(&root, secrets); - assert_eq!(catalog.clients(), &[receipt.client.clone()]); + assert_eq!(catalog.clients(), std::slice::from_ref(&receipt.client)); assert_eq!( catalog .verify_candidate(&receipt.bearer_token) From 7a61f0a1424b928f8e9ed7b1455279fea206dc56 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 23:01:50 +0800 Subject: [PATCH 16/61] fix(settings): stabilize model clear lifecycle --- .../task-3-report.md | 48 +++++++++- .../components/settings/StoragePane.test.tsx | 92 ++++++++++++++++++- web/src/components/settings/StoragePane.tsx | 45 +++++++-- 3 files changed, 173 insertions(+), 12 deletions(-) diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md index cd7615f7..3f8270d8 100644 --- a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md @@ -55,4 +55,50 @@ Observed result: exit 0, no whitespace errors. ## Commit -`b4268fa fix(settings): animate model removal confirmation` +`a1d5100 fix(settings): animate model removal confirmation` + +## Review fix round 1 + +### Findings addressed + +- Added a mounted flag and monotonically increasing operation epoch. Resolve, reject, and `finally` paths now discard late results before any state or focus intent is written. +- Replaced the one-way post-model focus lookup with a stable order: later enabled clear actions, then earlier enabled clear actions in reverse proximity, then the programmatically focusable Storage pane. +- Preserved the existing `Reveal` enter/exit and reduced-motion behavior. + +### TDD evidence + +RED command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx +``` + +Observed result: exit 1; 2 focus-fallback tests failed because focus fell to `body` when the `other` clear action was disabled. Unmount-before-resolve and unmount-before-reject lifecycle cases were also added to cover late completion without DOM/focus changes or React errors. + +GREEN command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx src/components/ui/Reveal.test.tsx +``` + +Observed result: exit 0; 2 files and 24 tests passed. + +Build command: + +```text +pnpm -C web build +``` + +Observed result: exit 0. Existing dynamic-import and bundle-size warnings remain unchanged. + +Diff command: + +```text +git diff --check +``` + +Observed result: exit 0, no whitespace errors. + +### Review fix commit + +`fix(settings): stabilize model clear lifecycle` diff --git a/web/src/components/settings/StoragePane.test.tsx b/web/src/components/settings/StoragePane.test.tsx index 3b6c7973..9d01bb44 100644 --- a/web/src/components/settings/StoragePane.test.tsx +++ b/web/src/components/settings/StoragePane.test.tsx @@ -43,16 +43,18 @@ function usageFixture(): UsageFixture { let container: HTMLDivElement; let root: Root; +let rootMounted: boolean; beforeEach(() => { vi.clearAllMocks(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); + rootMounted = true; }); afterEach(async () => { - await act(async () => root.unmount()); + if (rootMounted) await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); @@ -76,6 +78,16 @@ const openModelConfirmation = async () => { await act(async () => clearButton("models").click()); }; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + it("shows a loading state while the usage report is in flight", async () => { api.usage.mockReturnValue(new Promise(() => undefined)); await render(); @@ -187,6 +199,84 @@ it("closes after successful model deletion and moves focus to the next available expect(reveal()).toBeNull(); }); +it("falls back to the previous enabled clear action when no later model sibling is available", async () => { + vi.useFakeTimers(); + const initial = usageFixture(); + initial.categories = initial.categories.map((category) => + category.id === "other" ? { ...category, bytes: 0 } : category, + ); + initial.totalBytes = 750; + api.usage.mockResolvedValue(initial); + api.clear.mockResolvedValue({ + ...initial, + categories: initial.categories.map((category) => + category.id === "models" ? { ...category, bytes: 0 } : category, + ), + totalBytes: 250, + }); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + await act(async () => confirmationButton("confirm-remove").click()); + + expect(document.activeElement).toBe(clearButton("waveforms")); +}); + +it("focuses the pane when model deletion leaves no enabled clear action", async () => { + vi.useFakeTimers(); + const initial = usageFixture(); + initial.categories = initial.categories.map((category) => ({ + ...category, + bytes: category.id === "models" ? 500 : 0, + })); + initial.totalBytes = 500; + api.usage.mockResolvedValue(initial); + api.clear.mockResolvedValue({ + ...initial, + categories: initial.categories.map((category) => ({ ...category, bytes: 0 })), + totalBytes: 0, + }); + await render(); + await act(async () => undefined); + + const pane = container.querySelector("section"); + await openModelConfirmation(); + await act(async () => confirmationButton("confirm-remove").click()); + + expect(document.activeElement).toBe(pane); +}); + +it.each(["resolve", "reject"] as const)( + "ignores a model clear %s after the pane unmounts", + async (outcome) => { + const operation = deferred(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + api.usage.mockResolvedValue(usageFixture()); + api.clear.mockReturnValue(operation.promise); + await render(); + await act(async () => undefined); + + await openModelConfirmation(); + await act(async () => confirmationButton("confirm-remove").click()); + await act(async () => root.unmount()); + rootMounted = false; + + const outside = document.createElement("button"); + document.body.append(outside); + outside.focus(); + await act(async () => { + if (outcome === "resolve") operation.resolve(usageFixture()); + else operation.reject(new Error("late failure")); + }); + + expect(container.childElementCount).toBe(0); + expect(document.activeElement).toBe(outside); + expect(consoleError).not.toHaveBeenCalled(); + outside.remove(); + }, +); + it("keeps a model-clear backend failure visible in the open confirmation", async () => { api.usage.mockResolvedValue(usageFixture()); api.clear.mockRejectedValue("model removal failed"); diff --git a/web/src/components/settings/StoragePane.tsx b/web/src/components/settings/StoragePane.tsx index 1e0c3164..2744bf7f 100644 --- a/web/src/components/settings/StoragePane.tsx +++ b/web/src/components/settings/StoragePane.tsx @@ -35,9 +35,17 @@ const sectionStyle: CSSProperties = { gap: "var(--space-lg)", }; -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ + title, + children, + sectionRef, +}: { + title: string; + children: React.ReactNode; + sectionRef?: React.Ref; +}) { return ( -
+

(null); const [clearing, setClearing] = useState(null); const [confirming, setConfirming] = useState(null); - const [focusTarget, setFocusTarget] = useState<"model" | "next" | null>(null); + const [focusTarget, setFocusTarget] = useState<"model" | "afterModel" | null>(null); + const mountedRef = useRef(false); + const operationEpochRef = useRef(0); + const paneRef = useRef(null); const clearButtonRefs = useRef>>({}); useEffect(() => { let alive = true; + mountedRef.current = true; setError(null); storageUsage().then( (next) => { @@ -92,6 +104,8 @@ export function StoragePane() { ); return () => { alive = false; + mountedRef.current = false; + operationEpochRef.current += 1; }; }, []); @@ -99,13 +113,17 @@ export function StoragePane() { if (!focusTarget) return; if (focusTarget === "model") { - clearButtonRefs.current.models?.focus(); + (clearButtonRefs.current.models ?? paneRef.current)?.focus(); } else { const modelIndex = CATEGORY_ORDER.indexOf("models"); - const next = CATEGORY_ORDER.slice(modelIndex + 1) + const stableFocusOrder = [ + ...CATEGORY_ORDER.slice(modelIndex + 1), + ...CATEGORY_ORDER.slice(0, modelIndex).reverse(), + ]; + const next = stableFocusOrder .map((id) => clearButtonRefs.current[id]) .find((button): button is HTMLButtonElement => Boolean(button && !button.disabled)); - next?.focus(); + (next ?? paneRef.current)?.focus(); } setFocusTarget(null); @@ -114,19 +132,26 @@ export function StoragePane() { const runClear = async (categories: StorageCategoryId[], modelsConfirmed: boolean) => { if (clearing) return; const inFlight = categories[0]!; + const operationEpoch = ++operationEpochRef.current; + const isCurrentOperation = () => + mountedRef.current && operationEpochRef.current === operationEpoch; let succeeded = false; setClearing(inFlight); setError(null); try { const next = await storageClear(categories, modelsConfirmed); + if (!isCurrentOperation()) return; setUsage(next); succeeded = true; - if (inFlight === "models") setFocusTarget("next"); + if (inFlight === "models") setFocusTarget("afterModel"); } catch (reason) { + if (!isCurrentOperation()) return; setError(t("storage.error", { error: errorMessage(reason) })); } finally { - setClearing(null); - if (succeeded || inFlight !== "models") setConfirming(null); + if (isCurrentOperation()) { + setClearing(null); + if (succeeded || inFlight !== "models") setConfirming(null); + } } }; @@ -184,7 +209,7 @@ export function StoragePane() { const totalZero = usage.totalBytes === 0; return ( -
+
{t("storage.desc")} From dc92c972e0f58e553e5f9c57a923af9b3af5da98 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 23:11:33 +0800 Subject: [PATCH 17/61] fix(library): place Home navigation in the global rail --- .../task-4-report.md | 53 ++++++++++++ web/src/components/media/LibraryView.test.tsx | 85 ++++++++++++++++++- web/src/components/media/LibraryView.tsx | 42 +++++---- web/src/styles/components.css | 17 ++++ 4 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 .superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md new file mode 100644 index 00000000..6788b5f7 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md @@ -0,0 +1,53 @@ +# Task 4 implementer report + +Status: DONE + +## Scope + +- Moved the sole Library Home action from the content header to the top of the left category rail. +- Passed the navigation callback into `CategoryTree`; category state remains owned by `libraryStore` and therefore survives a return to Home and Library re-entry. +- Added rail-specific styling that applies the shared `--titlebar-safe-top` token and uses the title-bar control-size token with a 26px fallback. +- Kept the right header limited to its title, search, and sort controls. There is no alternate/mobile Home rendering. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/components/media/LibraryView.test.tsx +``` + +Observed result: exit 1. The new navigation test failed exactly as expected: the rail's first button was `All`, while the lone `Back to Home` action was in the content header. + +The package-script invocation also discovered pre-existing concurrent MCP work: `ExternalMcpPane.test.tsx` could not resolve its companion component. That separate failure was not modified by this task. + +### GREEN + +Commands: + +```text +pnpm -C web exec vitest run src/components/media/LibraryView.test.tsx --reporter=verbose +pnpm -C web build +git diff --check +``` + +Observed results: + +- Focused Library suite: 1 file, 7 tests passed. +- Production TypeScript/Vite build: exit 0. +- Diff check: exit 0, no whitespace errors. +- The build retained the repository's existing ineffective-dynamic-import and >500 kB chunk warnings. + +The full `pnpm -C web test` suite was also run after concurrent MCP files appeared. It finished with 144 files / 1247 tests passing and 1 MCP-only file / 5 tests failing in `ExternalMcpPane.test.tsx`; those assertions concern pairing API error and receipt behavior, not Library navigation, and are outside this task's owned files. + +## Self-review + +- `CategoryTree` renders exactly one accessible Home button before all category buttons, below the title-bar safe area. +- The test exercises actual navigation and category controls, asserts the Home control stays absent from the header while each built-in category is selected, and confirms the active Video category after re-entry. +- The change is confined to the three assigned Library files plus this required task report; concurrent settings/MCP and audit files were preserved. + +## Commit + +`fix(library): place Home navigation in the global rail` diff --git a/web/src/components/media/LibraryView.test.tsx b/web/src/components/media/LibraryView.test.tsx index 28845c6b..54377098 100644 --- a/web/src/components/media/LibraryView.test.tsx +++ b/web/src/components/media/LibraryView.test.tsx @@ -7,12 +7,15 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { LibraryEntry } from "../../lib/libraryApi"; -import { useLibraryStore } from "../../store/libraryStore"; +import { useI18nStore } from "../../i18n"; +import { stopLibrarySync, useLibraryStore } from "../../store/libraryStore"; +import { useEditorUiStore } from "../../store/uiStore"; import { textContrastRatio } from "../../../test/contrast"; import { LibraryEntryCard, LibraryEntryGrid, LibrarySearchBox, + LibraryView, libraryEntryPreviewSource, } from "./LibraryView"; @@ -27,12 +30,26 @@ const originalActions = { categorize: useLibraryStore.getState().categorize, unfavorite: useLibraryStore.getState().unfavorite, }; +const originalLibraryViewState = { + entries: useLibraryStore.getState().entries, + loading: useLibraryStore.getState().loading, + error: useLibraryStore.getState().error, + selectedCategory: useLibraryStore.getState().selectedCategory, + search: useLibraryStore.getState().search, + sort: useLibraryStore.getState().sort, +}; +const originalView = useEditorUiStore.getState().view; +const originalLocale = useI18nStore.getState().locale; afterEach(async () => { if (root) await act(async () => root?.unmount()); container?.remove(); root = null; container = null; + stopLibrarySync(); + useLibraryStore.setState(originalLibraryViewState); + useEditorUiStore.setState({ view: originalView }); + useI18nStore.getState().setLocale(originalLocale); useLibraryStore.setState(originalActions); vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -136,3 +153,69 @@ describe("LibraryEntryCard keyboard actions", () => { expect(unfavorite).toHaveBeenCalledWith(entry.id); }); }); + +describe("Library navigation", () => { + it("keeps one Home action first in the rail and preserves the selected category on re-entry", async () => { + useI18nStore.getState().setLocale("en"); + useLibraryStore.setState({ + entries: [], + loading: false, + error: null, + selectedCategory: "all", + search: "", + sort: "recent", + }); + useEditorUiStore.getState().setView("library"); + + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render()); + + const homeActions = container.querySelectorAll( + 'button[aria-label="Back to Home"]', + ); + expect(homeActions).toHaveLength(1); + + const rail = container.querySelector("aside"); + const header = container.querySelector("main > header"); + expect(rail).not.toBeNull(); + expect(header).not.toBeNull(); + expect(rail?.querySelector("button")).toBe(homeActions[0]); + + for (const [label, id] of [ + ["All", "all"], + ["Video", "video"], + ["Audio", "audio"], + ["Sound FX", "sound"], + ["Image", "image"], + ["Effects", "effect"], + ] as const) { + const category = [...(rail?.querySelectorAll("button") ?? [])].find( + (button) => button.textContent?.trim() === label, + ); + expect(category).toBeDefined(); + await act(async () => category?.click()); + expect(useLibraryStore.getState().selectedCategory).toBe(id); + expect(header?.querySelector('button[aria-label="Back to Home"]')).toBeNull(); + } + + const video = [...(rail?.querySelectorAll("button") ?? [])].find( + (button) => button.textContent?.trim() === "Video", + ); + await act(async () => video?.click()); + await act(async () => homeActions[0]?.click()); + expect(useEditorUiStore.getState().view).toBe("home"); + + await act(async () => root?.unmount()); + root = createRoot(container); + useEditorUiStore.getState().setView("library"); + await act(async () => root?.render()); + + expect(useLibraryStore.getState().selectedCategory).toBe("video"); + const reenteredVideo = [...container.querySelectorAll("aside button")].find( + (button) => button.textContent?.trim() === "Video", + ); + expect(reenteredVideo?.style.fontWeight).toBe("var(--fw-semibold)"); + }); +}); diff --git a/web/src/components/media/LibraryView.tsx b/web/src/components/media/LibraryView.tsx index 7026c30b..1d568a5d 100644 --- a/web/src/components/media/LibraryView.tsx +++ b/web/src/components/media/LibraryView.tsx @@ -106,7 +106,7 @@ export function LibraryView() { color: "var(--text-primary)", }} > - + setView("home")} />
- {/* 顶部条:返回主页 + 标题 + 搜索 + 排序 */} + {/* 顶部条:标题 + 搜索 + 排序 */}
-

}) { +function CategoryTree({ + custom, + onNavigateHome, +}: { + custom: ReadonlyArray; + onNavigateHome: () => void; +}) { const t = useT(); const selectedCategory = useLibraryStore((s) => s.selectedCategory); const setSelectedCategory = useLibraryStore((s) => s.setSelectedCategory); return (

{homeNotice && ( @@ -800,7 +655,7 @@ function ProjectLauncher({ style={{ width: "100%", display: "grid", - gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", gap: "var(--space-sm)", minHeight: 0, minWidth: 0, @@ -1017,7 +872,7 @@ function ProjectGridCard({ className="home-project-card" style={{ width: "100%", - minHeight: 96, + minHeight: 0, padding: "var(--space-md)", borderRadius: "var(--radius-md)", background: selected ? "var(--home-selected)" : "rgba(255,255,255,0.018)", @@ -1039,29 +894,30 @@ function ProjectGridCard({ width: "100%", }} > -
{coverUrl ? ( setThumbnailFailed(true)} - style={{ width: "100%", height: "100%", objectFit: "cover" }} + className="home-project-preview__image" /> - ) : } -
+ ) : ( +
+
+ {entry.name} + 16:9 +
+ +
+ )} +
{ it("uses homepage-specific Vercel tokens without replacing editor tokens", () => { @@ -66,9 +67,9 @@ describe("HomeView Vercel embedded visual direction", () => { expect(homeSource).toContain("textAlign: \"left\""); }); - it("places project-mode content near the top-left and shows projects in four columns", () => { + it("places project-mode content near the top-left in responsive project columns", () => { expect(homeSource).toContain("padding: \"var(--titlebar-safe-top) var(--space-xl-xxl) var(--space-xl-xxl)\""); - expect(homeSource).toContain("gridTemplateColumns: \"repeat(4, minmax(0, 1fr))\""); + expect(homeSource).toContain("gridTemplateColumns: \"repeat(auto-fit, minmax(min(100%, 220px), 1fr))\""); expect(homeSource).toContain("ProjectGridCard"); expect(homeSource).not.toContain("width: \"min(720px, 100%)\""); }); @@ -81,6 +82,21 @@ describe("HomeView Vercel embedded visual direction", () => { expect(projectGridCardSource).toContain("whiteSpace: \"nowrap\""); }); + it("uses a semantic 16:9 figure preview with covered thumbnails", () => { + expect(projectGridCardSource).toContain(" { + expect(homeSource).not.toContain("GenerationActivity"); + expect(homeSource).not.toContain("home-generation-heading"); + }); + it("enlarges the sidebar logo to a prominent size", () => { // Logo 块特征:紧邻 t("app.name") 的容器,字号从 --fs-sm-md 放大到 --fs-xl expect(homeSource).toContain("fontSize: \"var(--fs-xl)\""); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4c2c0f46..7ed4b63f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -22,7 +22,6 @@ import type { ExternalMcpPairingReceipt, ExternalMcpStatus, GenerateCaptionsResult, - GenerationLog, MediaList, MattingModelStatus, MotionTrackingRegion, @@ -241,17 +240,6 @@ export async function getTimeline(): Promise { }; } -/** The current session's append-only AI generation audit log (rows + credits - * math, persisted as `generation-log.json`). Read-only: the UI never mutates - * the log; only the core's generation lifecycle appends. Infallible — a - * session with no project yields the empty log. Outside Tauri it resolves to - * the honest empty log (no fake data). */ -export async function generationLog(): Promise { - await ensureTauri(); - if (invokeImpl) return invokeImpl("generation_log"); - return { version: 1, entries: [] }; -} - function editIdentityArgs(expected: ProjectEditIdentity): Record { return { expectedProjectEpoch: expected.projectEpoch, diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 02ad6480..83bf12de 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -308,6 +308,82 @@ color: var(--text-secondary); } +.home-project-preview { + position: relative; + display: block; + inline-size: 100%; + aspect-ratio: 16 / 9; + margin: 0; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--home-muted); +} + +.home-project-preview__image { + display: block; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.home-project-preview__fallback { + display: flex; + block-size: 100%; + box-sizing: border-box; + flex-direction: column; + justify-content: flex-end; + gap: var(--space-sm); + padding: var(--space-md); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.05), transparent 56%), + var(--home-muted); +} + +.home-project-preview__fallback-name { + min-inline-size: 0; + flex: 1; + overflow: hidden; + color: var(--home-foreground); + font-size: var(--fs-sm-md); + font-weight: var(--fw-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-project-preview__fallback-header { + display: flex; + align-items: center; + gap: var(--space-sm); + min-inline-size: 0; +} + +.home-project-preview__aspect { + flex: 0 0 auto; + color: var(--home-muted-foreground); + font-size: var(--fs-xs); + font-variant-numeric: tabular-nums; +} + +.home-project-preview__track-stack { + display: grid; + gap: 3px; +} + +.home-project-preview__track { + display: block; + block-size: 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); +} + +.home-project-preview__track--short { + inline-size: 72%; +} + +.home-project-preview__track--tiny { + inline-size: 42%; +} + @media (prefers-reduced-motion: reduce) { .reveal, .external-mcp-toggle__track, From e0478f7fa3ef1ffc71df13890351db84303b52b3 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 13 Aug 2026 23:50:44 +0800 Subject: [PATCH 21/61] fix(settings): preserve MCP pairing receipts --- .../settings/ExternalMcpPane.test.tsx | 120 +++++++++++++++- .../components/settings/ExternalMcpPane.tsx | 136 ++++++++++++++---- .../SettingsView.interaction.test.tsx | 130 +++++++++++++++++ web/src/components/settings/SettingsView.tsx | 33 ++++- 4 files changed, 384 insertions(+), 35 deletions(-) diff --git a/web/src/components/settings/ExternalMcpPane.test.tsx b/web/src/components/settings/ExternalMcpPane.test.tsx index 4605945d..3308dd54 100644 --- a/web/src/components/settings/ExternalMcpPane.test.tsx +++ b/web/src/components/settings/ExternalMcpPane.test.tsx @@ -43,6 +43,14 @@ function client(name = "Cursor") { }; } +function secondClient(name = "Claude") { + return { + ...client(name), + id: "client-2", + tokenDigest: "def456abc123", + }; +} + function status(overrides: Partial = {}): ExternalMcpStatus { return { revision: 1, @@ -69,6 +77,16 @@ let oneTimeToken = ""; let container: HTMLDivElement; let root: Root; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function button(label: string) { return [...container.querySelectorAll("button")].find( (candidate) => candidate.textContent?.trim() === label, @@ -196,15 +214,67 @@ describe("ExternalMcpPane", () => { }); it("copies the streamable endpoint and Authorization header from the one-time receipt", async () => { + api.pair.mockResolvedValueOnce(receipt("Claude Desktop")); await render(); await setClientName("Claude Desktop"); await act(async () => button(t("mcp.pair")).click()); await act(async () => button(t("mcp.copyConfig")).click()); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining(endpoint)); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining(`"Authorization": "Bearer ${oneTimeToken}"`), - ); + const payload = (navigator.clipboard.writeText as ReturnType).mock.calls[0]![0]; + expect(JSON.parse(payload)).toEqual({ + mcpServers: { + "Claude Desktop": { + type: "http", + url: endpoint, + headers: { Authorization: `Bearer ${oneTimeToken}` }, + }, + }, + }); + }); + + it("serializes receipt mutation while a config copy is pending and resets copied state for B", async () => { + const copy = deferred(); + (navigator.clipboard.writeText as ReturnType).mockReturnValueOnce(copy.promise); + await render(status({ enabled: true, state: "listening", clients: [client()] })); + await setClientName("Client A"); + await act(async () => button(t("mcp.pair")).click()); + await act(async () => button(t("mcp.copyConfig")).click()); + + expect(button(t("mcp.tokenDismiss")).disabled).toBe(true); + expect(button(t("mcp.pair")).disabled).toBe(true); + expect(button(t("mcp.regenerate")).disabled).toBe(true); + + await act(async () => copy.resolve()); + await act(async () => button(t("mcp.tokenDismiss")).click()); + const tokenB = `test-bearer-b-${crypto.randomUUID()}`; + api.pair.mockResolvedValueOnce(receipt("Client B", tokenB)); + await setClientName("Client B"); + await act(async () => button(t("mcp.pair")).click()); + + expect(container.textContent).toContain(tokenB); + expect(button(t("mcp.copyConfig"))).toBeDefined(); + expect(container.textContent).not.toContain(t("mcp.configCopied")); + }); + + it("does not let copy A completion mark receipt B after navigation", async () => { + const copyA = deferred(); + (navigator.clipboard.writeText as ReturnType).mockReturnValueOnce(copyA.promise); + await render(); + await setClientName("Client A"); + await act(async () => button(t("mcp.pair")).click()); + await act(async () => button(t("mcp.copyConfig")).click()); + + await act(async () => root.render(<>)); + const tokenB = `test-bearer-b-${crypto.randomUUID()}`; + api.pair.mockResolvedValueOnce(receipt("Client B", tokenB)); + await render(); + await setClientName("Client B"); + await act(async () => button(t("mcp.pair")).click()); + expect(container.textContent).toContain(tokenB); + + await act(async () => copyA.resolve()); + expect(container.textContent).toContain(tokenB); + expect(container.textContent).not.toContain(t("mcp.configCopied")); }); it("reports clipboard failures without hiding the one-time receipt", async () => { @@ -269,6 +339,48 @@ describe("ExternalMcpPane", () => { expect(document.activeElement).toBe(revoke); }); + it("restores focus to regenerate after a successful credential replacement", async () => { + await render(status({ enabled: true, state: "listening", clients: [client()] })); + const regenerate = button(t("mcp.regenerate")); + await act(async () => regenerate.click()); + await act(async () => button(t("mcp.confirmRegenerate")).click()); + + expect(document.activeElement).toBe(regenerate); + }); + + it("moves focus to the next client after a successful revoke", async () => { + const next = secondClient(); + api.revoke.mockResolvedValueOnce(status({ + revision: 2, + enabled: true, + state: "listening", + clients: [next], + })); + await render(status({ + enabled: true, + state: "listening", + clients: [client(), next], + })); + await act(async () => button(t("mcp.revoke")).click()); + await act(async () => button(t("mcp.confirmRevoke")).click()); + + const nextRow = container.querySelector("[data-external-mcp-client='client-2']")!; + expect(document.activeElement).toBe( + [...nextRow.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === t("mcp.regenerate"), + ), + ); + }); + + it("focuses the pane when revoking the final active client", async () => { + api.revoke.mockResolvedValueOnce(status({ revision: 2 })); + await render(status({ enabled: true, state: "listening", clients: [client()] })); + await act(async () => button(t("mcp.revoke")).click()); + await act(async () => button(t("mcp.confirmRevoke")).click()); + + expect(document.activeElement).toBe(container.querySelector(".external-mcp-pane")); + }); + it("drops a bearer from the DOM on navigation and does not persist it in browser storage", async () => { await render(); await setClientName("Cursor"); diff --git a/web/src/components/settings/ExternalMcpPane.tsx b/web/src/components/settings/ExternalMcpPane.tsx index 41dc2c5e..056eb4b8 100644 --- a/web/src/components/settings/ExternalMcpPane.tsx +++ b/web/src/components/settings/ExternalMcpPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Check, Copy, Plug, RefreshCw, ShieldAlert, Trash2 } from "lucide-react"; import { useT } from "../../i18n"; import { @@ -32,7 +32,7 @@ function clientConfig(receipt: ExternalMcpPairingReceipt): string { return JSON.stringify({ mcpServers: { [receipt.client.name]: { - type: "streamableHttp", + type: "http", url: receipt.endpoint, headers: { Authorization: `Bearer ${receipt.bearerToken}`, @@ -46,19 +46,30 @@ function listenerStatusKey(status: ExternalMcpStatus | null): string { return status?.state ?? "loading"; } -export function ExternalMcpPane() { +export function ExternalMcpPane({ + onReceiptOperationPendingChange, +}: { + onReceiptOperationPendingChange?: (pending: boolean) => void; +}) { const t = useT(); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [clientName, setClientName] = useState(""); const [receipt, setReceipt] = useState(null); const [copied, setCopied] = useState(false); + const [copyPending, setCopyPending] = useState(false); const [pending, setPending] = useState<"enable" | "pair" | "regenerate" | "revoke" | null>(null); const [confirmation, setConfirmation] = useState(null); + const [receiptOperationSettlement, setReceiptOperationSettlement] = useState(0); + const [focusTarget, setFocusTarget] = useState< + { kind: "regenerate"; clientId: string } | { kind: "client"; clientId: string | null } | null + >(null); const paneRef = useRef(null); const latestRevisionRef = useRef(-1); const mountedRef = useRef(false); const operationEpochRef = useRef(0); + const receiptEpochRef = useRef(0); + const receiptOperationLockedRef = useRef(false); const actionTriggerRef = useRef(null); const receiptDismissRef = useRef(null); @@ -98,10 +109,34 @@ export function ExternalMcpPane() { alive = false; mountedRef.current = false; operationEpochRef.current += 1; + receiptEpochRef.current += 1; unlisten(); }; }, [applyStatus, t]); + useEffect(() => { + if (!receiptOperationLockedRef.current) return; + receiptOperationLockedRef.current = false; + onReceiptOperationPendingChange?.(false); + }, [onReceiptOperationPendingChange, receiptOperationSettlement]); + + useEffect(() => () => { + onReceiptOperationPendingChange?.(false); + }, [onReceiptOperationPendingChange]); + + useLayoutEffect(() => { + if (!focusTarget) return; + const clientId = focusTarget.clientId; + const clientRow = [...(paneRef.current?.querySelectorAll( + "[data-external-mcp-client]", + ) ?? [])].find((row) => row.dataset.externalMcpClient === clientId); + const target = clientRow?.querySelector( + '[data-external-mcp-action="regenerate"]', + ) ?? paneRef.current; + target?.focus({ preventScroll: true }); + setFocusTarget(null); + }, [focusTarget]); + const focusConfirmation = useCallback((button: HTMLButtonElement | null) => { button?.focus({ preventScroll: true }); }, []); @@ -120,8 +155,25 @@ export function ExternalMcpPane() { }); }; + const clearReceipt = () => { + receiptEpochRef.current += 1; + setReceipt(null); + setCopied(false); + }; + + const showReceipt = (next: ExternalMcpPairingReceipt) => { + receiptEpochRef.current += 1; + setReceipt(next); + setCopied(false); + }; + + const beginReceiptOperation = () => { + receiptOperationLockedRef.current = true; + onReceiptOperationPendingChange?.(true); + }; + const runEnabledChange = async () => { - if (!status || pending) return; + if (!status || pending || copyPending) return; const epoch = ++operationEpochRef.current; setPending("enable"); setError(null); @@ -143,53 +195,72 @@ export function ExternalMcpPane() { }; const runPair = async () => { - if (pending) return; + if (pending || copyPending) return; const name = clientName.trim(); if (!name) { setError(t("mcp.error.clientName")); return; } const epoch = ++operationEpochRef.current; + beginReceiptOperation(); setPending("pair"); setError(null); - setCopied(false); - setReceipt(null); + clearReceipt(); try { const next = await externalMcpPair(name); if (!isCurrentOperation(epoch)) return; - setReceipt(next); + showReceipt(next); setClientName(""); refreshAfterMutation(epoch); } catch (reason) { if (isCurrentOperation(epoch)) setError(t("mcp.error.command", { error: errorMessage(reason) })); } finally { - if (isCurrentOperation(epoch)) setPending(null); + if (isCurrentOperation(epoch)) { + setPending(null); + setReceiptOperationSettlement((settlement) => settlement + 1); + } } }; const runConfirmation = async () => { - if (!confirmation || pending) return; + if (!confirmation || pending || copyPending) return; const currentConfirmation = confirmation; + const activeClients = status?.clients.filter((client) => client.revokedAt === null) ?? []; + const revokedIndex = activeClients.findIndex((client) => client.id === currentConfirmation.client.id); const epoch = ++operationEpochRef.current; + if (currentConfirmation.action === "regenerate") { + beginReceiptOperation(); + } setPending(currentConfirmation.action); setError(null); - setCopied(false); - setReceipt(null); + clearReceipt(); try { if (currentConfirmation.action === "regenerate") { const next = await externalMcpRegenerate(currentConfirmation.client.id); if (!isCurrentOperation(epoch)) return; - setReceipt(next); + showReceipt(next); refreshAfterMutation(epoch); + setFocusTarget({ kind: "regenerate", clientId: currentConfirmation.client.id }); } else { const next = await externalMcpRevoke(currentConfirmation.client.id); - if (isCurrentOperation(epoch)) applyStatus(next); + if (isCurrentOperation(epoch)) { + applyStatus(next); + const remainingClients = next.clients.filter((client) => client.revokedAt === null); + const focusIndex = Math.min(Math.max(0, revokedIndex), remainingClients.length - 1); + const nextClientId = remainingClients[focusIndex]?.id ?? null; + setFocusTarget({ kind: "client", clientId: nextClientId }); + } } if (isCurrentOperation(epoch)) setConfirmation(null); } catch (reason) { if (isCurrentOperation(epoch)) setError(t("mcp.error.command", { error: errorMessage(reason) })); } finally { - if (isCurrentOperation(epoch)) setPending(null); + if (isCurrentOperation(epoch)) { + setPending(null); + if (currentConfirmation.action === "regenerate") { + setReceiptOperationSettlement((settlement) => settlement + 1); + } + } } }; @@ -200,27 +271,33 @@ export function ExternalMcpPane() { }; const dismissReceipt = () => { + if (copyPending) return; // Receipt tokens are intentionally component-local. Clearing the state // before closing the disclosure removes the bearer from the DOM at once. - setReceipt(null); - setCopied(false); + clearReceipt(); receiptDismissRef.current?.blur(); }; const copyConfig = async () => { - if (!receipt) return; + if (!receipt || copyPending) return; + const receiptEpoch = receiptEpochRef.current; + setCopyPending(true); setError(null); try { if (!navigator.clipboard?.writeText) throw new Error(t("mcp.error.clipboardUnavailable")); await navigator.clipboard.writeText(clientConfig(receipt)); - if (mountedRef.current) setCopied(true); + if (mountedRef.current && receiptEpochRef.current === receiptEpoch) setCopied(true); } catch (reason) { - if (mountedRef.current) setError(t("mcp.error.clipboard", { error: errorMessage(reason) })); + if (mountedRef.current && receiptEpochRef.current === receiptEpoch) { + setError(t("mcp.error.clipboard", { error: errorMessage(reason) })); + } + } finally { + if (mountedRef.current && receiptEpochRef.current === receiptEpoch) setCopyPending(false); } }; const isListening = status?.state === "listening"; - const controlsDisabled = status === null || pending !== null; + const controlsDisabled = status === null || pending !== null || copyPending; const state = listenerStatusKey(status); const statusTitle = status ? t(`mcp.status.${status.state}`) : t("mcp.status.loading"); @@ -297,7 +374,12 @@ export function ExternalMcpPane() {
{receipt.bearerToken}
- @@ -305,6 +387,7 @@ export function ExternalMcpPane() { ref={receiptDismissRef} type="button" className="hover-area" + disabled={copyPending} onClick={dismissReceipt} > {t("mcp.tokenDismiss")} @@ -337,7 +420,8 @@ export function ExternalMcpPane() { className="hover-area" aria-expanded={isConfirming && confirmation.action === "regenerate"} aria-controls={`external-mcp-confirm-${client.id}`} - disabled={pending !== null} + data-external-mcp-action="regenerate" + disabled={pending !== null || copyPending} onClick={(event) => { actionTriggerRef.current = event.currentTarget; setError(null); @@ -352,7 +436,7 @@ export function ExternalMcpPane() { className="hover-area external-mcp-client__revoke" aria-expanded={isConfirming && confirmation.action === "revoke"} aria-controls={`external-mcp-confirm-${client.id}`} - disabled={pending !== null} + disabled={pending !== null || copyPending} onClick={(event) => { actionTriggerRef.current = event.currentTarget; setError(null); @@ -385,7 +469,7 @@ export function ExternalMcpPane() { className={confirmation.action === "revoke" ? "hover-area external-mcp-confirmation__destructive" : "hover-area"} - disabled={pending !== null} + disabled={pending !== null || copyPending} onClick={() => void runConfirmation()} > {t(confirmation.action === "regenerate" ? "mcp.confirmRegenerate" : "mcp.confirmRevoke")} @@ -393,7 +477,7 @@ export function ExternalMcpPane() {
@@ -264,9 +277,11 @@ export function SettingsView() { function SettingsSidebar({ activePane, + disabled, onSelect, }: { activePane: SettingsPaneId; + disabled: boolean; onSelect: (pane: SettingsPaneId) => void; }) { const t = useT(); @@ -280,6 +295,7 @@ function SettingsSidebar({