architecture adr ADR 006 agent plugin host - WangYajun369/ai-writing-platform GitHub Wiki
状态:已接受 · 阶段七已全部落地(10 / 10)
落地:CSP 放宽、包格式、加载器与清单校验、
grants校验、启用清单持久化、管理 UI、 插件画像并入 L3 且执行侧已认插件画像、PluginHost注入面与前端编排运行时、 备份文件打包(插件随全量备份往返)。
⚠️ 与本文原设想的一处偏离:插件 JS 不是「参与逐轮工具循环」,而是只在前端跑一次 产出plan(步骤 / 指示 / 工具 / 轮数),plan 回传 Rust 由原生引擎执行。 这是「引擎在 Rust 侧、无法回调前端 JS」约束下的解法,详见「需要 follow-up 的事项」首条。 日期:2026-10-10 影响范围:Agent 引擎 / IPC 契约 / 应用安全边界 / 前端插件 UI 适用版本:1.23.0| 最后核对:2026-10-15 相关:
在评估「Agent 是否该做成可安装 / 卸载的插件」时,用户明确选择支持第三方编写的、带自定义逻辑的 Agent——即插件能自带工具、访问数据、跑自定义流程。这不是「开发者在代码里加一个内置 Agent」,也不是「导入一段配置」。
本 ADR 记录如何承载这一诉求。ADR-005 曾以「'ai-prompt' 扩展点无消费端、PluginContext 无后端能力」为由排除复用现有插件系统;本 ADR 确认该结论仍然成立,但不是放弃插件化,而是另建宿主——现有 src/plugins/ 是编译期硬编码的静态容器,与「运行时装卸」无关。
事实一:现有插件系统与「运行时装卸」无关。
src/plugins/bootstrap.ts:9-12 是硬编码 import,插件清单就是 doBootstrap() 函数体本身。安装一个插件必须改源码、加 import、重新构建。无任何 manifest 扫描、目录扫描或动态 import。PluginManager 的 statuses 是内存 Map,启用状态不持久化,重启即回默认。
事实二:PluginContext 只有 11 个方法,且 7 个是空桩。
app.getActiveBookId / getActiveChapterId 恒 undefined,editor.* 四个方法恒空串或空实现(bootstrap.ts:23-31)。真正可用的只有 storage 与 notify。无 invoke、无数据库、无 LLM。两个内置插件(dictionary / taskCards)是靠顶层 import windowApi 绕过 context 的——那是「同属主 bundle」的特权,第三方代码拿不到。
事实三:项目零第三方代码执行设施。
无 wasmtime / wasmer、无脚本引擎(rhai / lua / deno)。Cargo.toml 的 crate-type = ["staticlib", "cdylib", "rlib"] 是 Tauri 移动端构建要求,不是插件机制;Cargo.lock 里的 wasm / libloading / dlopen2 全是 Tauri 的传递依赖。
事实四:CSP 的 connect-src 已经很严格(有利)。
connect-src 'self' ipc: http://ipc.localhost https://asset.localhost https://api.github.com
网络出站已被限制到白名单域,插件 JS 无法用 fetch 随意外传数据。
事实五:CSP 的 script-src 'self' 无 'unsafe-eval',且 CSP 无法按窗口隔离(代价)。
加载第三方 JS 必然需要放宽 CSP。而 Tauri v2 的 CSP 是 tauri.conf.json 中的页面级全局配置,不能按窗口或按插件分别设置——放宽即为全局放宽。
tauri.conf.json 中 withGlobalTauri: false。前端不存在 window.__TAURI__,一切 IPC 都必须经 @tauri-apps/api 的模块引用。而运行时加载的插件代码不参与 Vite 打包,拿不到任何 bundle 内模块的引用。
这意味着能力边界可以天然成立:插件能调用什么,完全取决于宿主注入了什么,无需在 IPC 层做复杂的白名单校验。
| 方案 | 沙箱强度 | 新增依赖 | 作者门槛 | 判定 |
|---|---|---|---|---|
| A. 前端 JS + 宿主注入受限 API | 中(网络已被 CSP 限制) | 无 | 低(写 JS 即可) | ✅ 选择 |
| B. WASM(wasmtime / wasmer) | 强(默认无文件与网络,可限内存与 CPU) | 重(编译变慢、体积增大量级) | 中(需能编译到 wasm) | 远期可选 |
| C. Rust cdylib + libloading | — | 中 | 高(须与宿主同 rustc 与依赖版本) | ❌ 排除:Rust 无稳定 ABI,版本错配即 UB |
| 方案 | 优点 | 缺点 |
|---|---|---|
| A. 插件可直连数据库与 LLM | 表达力最强 | 数据库连接与 API Key 进入插件可见范围;无法审计;一次泄露即全局失效 |
| B. 插件只能编排宿主原子能力 | 边界可控;审计面收敛到「插件调用了哪些原子能力」;可按需开关单项能力 | 插件无法表达宿主未提供的原子操作 |
| C. 插件声明式(无代码) | 最安全 | 无法满足「带自定义逻辑」的诉求 |
采用 A + B,即「前端 JS 载体 + 宿主注入 + 只可编排不可直连」,并延后到 ADR-005 前五阶段完成后实施。
第三方插件(JS · 不可信)
↓ 宿主注入的 host 对象
宿主编排层(提示词 · Pipeline · 工具引用)
↓
宿主原子能力(读章节 · 检索卡片 · 调 LLM · 写章节)
✗ 权限边界
绝不暴露(数据库连接 · API Key · 原始 SQL)
插件写的是「如何组合原子能力」,不是「直接操作数据」。宿主只向插件暴露行为,不暴露资源句柄。
不做「IPC 命令白名单校验」,因为:
-
withGlobalTauri: false+ 插件不参与打包 ⇒ 插件根本拿不到 invoke 能力,白名单无从绕过; - Tauri v2 的 capability 系统只约束官方插件权限,不约束 app 自定义命令,即便想靠它做白名单也做不了。
因此实现方式是:宿主构造一个只读的 host 对象注入插件,插件闭包内只能见到该对象。
type PluginHost = {
llm: { complete(prompt: string): Promise<string> }
book: { readChapter(id: string): Promise<string>; listChapters(): Promise<Chapter[]> }
card: { search(query: string): Promise<Card[]> }
log: { info(msg: string): void }
storage: { get<T>(k: string): Promise<T | undefined>; set<T>(k: string, v: T): Promise<void> }
}
⚠️ 仍需保留一层兜底:宿主注入的llm.complete必须走应用自己的配置(用户填入的 API Key),不得接受插件传入的 endpoint 或 key,否则密钥边界被绕过。
{app_data_dir}/plugins/<plugin-id>/
├── manifest.json
└── index.js
app_data_dir 已被现有代码使用(time_write.db、attachments/、backup.key),沿用即可。
manifest.json 字段:
| 字段 | 说明 |
|---|---|
id |
与目录名一致,复用现有 ID_PATTERN
|
name / version / description / author
|
与现有 PluginManifest 保持一致 |
grants |
权限声明,如 ["llm", "book.read", "card.search"]
|
profiles |
该插件贡献的 Agent 画像数组,元素形状与 L1 的 AgentProfile 一致,须含 UI 元数据(见下) |
entry |
入口文件,默认 index.js
|
grants 是核心:安装时向用户展示「该插件将获得以下能力」,运行时宿主只注入 grants 内声明的字段。
profiles[] 的元素除 id / base_prompt / tools / max_rounds 外,必须带 label / icon / color / quick_actions——因为插件画像同样要经 ADR-005 的 L3 list_agent_profiles 下发给前端渲染,缺了 UI 元数据前端无法展示。图标沿用现有字符串图标名约定(如 pen-tool / search),不引入新映射表。
因 CSP script-src 'self' 不含 'unsafe-eval',需在 tauri.conf.json 中放宽。这是全局放宽,无法只对插件生效,故配套三条缓解措施:
-
纯文本可读:插件是
.js源码而非二进制,安装界面直接展示源码供审核; -
显式确认:安装需用户逐项确认
grants; -
能力最小化:默认只注入
grants中声明的项,未声明的一律不注入。
ADR-005 的阶段一到五一字不改,在其后追加两个阶段:
| 阶段 | 内容 | 变化 |
|---|---|---|
| 一到五 | L0 工具注册表 / L1 画像注册表 / book_type / 领域画像 / L3 自动发现 |
不变 |
| 六 | L2 Pipeline
|
P3 → P2:它现在是插件的编排层,不再只是拆书 Agent 的附属 |
| 七 | 插件宿主 | 新增:加载器 + grants 注入 + 启用清单持久化 + 管理 UI |
理由是 ADR-005 的 L0 与 L2 本质上就是本 ADR 的前置:
- L0 工具注册表 = 宿主原子能力清单,它决定插件能调用什么;
-
L2
Pipeline= 编排层,它决定插件的自定义逻辑能表达到什么程度。
换句话说,本 ADR 的插件宿主 ≈ ADR-005 的 L0/L1/L2 + 运行时加载器 + grants 权限模型。跳过地基直接建宿主,会导致原子能力清单尚未收敛、插件 API 频繁 breaking change。
-
插件是数据 + 编排,不是资源访问者。Agent 的价值在于「如何组织上下文与步骤」,而非「直接操作数据库」。把边界划在行为层而非资源层,安全面收敛为一个可枚举的
grants列表。 -
withGlobalTauri: false让能力边界几乎免费。不需要沙箱、不需要 IPC 拦截层,只需要「不注入」。这是本项目既有配置带来的红利。 - JS 载体零新增依赖。引入 wasmtime 会让构建时间、二进制体积、插件作者门槛同时上升,而对单用户桌面应用而言,插件来源本就是「用户自己选择安装的那几个」,威胁模型弱于浏览器扩展生态。
- 先做地基不返工。L0/L1/L2 在两种路线下完全一致,先做完既不会浪费,也能让领域 Agent 提前可用。
-
Rust 原生动态库不可行。无稳定 ABI,宿主与插件必须用完全相同的 rustc 与依赖版本编译,否则 UB;且现有
cdylib是 Tauri 移动端构建要求,改它会破坏构建。
- 第三方(含用户自己)可编写带自定义逻辑的 Agent,运行时安装 / 卸载,无需重新编译
- 能力边界由
grants显式声明,安装时可审计,运行时按需开关 - 插件纯文本分发,可读、可 diff、可版本管理
- 复用现有
app_data_dir与备份体系,插件目录可纳入备份 - 前五阶段不返工,领域 Agent 能先落地先受益
⚠️ 必须全局放宽 CSP(script-src加'unsafe-eval'),且 Tauri v2 不支持按窗口隔离——这是选 JS 载体最实质的安全代价,无法只让插件承担-
宿主注入是唯一防线。一旦
host对象上挂载了高权限方法(如任意 SQL),边界即失效——需以代码评审与单测固化「注入面」 -
LLM 通道天然是数据出口。插件可通过
llm.complete把内容带出应用,这是任何插件系统的固有问题,只能在安装确认时告知 - 插件生态维护成本。宿主 API 一旦发布即需保持向后兼容,会拖慢 L0 工具注册表的演进
- Pipeline 优先级上升,工作量从「可选」变为「必需」
-
新增 IPC 命令(枚举 / 安装 / 卸载 / 启用),需同步
ipc-commands.ts契约
阶段七已定稿(2026-10-12):
- ✅ CSP 放宽的具体形态 —— 取
script-src加'unsafe-eval'(tauri.conf.json)。 放弃asset:+<script src>路线:它拿不到脚本返回值、插件只能靠全局变量暴露自己、 多插件需额外约定命名空间,隔离性反而比new Function更差,且 MIME 需实测。 - ✅
grants的粒度 —— 取按能力项(llm/book.read/card.search/card.write/storage),与宿主注入面的分组键一一对应。⚠️ 补充一条本 ADR 写作时未识别的关键约束:插件画像是声明式的(只报工具名、 由 Rust 引擎代为执行,全程不跑插件 JS),若不把工具名映射回grants就校验, 插件能绕过权限声明直接借 L0 的原子能力读写数据。因此grant_for_tool这张映射表 是grants对声明式插件仍然生效的唯一保障。 - ✅ 插件画像并入 L3 ——
list_agent_profiles已改为async+AppHandle, 返回「内置画像 + 已启用插件画像」。⚠️ 仅改列表是不够的:执行侧engine原本只调profiles::merge(skill, domain), 而它对未知能力 id 兜底为 writing——插件画像 id 不在内置注册表, 不改执行侧的话用户选了插件画像却依然跑 writing。故engine现先查插件画像再回退merge。 - ✅ 画像 id 冲突 —— 取加载时校验、撞车即拒绝(
E_PLUGIN_CONFLICT), 而非本 ADR 原建议的强制命名空间前缀。理由:ID_PATTERN(/^[a-z0-9][a-z0-9-]{1,63}$/)的字符集不含:也不含., 加前缀就得放宽这条前端已在用的规则;而「拒绝 + 报错里给出改名建议」同样能防撞车,代价更小。 - ✅ 命名划界 —— 已在 插件系统 中补「两套插件的划界」章节: 前者称内置模块,后者称Agent 插件。
仍未决:
- ✅ 插件 JS 的执行 —— 已定稿为**「保留 CSP 放宽 + 前端编排运行时」(而非回退放宽):
插件 JS 用
new Function在前端执行一次,拿到受限host(只含grants声明的字段) 后返回plan;plan 随execute_agent_skill回传 Rust,由原生引擎跑逐轮工具循环。 这样既不用在前端再造一套引擎、也不用给 Rust 引入 JS 引擎,'unsafe-eval'也终于有了真实消费端。⚠️ 由此产生的能力边界:插件能决定「分几步、每步用什么工具、给什么指示」, 但拿不到工具结果、无法在循环内分支**。需要「看结果再决定下一步」的插件做不到—— 这是当前方案的硬边界,将来要突破只能让引擎支持回调(重依赖)或把循环搬到前端。 - ✅ 插件目录是否纳入备份 —— 已落地:备份载荷新增加性的
files段 (文件以 base64 内嵌,版本仍为2.0,旧备份与新备份双向兼容),plugins/与attachments/一并打包,仅随全量备份导出,还原时只写不删。⚠️ 已知缺口:tasks/attachments两张表不在DatabaseExport内, 跨机迁移时还原出的附件是孤儿文件(会被每日孤儿清理删掉);真正闭环需另加这两张表。 - 🔜 是否引入插件签名 —— 当前威胁模型下(用户主动安装、纯文本可审核)暂不引入, 若将来有分发市场再评估。
- 🔜 WASM 作为第二载体 —— 本 ADR 已把宿主注入抽象为
PluginHost接口, 将来换 WASM 执行器时前端注入面不变,但宿主侧需新增 host function 绑定。