Files
NotesAgentic/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md
T

24 KiB
Raw Blame History

Plugin Command 与 Settings 问题与修复复盘

审阅与修复日期:2026-09-02 涉及分支:feat/plugin-command-settings 初始功能提交:6a08ad8 feat(extension): 实现插件命令与设置贡献 最终修复提交:d39ae72 fix(extension): 收紧插件命令运行时契约 合并提交:ff3da5d Merge pull request 'feat(extension): 实现 Plugin Command 与 Settings Contribution' (#10) 文档用途:记录阶段 D 从首次提交、连续审阅到合并期间发现的问题,说明形成原因、实际后果、解决思路和最终方案,供后续开发、问题定位、比赛材料和技术博客写作使用。

1. 背景与结论

阶段 D 在既有 Extension Core、MCP Bridge 和 Plugin Host 上增加 Plugin Command 与 Settings Contribution。初始实现已经具备命令注册、参数校验、设置持久化、Secret 加密存储和前端 Service,但首次提交仍把若干“接口能够调用”误当成了“安全边界已经闭合”。

本次分支没有在初始功能提交后直接合并,而是围绕清单输入、凭据隔离、JSON Schema、MCP 协议、事务一致性和前后端 Contract 连续审阅。最终共形成 1 个功能提交和 6 个修复提交,处理 12 类问题:

编号 问题 级别 处理结果
D-01 Plugin Secret 可被通用凭据接口或 Provider 引用 P0 建立 plugin.* 保留命名空间和双层访问门禁
D-02 Command 与 Settings 清单边界不完整 P1 收紧空值、重复项、权限、类型和数值边界校验
D-03 MCP Command Target 只有声明,没有真实执行链路 P0 增加专用 MCP 命令目标并与 Agent Tool 隔离
D-04 JSON Schema 外部引用可能触发越权 I/O P0 只允许当前文档内 Fragment 引用
D-05 Secret 引用可被篡改并跨凭据命名空间 P0 改用定长哈希引用并在读取、删除前重新核对
D-06 Secret 删除和插件卸载缺少事务一致性 P0 原子替换凭据表,失败时恢复 Settings 引用
D-07 Schema 引用校验忽略嵌套 $id 资源作用域 P1 使用 Draft 2020-12 Resource Resolver 按资源解析
D-08 MCP Command 未收到普通 Settings P1 在固定信封中同时传递 Settings 与声明过的 Secret
D-09 Command Effect 是任意字典,前后端边界过宽 P0 改为五类判别联合并限制各自 Payload
D-10 必填普通设置没有阻止启用和执行 P1 在 Enable 与 Execute 两个入口增加运行时门禁
D-11 MCP 工具 Schema 被发现后丢弃,真实信封未校验 P0 保存目标 Schema,并在调用前校验完整信封
D-12 启用探测和回归测试存在假阳性或假阴性 P1 使用最小协议标记,移除自制求解器并补强测试

最终验证基线为后端 136 项测试、前端 29 项测试、TypeScript 类型检查、前端生产构建、uv lock --checkgit diff --check 通过。PR #10 已合并到 main

2. 提交与审阅过程

顺序 提交 主要内容
1 6a08ad8 首次实现 Plugin Command、Settings、Secret API 和前端 Service
2 c3ef9df 收紧 Plugin Secret、Provider 凭据和 Command 清单边界
3 9e680a0 补齐真实 MCP Command Target,限制 JSON Schema 外部引用
4 022c322 改造 Secret 引用,修复删除和卸载事务
5 c06b962 按 Draft 2020-12 资源作用域修复 Schema 引用解析
6 eb3464b 补回 MCP Command 信封中的普通 Settings
7 d39ae72 收紧 Effect、必填设置和 MCP 运行时 Contract,修复测试盲点
合并 ff3da5d PR #10 合并进入 main

这段过程说明,Extension Core 的风险主要不在正常路径能否运行,而在不同入口是否共享同一套边界。HTTP、Provider Factory、Plugin Runtime、MCP Host、Settings Store 和测试 Fixture 只要有一个入口绕过限制,就可能形成跨命名空间访问、错误执行或假测试通过。

3. D-01Plugin Secret 可被通用凭据接口或 Provider 引用

原因

初始实现把 Plugin Secret 存入已有 EncryptedCredentialStore,但只在 Plugin Settings API 中隐藏明文,没有为凭据 ID 建立用途隔离。通用凭据 API 可以读写或删除同名 ID,Provider 配置也可以把 Plugin Secret 的凭据引用当作自己的 API Key 使用。

此外,Command 能否读取 Secret 只依赖 Plugin 拥有 secrets.use 权限,没有继续限制到当前 Command 在 commands.yaml 中明确声明的字段。

后果

  • 前端或其他模块可以覆盖、删除 Plugin 私有 Secret
  • Provider 可能把插件密钥发送给外部模型服务;
  • 同一 Plugin 内权限较低的 Command 可以读取与自己无关的 Secret;
  • API 没有回显明文并不代表 Secret 没有发生横向流动。

解决思路

Secret 隔离必须同时覆盖存储命名空间、公共 HTTP 入口、Provider 解析入口和 Command 字段级授权,不能只在返回 DTO 上隐藏值。

解决方案

  • plugin.* 设为 Plugin Settings 专用保留命名空间;
  • 通用凭据查询、写入和删除接口拒绝访问该前缀;
  • ProviderFactory 外包一层 ProviderCredentialResolver,即使绕过 HTTP 配置校验也不能读取 Plugin Secret
  • Command 通过 secrets 字段声明允许读取的 Setting Key
  • Resolver 同时检查插件权限、字段是否存在、字段类型和 Command 声明;
  • 未声明字段返回 PLUGIN_SECRET_ACCESS_DENIED,必填 Secret 未配置返回 PLUGIN_SECRET_REQUIRED
  • 审计事件不记录 arguments、effect 或 Secret。

4. D-02Command 与 Settings 清单边界不完整

原因

YAML 解析成功只说明语法可读,并不代表清单结构满足宿主协议。初始校验对 commands: 空值、重复 Secret、未声明权限、无效执行目标、Settings 默认值及非有限数值边界等情况覆盖不足。

例如 YAML 中只有 commands: 时,解析结果是 null 而不是空数组;NaN 和正负无穷虽然属于 Python 浮点值,却不能成为可移植的表单边界。

后果

  • 安装阶段可能放过无法执行的 Command;
  • 空清单在后续遍历时变成内部异常,而不是稳定业务错误;
  • 前后端对数值范围和默认值产生不一致理解;
  • 重复声明或越过 Plugin 命名空间的 ID 会污染全局注册表;
  • 错误只能到运行期暴露,定位成本更高。

解决思路

把 Plugin 包视为不可信输入,在安装阶段完成结构、语义和权限的完整验证,并将解析异常统一转换成稳定的 ExtensionError

解决方案

  • 要求 contributes 声明与 commands.yamlsettings.yaml 内容完全一致;
  • 拒绝 null Command 列表、重复 ID、重复 Secret 和越过 Plugin 命名空间的标识;
  • 执行目标只能在受控 handler 与当前 Plugin 的 mcp_tool 中二选一;
  • 校验 when、Context、Location 和权限白名单;
  • Settings 类型限定为 stringnumberbooleanselectsecret
  • 校验默认值、Select 选项、必填规则和最小/最大边界;
  • 拒绝 NaN、正无穷和负无穷;
  • 清单错误统一返回可定位的稳定错误码。

5. D-03MCP Command Target 只有声明,没有真实执行链路

原因

初始 Command 执行器只支持宿主内置 Handler。接口和规划中虽然存在 MCP Command 的概念,但 Runtime 没有把 Command 绑定到 MCP 工具,也没有定义 Command Context、Settings 和 Secret 如何进入 MCP 请求。

直接复用 Agent ToolRegistry 看似省事,却会把“用户主动执行的插件命令”和“模型可自主调用的 Agent Tool”混成同一种能力。

后果

  • MCP 插件声明的 Command 实际无法执行;
  • 如果简单注册为 Agent Tool,模型可能绕过命令位置、Context 裁剪和 Secret 声明直接调用;
  • Command 超时、Effect 和错误边界无法统一;
  • 前端看到命令已注册,点击后却只能得到运行时错误。

解决思路

为 MCP Command 建立独立执行通道。它可以复用 MCP 连接,但不能自动进入 Agent Tool Registry;宿主负责构造固定协议信封并验证返回的白名单 Effect。

解决方案

  • commands.yaml 支持当前 Plugin 命名空间内的 mcp_tool 目标;
  • Runtime 在 Plugin 启用后绑定目标,在禁用、异常退出和重启时同步注销;
  • MCP Command 不注册到 Agent ToolRegistry
  • 宿主只传递已校验 arguments、已裁剪 Context、有效 Settings 和当前 Command 声明的 Secret
  • MCP 返回结果必须转换成受控 Effect;
  • 远程原始异常、过大结果和超时统一映射为稳定宿主错误。

6. D-04JSON Schema 外部引用可能触发越权 I/O

原因

Command 和 MCP Tool 都接受插件提供的 JSON Schema。初始实现直接交给校验器处理 $ref$dynamicRef,没有限制引用 URI。恶意或错误 Schema 可以引用本地文件、HTTP 地址或其他外部资源。

后果

  • Schema 校验可能读取宿主文件或发起未授权网络请求;
  • 安装一个插件就可能产生隐式 I/O
  • 离线环境中校验结果不稳定;
  • 外部资源变化会让相同插件包得到不同验证结果;
  • Command 和 Agent Tool 如果采用不同规则,会出现新的绕过路径。

解决思路

当前阶段不需要跨文件 Schema。宿主应只允许当前文档内部的 Fragment 引用,并在注册阶段递归检查所有 Schema 节点。

解决方案

  • Command 与 Tool 共用 schema_security 校验边界;
  • 递归扫描 $ref$dynamicRef
  • 只允许以 # 开头的当前文档 Fragment
  • 拒绝文件、HTTP 和其他外部资源 URI;
  • 无法解析的本地引用在安装或注册阶段直接失败;
  • 运行期继续使用官方 Draft 2020-12 Validator 校验数据。

7. D-05:Secret 引用可被篡改并跨凭据命名空间

原因

初始 Settings 文件保存的 Secret 引用由可读的 Plugin ID 和 Setting Key 拼接而成。长度随名称增长,而且 Runtime 读取引用时默认信任磁盘内容,没有重新确认该引用确实属于当前字段。

本地文件损坏或被篡改后,一个 Plugin Setting 可以被改为指向 Provider 凭据或另一个 Plugin 的 Secret。

后果

  • 长 Plugin ID 和 Setting Key 可能超过凭据 ID 长度限制;
  • Settings 文件泄露内部字段名称;
  • 篡改引用可能造成跨命名空间读取或删除;
  • 卸载一个插件时可能误删其他模块的凭据。

解决思路

Secret 引用应由宿主确定性生成,长度固定,并在每次敏感操作前由当前 plugin_id + setting_key 重新计算,而不是信任持久化文件。

解决方案

使用以下语义生成引用:

plugin.<sha256(plugin_id + "\\0" + setting_key)>
  • 引用长度固定且符合凭据 ID 规则;
  • Settings Store 只保存引用和 configured 状态,不保存明文;
  • 读取、覆盖、删除和卸载前重新计算期望引用;
  • 引用不匹配时按损坏存储拒绝处理;
  • 增加跨命名空间篡改回归测试。

8. D-06:Secret 删除和插件卸载缺少事务一致性

原因

Secret 同时涉及 Settings 引用文件和加密凭据文件。初始删除流程按顺序修改两个存储,但任一步失败都没有完整回滚。插件卸载多个 Secret 时逐条删除,执行到一半失败会留下部分清理状态。

后果

  • Settings 显示未配置,但密文仍残留;
  • 凭据已删除,Settings 却仍显示已配置;
  • 多 Secret 卸载可能只删除前几项;
  • 重试操作无法判断上一次执行到哪里;
  • 用户以为插件卸载已清除密钥,实际磁盘仍可能保留数据。

解决思路

把引用更新和凭据删除看作一个逻辑事务。底层单文件凭据表应一次构造新状态并原子替换;跨 Settings 与 Credential Store 的操作需要显式补偿回滚。

解决方案

  • EncryptedCredentialStore 增加多凭据原子删除;
  • 先验证全部目标引用,再生成新的凭据表;
  • 通过临时文件和原子替换一次提交;
  • 删除单个 Secret 时,凭据删除失败则恢复原 Settings 引用;
  • 卸载 Plugin 时,批量删除失败则恢复完整 Settings 命名空间;
  • 错误统一转换为稳定存储错误,避免部分成功被当作完整成功。

9. D-07Schema 引用校验忽略嵌套 $id 资源作用域

原因

第一版本地引用检查把整个 Schema 当成单一 Fragment 树,用根文档指针或 Anchor 查找所有引用。Draft 2020-12 允许嵌套 $id 创建新的 Schema Resource;资源内部的 #anchor 应相对于新的 Base URI 解析,根资源也不能反向使用嵌套资源的 Anchor。

后果

  • 合法的嵌套资源引用被误拒绝;
  • 根 Schema 可能错误引用只属于子资源的 Anchor;
  • 宿主预检结果与官方运行时 Validator 不一致;
  • 同一 Schema 在安装阶段通过,却可能在执行阶段失败,反之亦然。

解决思路

安全限制仍然是“禁止外部资源”,但本地资源内部的解析语义必须遵守 JSON Schema 标准,不能自己用字符串和全局 Anchor 集合近似实现。

解决方案

  • 引入与 Draft 2020-12 Validator 配套的 Resource Registry
  • 为根资源和嵌套 $id 建立正确作用域;
  • 每个引用按其所在资源的 Base URI 解析;
  • 保留外部资源拒绝策略;
  • 增加“根资源不能使用子资源 Anchor”和“子资源可使用自身 Anchor”的成对测试。

10. D-08MCP Command 未收到普通 Settings

原因

真实 MCP Command 链路补齐后,固定信封传递了 Command ID、Arguments、Context 和 Secret,但遗漏了已经通过 Schema 校验的普通 Settings。声明式内置 Handler 能读取 SettingsMCP Handler 却不能,两个执行目标语义不一致。

后果

  • 用户在插件设置页修改普通配置,对 MCP Command 不生效;
  • 插件只能把非敏感设置错误地编码进 arguments 或 Secret
  • 内置 Handler 测试通过会掩盖 MCP 路径的缺口;
  • 插件从内置实现迁移到 MCP 后行为发生变化。

解决思路

内置 Handler 与 MCP Command 应消费同一份运行时配置。差别只在执行介质,不在 Command Contract。

解决方案

  • MCP 固定信封增加 settings
  • Settings 由 PluginSettingsStore.runtime_values() 产生;
  • 只传递当前 Plugin Schema 中有效的非敏感字段;
  • Secret 继续放在独立 secrets 命名空间;
  • Fixture 回显非敏感配置用于断言,但不回显 Secret;
  • 增加 Settings 实际到达 MCP Server 的集成测试。

11. D-09Command Effect 是任意字典,前后端边界过宽

原因

初始 PluginCommandEffect 只有 type 和任意 payload。宿主虽然限制 Effect 名称和总体大小,却没有限制 Payload 字段、路由名称、刷新范围或通知级别。前端 TypeScript 也只能把 Payload 当成普通对象处理。

后果

  • 插件可以返回前端从未支持的字段和路由;
  • 前端需要在运行时猜测 Payload 结构;
  • navigaterefresh 可能越过宿主允许的目标;
  • OpenAPI 无法表达不同 Effect 的必填字段;
  • 无效 Effect 往往要到页面执行时才暴露。

解决思路

Effect 是宿主能力协议,不是插件任意消息。每种 Effect 都应是独立、封闭、可判别的 Contract,并在进入 HTTP 响应前完成验证。

解决方案

  • 后端建立 notificationnavigaterefreshjobnone 五类模型;
  • 使用 type 作为 Pydantic 判别字段;
  • 限制通知级别、消息长度、路由白名单、刷新 Scope 和 Job ID
  • TypeScript 同步为精确的判别联合;
  • OpenAPI PluginCommandResult.effect 生成 oneOf 和 discriminator
  • 保留可序列化性和 64 KiB 总大小限制作为第二层保护。

12. D-10:必填普通设置没有阻止启用和执行

原因

初始 Runtime 会合并已保存值和默认值,却没有检查 required 且没有默认值的普通字段是否仍为空。Secret 已有独立缺失检查,因此测试容易只覆盖 Secret,忽略普通 Settings。

后果

  • 配置不完整的 Plugin 仍可启动 MCP Host
  • Command 到插件内部才因缺少字段失败;
  • 用户只能看到模糊执行错误,不知道应先补配置;
  • 插件启用后删除必填值,后续执行没有再次校验。

解决思路

必填设置既是启用前置条件,也是每次执行的运行时不变量。不能只在保存表单时校验,因为磁盘内容可能变化,启用后的配置也可能被更新。

解决方案

  • runtime_values() 返回完整有效普通 Settings
  • 缺少必填且无默认值的字段时抛出 PLUGIN_SETTINGS_REQUIRED
  • Plugin Enable 前执行一次检查,不启动无效 Host;
  • 每次 Command Execute 前重新检查;
  • 返回 409 和缺失字段上下文,便于前端引导用户进入设置页;
  • 增加“补齐设置后可启用”的完整回归测试。

13. D-11MCP 工具 Schema 被发现后丢弃,真实信封未校验

原因

MCP 初始化阶段能够取得工具名称和 inputSchema,但 Runtime 记录只保留了工具名。Command 执行时直接发送宿主信封,没有用目标工具的完整 Schema 校验实际数据。

这意味着插件只要暴露同名工具就可能通过启用检查,即使它根本不接受 NotesAgent Command 协议。

后果

  • 不兼容目标在启用阶段被注册为可执行 Command;
  • 错误推迟到远程 MCP Server,返回信息不稳定;
  • Context、Settings 或 Secret 结构变化时无法在宿主边界发现漂移;
  • 前端看到可用命令,执行后才得到 502;
  • 禁用或重启后若 Schema 缓存不清理,还可能使用过期契约。

解决思路

发现阶段保留完整目标 Schema;启用阶段只检查最低协议标记;执行阶段再用真实数据验证全部约束。Schema 生命周期必须和 MCP 工具生命周期一致。

解决方案

  • Plugin 运行记录增加 MCP Command Schema 映射;
  • 禁用、回滚、重启和 Host 不可用时同步清理 Schema
  • 启用时要求目标 Schema 顶层直接声明 properties._notesagent,且类型为 object
  • 执行前构造完整固定信封;
  • 使用官方 Draft 2020-12 Validator 校验真实信封;
  • 不匹配时返回 PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH,不调用 MCP Server。

14. D-12:启用探测和回归测试存在假阳性或假阴性

原因

审阅期间先后暴露了三类测试方法问题:

  1. 启用阶段曾用空 arguments、Context、Settings 和 Secret 伪造信封,以判断目标 Schema 是否兼容。合法 Schema 如果要求真实业务字段,会被错误拒绝;
  2. 为避免空数据误判,曾尝试加入自定义 JSON Schema 可满足性求解,但该近似实现无法正确覆盖 notoneOf 等完整 Draft 2020-12 语义;
  3. MCP Secret Fixture 即使没有收到 api_key 也会返回成功,测试只验证了命令成功,没有证明 Secret 真正到达服务端。

最后还发现空 Echo 消息会构造 notification,但新的通知 Contract 要求消息非空,导致合法空输入被包装为 502。

后果

  • 合法插件可能在启用阶段被拒绝,形成假阴性;
  • 不兼容 Schema 可能被自定义求解器放过,形成假阳性;
  • Secret 传递链路回归后测试仍显示通过;
  • Contract 收紧后,旧 Fixture 的边界值会产生新的运行时错误;
  • 测试数量增加,却没有真正覆盖需要证明的安全事实。

解决思路

启用阶段只做稳定且明确的协议结构检查,完整 Schema 语义交给官方 Validator 和真实执行数据。测试必须让目标事实缺失时明确失败,而不是通过返回值间接猜测。

解决方案

  • 取消空业务数据 Probe
  • 将启用门槛缩小为直接 _notesagent: { type: object } 协议标记;
  • 允许 $ref$dynamicRefallOfanyOfoneOfnot 等约束出现在 _notesagent 内部;
  • 完全移除自定义 Schema 可满足性求解器;
  • 实际执行统一交给官方 Draft 2020-12 Validator
  • MCP Fixture 未收到声明的 Secret 时主动返回 MCP 错误,但永不返回明文;
  • 空 Echo 返回 none Effect,非空 Echo 返回 notification
  • 为协议标记、真实信封、Secret 到达、空 Echo 和恶意 Effect 分别增加回归测试。

15. 验证方法与结果

本分支最终执行以下验证:

cd backend
uv lock --check
uv run pytest

cd ../frontend
pnpm test -- --run
pnpm type-check
pnpm build

cd ..
git diff --check

结果:

backend: 136 passed
frontend: 29 passed
TypeScript type-check passed
frontend production build passed
uv lock --check passed
git diff --check passed

后端测试在 Windows 下仍有既有 .pytest_cache 权限警告,不影响测试结果。前端生产构建仍有既有大 Chunk 提示,不影响本次功能正确性和合并结论。

新增或强化的关键测试包括:

  • Plugin Secret 公共 API、Provider Resolver 和 Command Resolver 三层隔离;
  • 清单空值、重复项、无效权限、非有限边界和非法 Schema;
  • MCP Command 与 Agent Tool 隔离;
  • 固定信封中的 Context、Settings 和声明式 Secret
  • 外部 Schema 引用拒绝和嵌套 $id 资源作用域;
  • Secret 定长引用、篡改阻断、删除回滚和卸载原子性;
  • 五类 Effect 的合法 Payload 和恶意 Payload 拒绝;
  • 必填普通设置对 Enable 和 Execute 的双重门禁;
  • MCP 实际信封校验、Secret 到达证明和空 Echo 行为。

16. 预防措施

  • Plugin 包、MCP Schema 和 MCP 返回值一律按不可信输入处理;
  • Secret 安全检查必须覆盖写入、读取、删除、解析、传输和审计全链路;
  • 保留命名空间需要在公共 API 和内部 Resolver 两侧同时阻断;
  • 多存储更新必须设计原子提交或补偿回滚,并测试中途失败;
  • 不自行实现通用 JSON Schema 求解器,标准语义交给官方 Validator;
  • 启用阶段只检查静态协议能力,不使用伪业务数据推导可执行性;
  • Agent Tool 和用户触发的 Plugin Command 必须保持独立注册和权限边界;
  • Pydantic Contract、OpenAPI、TypeScript DTO 和测试 Fixture 在同一提交中同步;
  • 回归测试应直接证明目标事实,例如“Secret 确实到达且未落盘”,不能只证明接口返回成功;
  • 每次收紧 Contract 后,重新检查空值、默认值、最大值和旧 Fixture 等边界输入。

17. 当前边界与后续事项

本次合并完成的是阶段 D 的宿主协议和开发期运行边界,不等于第三方插件已经具备生产级操作系统隔离。

当前仍保留以下后续事项:

  • 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前;
  • 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host;
  • 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract
  • 前端已实现命令面板、Plugin 详情命令和动态 Settings / Secret 表单;上下文菜单与 Toolbar 挂载点仍复用现有 Service 继续扩展;
  • Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计;
  • 前端大 Chunk 应通过路由和 Markdown 依赖拆包处理,不与本次 Extension Contract 修改混合。

18. 复盘结论

本次最重要的经验是:插件系统的正确性不能只按“命令是否执行成功”判断。真正需要审阅的是数据从清单进入注册表、从 Settings 进入运行时、从 Secret Store 进入执行器、从宿主信封进入 MCP,以及从 MCP Effect 返回前端的每一道边界。

连续审阅避免了跨命名空间 Secret 访问、非事务删除、外部 Schema I/O、MCP 伪兼容和任意 Effect 等问题进入 main。最终实现把每个边界落到明确 Contract、稳定错误码和可失败的回归测试上,为下一阶段前端集成和桌面安全沙箱提供了可复用基础。