From c32f2e730e35ca2e44ada230da8242a5a91171ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 20:02:39 +0000 Subject: [PATCH 1/8] docs(rfc): add plugin extensibility survey and boundaries (RFC 0011) Establish a referenceable baseline for what the plugin system can actually do today, compare it structurally against DeepSeek Harness / Cordis, and record the boundary decisions that keep getting re-litigated. - current-surface: inventory of the existing extension surface with source locations (config-layer plugin graph, extension points + plugin APIs, toolUsePresentations, the @oneworks/hooks middleware chain, server runtime primitives, security boundaries), plus a record of three misjudgements made during the survey - dsh-comparison: structural comparison pinned to fixed upstream revisions, covering interception vs registration seams, external code-agent scheduling, and the generated-catalog documentation model - boundaries: seven referenceable disciplines (plugins cannot create plugins, view extension ordering, registration seams belong on the resident runtime, no accepted-then-ignored, trust/scope semantics, model-visible implies logged, the three-role seam definition) - actions: prioritised items split by whether they need a product decision Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rfcs/0011-plugin-extensibility-actions.md | 98 +++++++++++ .../0011-plugin-extensibility-boundaries.md | 101 +++++++++++ ...11-plugin-extensibility-current-surface.md | 137 +++++++++++++++ ...011-plugin-extensibility-dsh-comparison.md | 159 ++++++++++++++++++ .oo/rfcs/0011-plugin-extensibility.md | 57 +++++++ rfc.md | 8 + 6 files changed, 560 insertions(+) create mode 100644 .oo/rfcs/0011-plugin-extensibility-actions.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-boundaries.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-current-surface.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-dsh-comparison.md create mode 100644 .oo/rfcs/0011-plugin-extensibility.md diff --git a/.oo/rfcs/0011-plugin-extensibility-actions.md b/.oo/rfcs/0011-plugin-extensibility-actions.md new file mode 100644 index 000000000..e54f29b1c --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-actions.md @@ -0,0 +1,98 @@ +# RFC 0011: 行动项与优先级 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +行动项按"是否需要产品决策"分组。P0/P1 是纯技术改进,不改变任何对外承诺;P2 起需要先有开放程度的判断。 + +## P0-1:抽通用 ACP 适配器层 + +**问题**:`agentclientprotocol` 在 `packages/adapters/{cline,dsh,goose}` 各实现了一遍,无共享层,`packages/adapters/` 下也无 acp 包。下一个 ACP agent 需要写第四遍。 + +**参照**:DSH 的 `subagent-acp` 是通用的,配置里给 `command` / `args` / `env` 即可接入任意 ACP agent,`providerName` 可配,同进程可注册多个不同名字的外部 provider。 + +**收益**:抽出共享层后,接入新 ACP agent(Cursor、CodeBuddy、opencode 等)从"写一个适配器"降为"加一段配置"。 + +**风险**:低。纯内部重构,不涉及任何信任决策或对外接口变更。三个现有适配器有各自的 session 投影与能力声明,需确认可共享的是传输层与协议编解码,而非会话语义。 + +**建议**:先做可行性评估——对比三处实现的重叠度,确认抽象边界应落在 transport / codec 还是更上层。 + +## P0-2:生成式能力目录 + CI 门禁 + +**问题**:插件能力面分散在 `.oo/docs/usage/plugins/ui-runtime.md`(400+ 行手写)、`create-plugin/SKILL.md` 与源码之间,无生成、无门禁。本 RFC 调研中对自身能力误判三次(见[现有扩展面盘点](0011-plugin-extensibility-current-surface.md)的"已知误判记录")。 + +**参照**:DSH 的 `scripts/gen-cordis-api.ts` 从 AST 生成,`verify-cordis-api --check` 挂 doc-sync 门禁,产出还经 `cordis_inspect` 工具喂给模型;`docs/user/develop/framework/service.md` 明文拒绝维护第二份手工清单。 + +**对我们价值更大的理由**:One Works 本身是 AI 工作区,插件作者会用 Claude Code / Codex 对着我们的 API 写插件。机器可读、CI 校验新鲜度的目录直接决定生成代码的正确率。 + +**建议实现**:`scripts/gen-plugin-api.ts`,从 `PluginClientContext` / `PluginServerContext` / `PluginViewContext` 的 TS 声明抽结构化目录,产出机器可读 JSON + 渲染 markdown,加 `--check` 模式接入现有检查。首次运行即可量化 `ui-runtime.md` 的漂移程度。 + +**限定**:不是银弹。DSH 的生成文档仍有轻微漂移(`docs/subsystems/workflow.md` 引 157,实测 168),但事件部分行号全对,整体显著优于纯手工。 + +## P0-3:补 ErrorBoundary + +**问题**:`apps/client/src/plugins/` 与 `apps/client/src/components/plugins/` 下**零个** `ErrorBoundary` / `componentDidCatch`,`PluginHost.tsx:275` 裸渲染 `view.renderNode(viewContext)`。 + +**风险**:插件 route 页面渲染异常直接白屏,无降级。 + +**与视图槽无关,应独立先做。** 详见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 2。 + +## P1:Hook 权限面对 marketplace 场景的审视 + +**问题**:`resolvePluginHooksEntryPath`(`packages/utils/src/plugin-resolver.ts:703-707`)解析 `/hooks` export,`plugin-entry-cache.ts:43-53` 无条件把能解析出 hooks entry 的实例收进中间件链,**解析链上无 gate**。 + +而 hook 插件的权限包括:`PreToolUse` 返回 `deny` 否决任意工具调用、`GenerateSystemPrompt` 改写系统提示词、`PreCompact.replacementPrompt` 替换压缩提示词、任意事件 `continue: false` 停机。 + +**需要核实的点**: + +- marketplace 安装的插件是否自动获得 hook 能力,还是需要用户额外确认 +- 插件详情页的 `hooks` tab(`PluginDetailPanel.tsx:313`)展示的是资产 hooks(`PluginManifestAssets.hooks`)还是运行时 hook 插件——初步判断是前者(`NativePluginDetailPanel.tsx:116` 把 'mcp' 与 'hooks' 作同类资产分组),但未读完渲染逻辑 +- 这些权限是否作为"该插件请求的权限"呈现给用户 + +**背景**:这条线是命令行时代的设计(插件由用户手写进配置),marketplace 接上后同一条链变成了分发面。DSH 至少在文档里把等价风险明说了("允许该包在你机器上、在 agent sandbox 之外执行代码")。 + +**注意**:宿主自身的权限执行器 `builtin-permissions.ts` 也是这条链上的一个 hook 插件,第三方插件与它同链、顺序决定优先级。 + +## P2:Model provider seam(需产品决策) + +**问题**:`packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表,第三方加 provider 只能提 PR。 + +**为什么是最值得开的注册型 seam**: + +- 数据面而非控制面——provider 只负责发请求、转流,不干预 agent 决策 +- RFC 0006 已把"官方模型服务商"做成一等公民,但目录硬编码 +- 销毁机制现成(`addDisposable(scope, ...)` + frozen owner token + `rollbackScopeRegistrations`) + +**要抄的形状**(来自 `ctx.llm`): + +- `registerConfigurableProviders` 的休眠路由——插件声明能力,用户配置才激活 +- 全有或全无 + 重复检测(对应我们已有的 `duplicate()` 诊断) +- **凭证 seam:插件拿 ref 不拿明文 key**。marketplace 插件碰 API key 是明确风险面 +- 强制 server-only(`PluginServerManifest.roles` 已有角色概念可挂) + +**落点**:常驻 server plugin runtime,不是 hook。见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 3。 + +**需要的决策**:是否允许第三方提供模型 provider。这直接关系 RFC 0006 的商业路径。 + +## P2:适配器 seam 化(需产品决策) + +**现状**:16 个 `@oneworks/adapter-*` 是编译期内置(根 `package.json` devDependencies + 静态 import)。加一个适配器要改仓库、进 root package.json、重新发版。 + +**对照**:DSH 的 `SubagentProvider` 是 seam,第三方发 npm 包、用户配置加一行即可。其社区已产出第三方版的 Codex/Claude Code/ACP provider。 + +**我们的优势不应低估**:16 个适配器有统一 hook 协议、账号池、历史导入、权限镜像,深度显著超过 DSH 的 3 个薄 provider(one-shot、不继承上下文、纯文本、无审批)。seam 化不等于放弃深度,但需要设计"第三方 provider 能拿到多少宿主能力"的分层。 + +**需要的决策**:这是本 RFC 中影响最大的一项,涉及维护成本、质量控制与品牌。DSH 的策略是核心保持瘦、扩展面全让给社区(明确不收外部 PR),并有守门测试断言可选 provider 不进 base bundle。这是一种可选路径,不是唯一路径。 + +## 待核实项 + +以下问题在调研中出现但未查清,建议在实施 P0-2 时一并解决: + +1. 同 scope 内 parent 与 child 的 command id 撞名如何处理(覆盖 / 报错 / 静默保留第一个)——`runtime.ts:2802` 的检查针对内置 route key,此路径未核实 +2. 插件详情页 `hooks` tab 的确切数据来源(见 P1) +3. 16 个适配器的上游版本漂移防护是否都达到 dsh 适配器的水平(`DSH_VERSION` 固定 + `isOfficialCompositionComplete` 完整性校验)。DSH 只维护 2 个 product provider 就把限制写成明文 Known Limitations 清单,我们 16 个的成本是另一个量级 + +## 不建议做的 + +- **开放 `agentLoop` / `tools` / `approval` / `sandboxPolicy` 的注册型控制面**。DSH 敢开是因为其插件等同 shell 权限(明文记录);我们是 marketplace 分发,开了即提权通道。 +- **视图槽先于格式词汇表**。见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 2。 +- **让插件创造插件**。见纪律 1。 diff --git a/.oo/rfcs/0011-plugin-extensibility-boundaries.md b/.oo/rfcs/0011-plugin-extensibility-boundaries.md new file mode 100644 index 000000000..f92949840 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-boundaries.md @@ -0,0 +1,101 @@ +# RFC 0011: 边界与设计纪律 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +本章把已论证过的边界判断写成可引用的纪律,目的是避免每次提出新扩展点时重新论证。 + +## 纪律 1:插件不能创造插件 + +**不新增让插件在运行时实例化其他插件的能力**(相当于 Cordis 的 `ctx.plugin()`)。 + +需要动态插件图时,由宿主通过 plugin overlay 注入,走同一个 resolver、同一套 scope 分配、同一个 `/plugins` 列举。**动态性发生在配置解析层,不发生在插件代码里。** + +### 依据 + +**(1) 清理模型以 scope 为单位。** `disposablesByScope`、`removeExtensionPointListenersByScope`、`rollbackScopeRegistrations(scope, owner)`、`disposeScope(scope)` 全部 keyed on scope。动态子插件只有两条路:自己占新 scope(谁分配?冲突检测在启动期是 fatal;且 `/plugins` store 与 `PluginDetailPanel` 按服务端解析出的 instance 列表渲染,动态 scope 对 UI、诊断、卸载全部隐形),或共享父 scope(那它就不是插件,只是父插件的代码)。 + +**(2) reload 会失效。** `PluginProvider.tsx:97-104` 的 `reloadPlugin(scope)` 从 `instancesRef`(服务端解析结果)里找 instance,动态创建的东西不在其中,`watch` / HMR 对它是空操作。 + +**(3) CSP 已堵死代码生成路径。** `script-src` 无 `blob:`(`apps/client/index.html:7`),插件代码只能同源经 `/api/plugins/:scope/client/*` 加载,即只能来自已安装包——那为什么不声明? + +**(4) 卸载语义崩塌。** marketplace 有 removal journal / receipt / quotes 一整套账本,运行时拉起的东西没有 install 记录,也就没有 removal 记录。 + +### 三种被混为一谈的需求 + +| 需求 | 结论 | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 运行时决定要不要加载某个已安装插件 | 已有更好答案:`activation: 'optional'` + 用户配置开关 + `onAvailable` 被动等待。若不够,应加"插件请求启用某 optional child、宿主弹窗由用户确认",决定权在用户 | +| 参数化多实例 | 配置层已支持(`children` 数组 + 不同 scope)。若诉求是"运行时才知道要几个",那是插件内部数据结构问题,不是插件粒度问题 | +| 运行时生成代码注册为插件 | 一票否决。等于同时绕过 marketplace、构建期边界校验(`client-source-boundary.ts` 只在构建期跑)与 CSP | + +### 正确的落点 + +`PluginOverlayConfig`(`packages/types/src/plugin.ts:76`)的 `mode: 'extend' | 'override'` 与 `overlaySource` 已贯穿整棵解析树,spec/entity 层已在用。要扩展动态插件图应扩展这里。 + +## 纪律 2:视图扩展优先扩格式词汇表,而非开组件槽 + +视图扩展存在一条能力光谱: + +| 方式 | 贡献什么 | 表达力 | 信任成本 | +| -------------- | ------------------------- | ------ | -------------- | +| 元数据贡献 | `{id,title,icon,command}` | 低 | 无 | +| 声明式渲染描述 | path + format + item 映射 | 中高 | 无(格式封闭) | +| 协议投影 | 跨进程事件 + 上面的描述 | 中高 | 进程边界隔离 | +| 视图槽挂组件 | React 节点 | 最高 | owner 让出画布 | +| iframe | 整页 | 最高 | 强隔离,代价大 | + +**决策顺序:** + +1. **先扩格式词汇表。** 有人要塞组件时,先问"缺的是哪个 format"。`toolUsePresentations` 证明了很多"必须自定义渲染"的需求实际是"宿主的声明式格式不够用"——cua-driver 的嵌套对象数组 + 渐进披露,一份 schema 就解决了,还白拿 i18n、主题、无障碍与一致性。补一个 `table` / `diff` / `timeline` / `progress` 受益的是所有插件。 +2. **把声明式渲染推广到别处。** 目前 `toolUsePresentations` 只服务 `chat.toolUse.presentations` 一个槽。预留的 `message.renderers`、`settings.sections`、`workspace.resourceOpeners` 应复用同一套 field/format 描述,而非各自发明。 +3. **视图槽留给真正无法声明化的场景**(自由画布、图编辑器、地图)。 + +### 若开视图槽,四个前置条件 + +1. **ErrorBoundary 是前置条件,不是可选项。** 现状:`apps/client/src/plugins/` 与 `components/plugins/` 下**零个** `ErrorBoundary` / `componentDidCatch`,`PluginHost.tsx:275` 是裸渲染 `view.renderNode(viewContext)`。单插件页面崩溃只影响自己尚可接受;一旦 contributor 组件挂进 owner 页面,一个异常会带塌 owner 整页,而用户只会认为是 owner 插件坏了。**此项与是否做视图槽无关,应独立先做。** +2. **挂载权归宿主。** owner 拿到的必须是宿主包好的不透明节点(内部仍走 `PluginHost` 的 `(scope, viewId)` 路径),而非 contributor 的组件引用。否则 contributor 代码会跑在 **owner 的 viewContext** 里——`view.options.update()` 会把配置写到 owner 头上,`data.useQuery` 的 SWR key 前缀也会串(`PluginHost.tsx:136, 160`)。 +3. **扩展点须显式声明接受视图**,并携带布局约束(`maxHeight` / `orientation` / 是否允许自撑高),由宿主在包裹层强制。默认应保持数据模式。 +4. **顺序必须稳定可预期**——按 `order` 字段或 `pluginScope` 字典序,不能是 Map 插入顺序(那取决于插件激活顺序,而激活顺序本身不保证,这正是 `onAvailable` 要解决的问题)。 + +## 纪律 3:注册型 seam 走常驻 runtime,不扩 hook 事件表 + +hook 传输是跨进程的(`call-hook.js` 用 `spawn`,`worker-client.ts` 维护 worker 池),形态是"一次事件,JSON 进 JSON 出"。 + +- 对**拦截型**完美契合——事件本来就是离散的 +- 对**注册型**不成立——LLM adapter 要维持流式连接、跨多次调用持有状态 + +因此新增注册型 seam 应落在 `registerLocalService` 那条常驻线上,而非新增 hook 事件。 + +**DSH 提供了一个可行的折中形态**:`SubagentProvider` 的 `start()` 只负责"怎么起、怎么说话",真正的长连接与进程生命周期由宿主的 `ctx.subprocess` 托管。`subagent-claude-code` 尤其典型——SDK 自己要拉进程,它用 `spawnClaudeCodeProcess` hook 把进程句柄夺回来交给宿主统管,于是 teardown 阶梯、孤儿进程回收、超时全归宿主。 + +**插件提供协议适配,宿主拥有进程和生命周期** —— 这个形态比让插件直接持有连接安全得多,且已被上游验证。 + +## 纪律 4:禁止 accepted-then-ignored + +能力不支持时必须 fail loud,不得静默降级。 + +DSH 把这条作为相对 Claude Code 的**刻意分歧**记录在案:hook 误用在 CC 里退化成 `null`,DSH 一律 fatal 抛出。其远程 subagent provider 的 `NO_START_CAPABILITIES` 也是同理——服务层在 `start()` 之前就抛 `UNSUPPORTED_CAPABILITY`,而非接受后忽略。 + +我们已有部分实践(`resolveInstance` 的环检测抛错、scope 冲突启动期 fatal、`duplicate()` 诊断),应确立为统一纪律。 + +**反例警示**:`subagent-acp` 的 `toAcpPrompt()` 把非 text block **静默丢弃**,而同抽象下的 Codex / Claude Code provider 则**直接抛错**。同一 seam 两种行为是需要避免的形态。 + +## 纪律 5:trust / scope 字段的语义须明确写出 + +DSH 的 `PresetTrust` README 写得很直白:trust 字段"exists so consumers can present that difference, **not to enforce it**"。 + +我们的 `scope` 同理——它是**逻辑隔离**(防命名冲突、划分 API 命名空间),真正的安全边界来自进程边界、CSP、构建期校验与 proxy 白名单。这一点必须在文档中明确,避免团队产生虚假安全感。 + +**当前需要澄清的一处**:因为 child 默认继承 parent scope(`plugin-resolver.ts:917`),parent 与 child 落在同一 scope 命名空间。已确认 `runtime.ts:2802` 的冲突检查针对的是内置 route key,同 scope 内 command id 撞名的处理路径尚未核实,应在实现能力目录时一并查清并写入文档。 + +## 纪律 6:Model-visible ⟺ logged(建议采纳) + +来自 DSH `AGENTS.md`:任何进入模型请求的内容必须能从 session log 重建;新增模型可见输入必须同时新增 session event。 + +这条对可复现性、审计与"用户能看懂 agent 为什么这么做"是根本性的,且与开放程度无关。DSH 的 `agent-preset/selected` 会话事件就是例证——因为 preset 决定模型看到的工具 schema 与 prompt,切换必须可从日志重建。 + +## 纪律 7:capability seam 的定义 + +来自 DSH `AGENTS.md`:**一个 capability seam 由 Service Definition / Service Provider / Consumer 三个 role 构成,单个 role 不构成 seam。** + +这个定义可以直接用来防止"开了个接口但没人实现也没人消费"的假扩展点。新增 seam 的评审应要求三个 role 同时存在或有明确规划。 diff --git a/.oo/rfcs/0011-plugin-extensibility-current-surface.md b/.oo/rfcs/0011-plugin-extensibility-current-surface.md new file mode 100644 index 000000000..67d8e28e4 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-current-surface.md @@ -0,0 +1,137 @@ +# RFC 0011: 现有扩展面盘点 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +本章记录 One Works 插件系统**当前实际具备**的扩展能力,作为后续讨论的基线。所有条目都标注了源码位置。 + +## 1. 插件实例图(配置解析层) + +插件实例来自四个来源,后层覆盖前层:全局 `~/.oneworks/global/plugins/*`、项目 `.oo/plugins.dev/*`(默认 `watch: true`)、`plugins` 配置、运行时/任务 overlay。 + +**manifest `children` —— 静态组合依赖**(`packages/utils/src/plugin-resolver.ts:891-985`): + +- 父插件 manifest 声明 `children: { "": { source: {type:'package'|'directory', ...}, activation: 'default'|'optional' } }` +- `activation: 'default'` 自动激活;`'optional'` 需用户显式声明 +- 环检测:`ancestorKeys` + `cycleKey`,撞环抛 `Detected cyclic child plugin graph`(`:908-911`) +- scope 继承:`const scope = config.scope ?? inheritedScope`(`:917`) +- options 合并:manifest 的 child options 打底,用户配置浅覆盖(`mergeOptions`) +- 用户可覆写单个 child,含 `enabled: false` 关掉默认激活的(`hasExplicitChildOverride`) +- 目录 fallback:`collectFallbackDirectoryChildren` 把插件目录下的子目录登记为 `optional` child + +**任务级 overlay**:`PluginOverlayConfig`(`packages/types/src/plugin.ts:76`)的 `mode: 'extend' | 'override'`,`overlaySource` 贯穿整棵解析树(`plugin-resolver.ts:887, 945, 959, 983`),已在 spec/entity 层使用(`packages/workspace-assets/src/prompt-selection.ts:77-81`)。 + +**skill 依赖锁**:插件可依赖外部 skill 文档,经 lockfile 的 `pluginSkills` 把外部安装的 `SKILL.md` 挂到插件实例名下,标记 `plugin-skill-dependency-lock`(`packages/workspace-assets/src/bundle-internal.ts:682-705`)。 + +## 2. 插件间依赖装配(运行时层) + +**这一层是完整的**,语义等价于 Cordis 的 `inject`/`provide`。参考实现是 `packages/plugins/demo` 与 `packages/plugins/demo-extension` 这对。 + +### Extension point + +- `ctx.extensionPoints.register({ id, title, contributionSchema })` —— 暴露扩展点,完整 id 为 `/` +- `ctx.extensionPoints.onAvailable(target, cb)` —— **等待语义**:目标已存在立即触发,不存在则挂起,目标注册时唤醒(`apps/client/src/plugins/plugin-registry.ts:635-674`)。`registerExtensionPoint` 注册后调 `activateExtensionPointListeners(key)` 回头唤醒所有等待者(`:630`) +- `ctx.extensionPoints.contribute(target, contribution)` —— 贡献结构化能力 +- manifest 的静态 `extensionContributions` **也走 `onAvailable`**(`:844-847`),所以声明式贡献同样不怕激活顺序 + +**回收语义**:扩展点 dispose 时 `deactivateExtensionPointListeners` 逐个 `disposeExtensionPointListener`,执行 listener 回调返回的 cleanup(`:619-624, 1019-1023, 1061-1066`)。 + +**竞态保护**:`listener.version` 每次激活自增,异步 handler resolve 回来时比对,不匹配则丢弃刚拿到的 disposable(`:1031-1055`)。等价于 Cordis fiber 的 `epoch`。 + +### Plugin API + +- `ctx.pluginApis.register({ id, inputSchema, outputSchema, handler })` —— handler 的 `meta` 带 `callerScope` / `targetScope` / `apiId` +- `ctx.pluginApis.call(target, input, options?)` —— 目标未注册时不报错,进 `pendingPluginApiCalls` 挂起,`registerPluginApi` 里 `drainPendingPluginApiCalls(key)` 排空(`:472-494, 1091-1128`)。支持 `timeoutMs` 与 `AbortSignal`,调用方插件卸载时通过 signal reject 挂起的 Promise + +### 作用域回收 + +整套清理以 scope 为单位:`disposablesByScope`、`removeExtensionPointListenersByScope`、`rollbackScopeRegistrations(scope, owner)`、`disposeScope(scope)`。owner 是 `Object.freeze` 的 token 存在 `WeakSet` 里(`:190, 340`),用于激活轮次回滚。 + +## 3. 视图侧扩展 + +存在**四种**形态,其中两种已在生产使用: + +| 形态 | 贡献什么 | 状态 | +| ------------------ | -------------------------------------------- | ------------------------------------------------------------------ | +| 元数据贡献 | `{id, title, icon, command}`,owner 自行渲染 | ✅ `extensionContributions` + `view.extensions.getContributions()` | +| **声明式渲染描述** | path + format + item 映射,宿主渲染 | ✅ `toolUsePresentations` | +| 协议投影 | 跨进程事件 + 上面的描述 | ✅ ACP / adapter event projection | +| 视图槽挂组件 | React 节点 | ❌ 不存在 | + +### `toolUsePresentations` + +插件提交**结构化渲染指令**,宿主据此渲染任意工具调用的输入输出(`apps/client/src/plugins/plugin-tool-use.ts`): + +- 字段描述:`{ path, title, format, item: { titlePath, subtitlePath, statusPath, metaPath, detailPath } }` +- 输入格式集合封闭:`inline | text | code | list | chips | records | json` +- 结果格式:`auto | text | code | json | markdown`,另有 `mode: auto | declared | hidden` 做渐进披露 +- 实例参考:`packages/plugins/cua-driver/plugin.json:145` 起 + +**权限设计**:`origin` 默认只能接管自己 scope 下的工具,经 base64 编码的 `oneworks-` 命名空间反解校验(`isToolFromPluginScope`);接管别家工具须显式 `origin: 'any'`,且匹配优先级更低(20/10 vs 40/30)。**表达力做加法,权限做减法。** + +约束见 `packages/plugins/cli-skills/skills/create-plugin/SKILL.md:74`:不允许可执行模板、任意 HTML 或插件私有 React renderer。 + +### 视图侧的已知缺口 + +`view.extensions.getContributions(target)` 返回的是数据记录(`apps/client/src/plugins/plugin-manifest.ts:800`),owner 自行渲染。**无法把 React 组件贡献进别人的视图。** 相关设计约束见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)。 + +## 4. Agent loop 拦截(`@oneworks/hooks`) + +15 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: + +| 事件 | 插件能做什么 | +| --------------------------------------------------- | ----------------------------------------------------------------------------- | +| `PreToolUse` | `permissionDecision: 'allow' \| 'deny' \| 'ask'` + 理由 —— **可否决工具调用** | +| `PostToolUse` / `UserPromptSubmit` / `SessionStart` | `additionalContext` 注入 | +| `PreCompact` | `additionalContext` + **`replacementPrompt`** | +| `GenerateSystemPrompt` | system prompt 生成 seam | +| `TaskStart` / `TaskStop` | 拿到 `options` / `adapterOptions` | +| 任意事件 | `continue: false` + `stopReason` —— **可终止循环** | + +插件接口是 koa 式中间件链(`packages/hooks/src/context.ts:11-22`): + +```ts +export type Plugin = + & { name?: string } + & { + [P in keyof HookInputs]: (ctx, input, next) => Promise + } +``` + +`callPluginHook` 按顺序串联,可 `await next()` 后改结果,也可短路(`packages/hooks/src/plugin-hook.ts`)。 + +**跨适配器统一**:`HookSource = 'native' | 'bridge'`。适配器原生支持 hook 的直接透传;不支持的由 `packages/hooks/src/bridge.ts`(516 行)把会话消息与工具事件**合成**成统一 hook 协议。协议形状对齐 Claude Code(`type.ts` 的 JSDoc 直接链到 `docs.anthropic.com/.../hooks`)。 + +**传输是跨进程的**:`call-hook.js` 用 `spawn` 起子进程,`worker-client.ts` 维护 worker 池预热。这决定了它只能承载拦截型 seam,见[总览的结论摘要](0011-plugin-extensibility.md#结论摘要)。 + +**宿主自身也走这条链**:`packages/hooks/src/builtin-permissions.ts` 是一个内置 hook 插件,读权限镜像文件做 allow/deny 判定。即第三方 hook 插件与宿主权限执行器在同一条链上,顺序决定谁说了算。 + +## 5. 服务端插件运行时 + +`PluginServerContext`(`apps/server/src/services/plugins/types.ts:244-268`)的注册原语: + +- `registerCommand(commandId, handler)` +- `registerApi(apiId, options)` —— `handler` 模式或 `proxy` 模式 +- `registerLocalService(serviceId, start)` —— 生命周期绑到 workspace service +- `runtime.registerChannel(channelId, handler)` / `invokeChannel(...)` + +以及三个 facade:`sessions`(`listSessions` / `submitMessage`)、`oneworksChannel`、`roomTunnel`。 + +## 6. 安全边界 + +- 前端插件不直接访问文件系统;服务端插件不能注册顶层 `/api/*`,只能在 `/api/plugins/:scope/*` 下 +- `proxy.ts`:仅允许 loopback 目标(`isLoopbackProxyTarget`),转发前剥掉 `authorization` / `cookie` / `proxy-authorization` +- client asset 路由拦 `..`、绝对路径、null 字节、符号链接逃逸,强制 `X-Content-Type-Options: nosniff` +- `client-source-boundary.ts` 在**构建期**校验源码引用边界(Vite `enforce: 'pre'` transform) +- CSP:`script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'`,**无 `blob:`**(`apps/client/index.html:7`),插件代码只能同源经 `/api/plugins/:scope/client/*` 加载 + +## 已知误判记录 + +本 RFC 调研过程中对自身扩展面出现的错误判断,保留在此作为"为什么需要生成式能力目录"的证据: + +| 误判 | 实际情况 | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| "插件之间不能声明依赖,没有 inject 语义" | `children` 是组合依赖;`extensionPoints.onAvailable` + `pluginApis.call` 是完整的运行时依赖装配,含等待语义、自动回收、epoch 竞态保护 | +| "没有视图侧扩展点" | `toolUsePresentations` 是完整的声明式渲染扩展,已在 cua-driver / browser-driver / external-browser-driver 生产使用 | +| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 15 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | + +三次误判都是在能读到完整代码库的前提下发生的,根因是能力面分散在手写文档(`.oo/docs/usage/plugins/ui-runtime.md` 400+ 行)、SKILL.md 和源码之间,没有单一事实源。 diff --git a/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md b/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md new file mode 100644 index 000000000..cb051facc --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md @@ -0,0 +1,159 @@ +# RFC 0011: DSH / Cordis 结构对照 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +对照上游: `deepseek-ai/deepseek-harness@99f6f02`、`cordiverse/cordis@f46ae95`(cordis 4.0.0-rc.8) + +本章的作用是分清"我们缺的"与"我们刻意不做的"。上游行号对应上述固定 revision。 + +## 1. Cordis 与我们不是同一物种 + +`vendors/cordiverse/cordis` 此前未 checkout,本次调研拉取后阅读了 `packages/core`。 + +**趋同的那一层不是 Cordis 的特色。** `onAvailable` + epoch 防竞态 + 提供方消失回收消费方,是 late binding + 生命周期这一问题的通用解,OSGi ServiceTracker、Eclipse extension point、VS Code `activationEvents` + `extensionDependencies` + `extension.exports` 都是同一形状。收敛源自约束相同,非同源。 + +论亲缘,One Works 更接近 VS Code Extension Host,术语也来自 Eclipse/VS Code 一支:extension point、contribution、activation。 + +### 三条结构性差异 + +**(1) Cordis 是自举的,我们不是。** Cordis 的 loader、hmr、timer、logger-console 自己都是 `@cordisjs/plugin-*`,与用户插件进同一 registry、同一套 fiber 生命周期;除 `packages/core` 这个容器外没有特权核心。 + +我们相反:discovery、runtime、marketplace、HMR 全是宿主代码(`apps/server/src/services/plugins/`),且插件不能占用 `sessions` / `config` / `workspace` / `agent-rooms` 等内置 route key。**有特权宿主 + 只能做加法的扩展**,对 **没有中心、一切皆插件**。 + +**(2) ctx 是继承链 vs 固定 API 表。** Cordis 的 `Context` 是活的:`ctx.plugin(x)` 让插件在运行时动态加载另一个插件,产生子 Context,contexts 形成原型链,`ctx.isolate(name)` 造影子命名空间。 + +我们的 client ctx 是扁平的 16 个 key(`api` / `commands` / `extensionPoints` / `pluginApis` / `routes` / `slots` / `views` / `themes` / `launcher` / `notifications` / `runtime` / `react` / `hot` / `i18n` / `manifest` / `scope`),**没有任何一个能实例化另一个插件**。 + +**(3) 属性注入 vs 带 schema 的调用。** Cordis 的 `provide` 把对象挂到 context 上(`ctx.database` 即实例),靠 `ReflectService`(281 行 Proxy)追踪访问归属做自动清理,拿到的是**对象引用**。 + +我们是 `ctx.pluginApis.call('scope/id', input)`,带 `inputSchema` / `outputSchema`,`meta` 给提供方 `callerScope`。拿到的是**一次调用的返回值**。这不是风格差异——属性注入无法审计、无法拒绝、无法跨进程;带 schema 的调用三样都能。 + +## 2. DSH 暴露给插件的 55 个 ctx 服务 + +``` +agentDefaultModel agentLoop agentPresets agents apiProxy approval attachments +clientModules codeRuntime commands compaction credentials directoryPicker e2b fs +goals invariants jobs llm lsp messageFeedback permissionPresets planMode sandbox +sandboxPolicy sessionPersistence sessionProjections sessionQuery sessions +sessionTitle settings shell shellEnv skills spillStore storage subagents +subprocess systemPrompt terminals timer tokenMeter toolResultPruner tools +typert userQuestions web webServer workflowEngine workspaceRegistry ... +``` + +外加 55 个事件(`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`approval/request`、`llm/stream`、`system-prompt/assemble` 等)。 + +**核心机制是插件既消费又提供实现**: + +```ts +export const inject = ['llm'] +ctx.llm.registerAdapter(['deepseek-official'], adapter) +``` + +`ctx.llm` 的契约(`packages/llm/llm/src/index.ts:338`):`registerAdapter` 返回随 fiber 销毁的 handle,重复注册抛 `DUPLICATE_ADAPTER`(全有或全无);`registerConfigurableProviders` 声明"可由配置激活的休眠 provider 路由";API key 走 `ctx.credentials` 凭证 seam,插件拿 ref 不拿明文。 + +### 信任前提不同 + +`packages/preset/agent-presets/src/preset.ts:5-8`: + +> a `user` preset was authored locally, by a person or by an agent, and therefore **carries the same trust as shell access**. + +且 `README.md:133-135` 明确 trust 字段"exists so consumers can present that difference, **not to enforce it**"——它只影响写路径(`remove()` 拒绝非 user preset、`copy()` 落到第一个 user root),不是权限沙箱。 + +同样的坦率也见于 `packages/extensions/tool-cordis`("Treat this toolset like bash access")与 workflow("A vm context and worker thread are not security boundaries")。 + +**DSH 的插件等同于 shell 权限,所以它敢把 `agentLoop` / `tools` / `approval` / `sandboxPolicy` 全开。** 我们是 marketplace 分发 + 卸载账本 + 构建期边界校验 + CSP,不能整套照抄。 + +## 3. 逐项对照 + +### 拦截型 seam:我们基本齐平 + +| DSH | One Works | +| ----------------------------------------- | ---------------------------------------------------------- | +| `tools/pre-execute` + `PreToolDecision` | `PreToolUse` + `permissionDecision` ✅ | +| `tools/post-execute` + `PostToolDecision` | `PostToolUse` + `additionalContext` ✅ | +| `system-prompt/assemble` | `GenerateSystemPrompt` ✅ | +| `PreCompact` / `ctx.compaction` | `PreCompact` + `replacementPrompt` ✅ | +| `session/created` `/disposed` | `SessionStart` / `SessionEnd` ✅ | +| `agent/pre-step` + `PreStepDecision` | `continue: false` ⚠️ 粒度粗 | +| `ctx.approval` | `permissionDecision: 'ask'` ⚠️ 只能触发,不能自定义审批策略 | + +### 注册型 seam:我们没有 + +| DSH | One Works | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| `ctx.llm.registerAdapter` | ❌ `packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表 | +| `ctx.subagents.registerProvider` | ❌ 适配器是编译期内置(根 `package.json` devDependencies + 静态 import) | +| `ctx.tools` 注册工具 | ⚠️ 走 MCP,不走插件 seam | +| `skills` / `sessionTitle` / `web` / `lsp` provider | ❌ | + +**这不是遗漏。** hook 传输是"每事件一次子进程往返",对拦截型完美契合,对注册型根本不成立——LLM adapter 要维持流式连接、跨多次调用持有状态。要开注册型 seam 得走常驻 server plugin runtime。 + +## 4. DSH 如何调度外部 code agent + +`packages/subagent/` 下有 11 个包,其中 4 个是 out-of-process backend: + +| provider | 传输 | 进程归属 | +| ---------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| `subagent-codex` | `codex app-server --stdio`,私有 wire | `ctx.subprocess.spawn` | +| `subagent-claude-code` | 官方 `@anthropic-ai/claude-agent-sdk` 的 `query()` | SDK 经 `spawnClaudeCodeProcess` hook 把 CLI 进程交回 `ctx.subprocess` 托管 | +| `subagent-acp` | 通用 ACP over ndjson stdio | `ctx.subprocess.spawn` | +| `subagent-dsh-sdk` | stdio JSON-RPC,子进程是第二个完整 DSH runtime | SDK 自己 | + +**抽象极简**(`packages/subagent/subagent/src/types.ts:285-324`):3 个只读字段 + 1 个必需方法 `start()`。跨进程写进抽象里——`SubagentRun.localAgent: Agent | undefined`,`undefined` 即远程;`subagent/` 包自带 215 行 `out-of-process.ts` 放公共词汇(cwd 解析、永不 reject 的结算、幂等 dispose handle)。 + +**已明文记录的限制**:四个远程 provider 全部 `NO_START_CAPABILITIES`(不能指定 outputSchema / persona / toolFilter / depthLimit)、`inheritsParentContext: false`、均未实现 `prepareContinuable`(**只能 one-shot**)、跨进程只传文本、无人工审批路径、Claude Code 不流式(取消时无部分答案)、`ctx.subagents.interrupt()` 对远程子无效。 + +**ACP 是双向的**:`packages/acp/acp/` 是 server(`AgentSideConnection`),`subagent-acp` 是 client(`ClientSideConnection`)。按能力归属而非协议归属分包。 + +### Workflow 的异构粒度 + +workflow 脚本是模型现写的普通 JS,realm 注入 5 个全局:`agent()` / `parallel()`(有 barrier)/ `pipeline()`(无 barrier)/ `phase()`(纯进度分组)/ `log()`。脚本内**无 fs / network / timer / Node API**。 + +**一次 run 只绑一个 subagent provider**(`workflow-worker-thread/src/host.ts:139`),`ChildStartRequest` 没有"选 provider"字段,文档明说脚本 "cannot observe or replace either policy"。 + +- ❌ "第一步 DSH、第二步 Claude Code" —— workflow 层做不到 +- ✅ "整个 workflow 全跑 Claude Code CLI" —— 改 `provider` 即可 +- ✅ 逐轮异构 —— 在**工具层**:`standard` preset 把同一个 `dsh-tool-subagent` 挂 4 次绑不同 provider,暴露成 `subagent` / `subagent_fork` / `subagent_codex` / `subagent_claude_code`,父 agent 在自己回合里逐个调用 + +易混点:`agent(prompt, { provider, model })` 的 `provider` 是 **LLM 路由**,与 subagent 传输后端是两个命名空间。 + +## 5. 深度对比:我们更深,形态它更开 + +DSH 的 3 个 product provider 是薄的(one-shot、不继承上下文、纯文本、无审批)。我们 16 个适配器有统一 hook 协议、账号池、历史导入、权限镜像、原生历史自动导入,不是一个量级。 + +但形态差异带来的后果已经显现。DSH 的 `CONTRIBUTING.md` 明确不收外部 PR,把人推向 `dsh-plugin` topic,并声明: + +> You may consider this repository an idea, an official showcase, and a source of inspiration, **but not a mandate from us**. + +其社区已产出与 One Works 产品面高度重叠的插件:跨 14+ agent 的历史导入、跨 agent SKILL.md 移植、Cursor/Gemini/Copilot workspace instruction 加载、可视化插件市场、开放侧边栏底座、内联 GenUI 渲染、多个 TUI/VS Code/桌面前端,以及第三方版的 Codex/Claude Code/ACP subagent provider(带两层权限模型与"子 agent 不能派生权限更高的后代"约束)。 + +注意:`dsh-plugin` topic 下约 7,466 个仓库,噪音极高(含大量无关项目蹭 tag),**该数字不能作为插件数量的可信指标**;上述条目经逐条核对描述。 + +## 6. 文档体系对照 + +DSH 是三层: + +1. **概念地图**(手写)—— `docs/architecture.md`,"Events are the extension points" + "Where new behavior goes" 目标→机制表 +2. **生成式目录**(机器生成 + CI 门禁)—— `docs/capability-seams.md`(逐行列 ~55 个 `ctx.*` 的 role / owner / 实现 / 消费者)、`docs/event-producer-consumer.md`(每事件的 dispatch mode、声明位置带文件:行号、生产者、消费者)、`docs/tool-catalog.md`(真实 boot 后读 `ctx.tools.schemas()`)、`docs/config-catalog.md` +3. **feature → mechanism 对照** —— `docs/cookbook/extension-cookbook.md` + +生成器 `scripts/gen-cordis-api.ts` + `verify-cordis-api --check` 挂在 doc-sync 门禁;且 `docs/user/develop/framework/service.md` 明文拒绝维护第二份手工清单。产出还通过 `cordis_inspect` 工具喂给模型。 + +**诚实的限定**:即便如此仍有轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),事件部分的行号则全部正确。生成 + 门禁不是银弹,但显著优于纯手工。 + +我们当前是手写 `.oo/docs/usage/plugins/ui-runtime.md`(400+ 行)+ `create-plugin/SKILL.md`,无生成、无门禁。 + +## 7. 分发模型对照 + +DSH 是 bundle(npm 包,`package.json` 声明 `dsh.bundle.patch` 指向 `cordis.patch.yml`)+ profile(`$DSH_HOME/profiles/` 目录,声明有序 bundle 列表)。层序覆盖,**patch 是整体替换 row 的 `config`,不是深合并**(我们的 `mergeOptions` 是浅合并,两种都可行但须写进文档)。 + +最小插件骨架: + +```ts +export const name = 'hello-plugin' +export function apply(ctx: Context) {/* ... */} +``` + +对比我们需要 `plugin.json` manifest + client/server 双 entry + vite 构建。 + +从 GitHub 安装需用户开 `allowBuilds`,其文档直白提醒这等于"允许该包在你机器上、在 agent sandbox 之外执行代码"。 diff --git a/.oo/rfcs/0011-plugin-extensibility.md b/.oo/rfcs/0011-plugin-extensibility.md new file mode 100644 index 000000000..b78ff3024 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility.md @@ -0,0 +1,57 @@ +# RFC 0011: 插件扩展面盘点与边界 + +返回入口:[RFC 索引](../../rfc.md) + +Status: 调研完成,待决策\ +对照上游: `deepseek-ai/deepseek-harness@99f6f02`(release/dsh-0.1.0-rc.7)、`cordiverse/cordis@f46ae95`(cordis 4.0.0-rc.8)\ +Reviewed: 2026-08-18 + +## 背景 + +One Works 的插件扩展面是分多轮长出来的:`plugins` 配置与 manifest、client/server 双运行时、extension point 与 plugin API、`@oneworks/hooks` 中间件链、marketplace 分发。每一层都有实现,但**没有单一事实源**描述"插件到底能做什么"。 + +这带来两个具体后果: + +1. 内部评审时对现有能力的判断会出错。本 RFC 的调研过程中,对自身扩展面出现过三次错误判断(详见 [现有扩展面盘点](0011-plugin-extensibility-current-surface.md) 的"已知误判记录"),而调研是拿着完整代码库做的。插件作者只会更容易出错。 +2. 新增扩展点时缺少可引用的边界依据,每次都要重新论证。 + +同时,DeepSeek Harness(DSH,基于 Cordis)作为同类系统提供了有价值的对照:它把几乎全部运行时能力做成了命名 seam,并配套了生成式能力目录。它的社区在数月内长出了与 One Works 产品面高度重叠的插件。 + +## 目标 + +- 盘点 One Works 插件系统**当前实际具备**的扩展能力,建立可引用的基线。 +- 与 DSH/Cordis 做结构性对照,分清"我们缺的"与"我们刻意不做的"。 +- 把已达成的边界判断写成可引用的纪律,避免重复论证。 +- 给出按优先级排序的行动项,区分"技术决策"与"需要产品决策"。 + +## 非目标 + +- 本 RFC 不新增任何扩展点,也不修改任何运行时行为。 +- 不对"扩展面开放到什么程度"给出结论——该判断涉及商业路径与维护成本,属于产品决策。 + +## 章节 + +- [现有扩展面盘点](0011-plugin-extensibility-current-surface.md) +- [DSH / Cordis 结构对照](0011-plugin-extensibility-dsh-comparison.md) +- [边界与设计纪律](0011-plugin-extensibility-boundaries.md) +- [行动项与优先级](0011-plugin-extensibility-actions.md) + +## 结论摘要 + +**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 15 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 + +**真正缺失的是"注册型 seam"。** 现有 seam 全部是拦截型:宿主流程跑到某个点回调插件,插件可否决或增补,但不提供实现。缺的是"插件提供一个实现并成为运行时一部分"——典型是 model provider。这不是遗漏,而是 hook 的跨进程传输形态(每事件一次子进程往返)天然只能承载拦截型。要开注册型 seam 需走常驻 server plugin runtime,不是扩展 hook 事件表。 + +**结构性差异只有一条:seam vs 编译期内置。** 我们的 16 个适配器在深度上显著超过 DSH 的 3 个 out-of-process provider(统一 hook 协议、账号池、历史导入、权限镜像),但它们是编译期内置;DSH 的是 `SubagentProvider` seam,第三方发 npm 包、用户配置加一行即可接入。 + +**最高优先级的行动项不涉及任何信任决策:** 抽通用 ACP 适配器层(当前 cline/dsh/goose 各实现一遍),以及建立生成式能力目录 + CI 门禁。详见[行动项](0011-plugin-extensibility-actions.md)。 + +## 调研方法 + +本 RFC 的事实基础来自: + +- 直接阅读 One Works 仓库源码(路径与行号在各章节内标注)。 +- 拉取 `vendors/cordiverse/cordis` submodule(此前未 checkout)并阅读 `packages/core` 源码。 +- 克隆 `deepseek-ai/deepseek-harness` 并由四个并行子任务分别调研:subagent provider 体系、ACP 与外部 agent 集成、workflow 与 preset 编排、插件生态与官方文档。 + +所有结论均标注了来源位置。上游行号对应上述固定 revision,升级上游后需重新核对。 diff --git a/rfc.md b/rfc.md index 4c0294523..ca7b27251 100644 --- a/rfc.md +++ b/rfc.md @@ -17,6 +17,14 @@ - [运行时 API 与服务契约](.oo/rfcs/0006-standard-voice-runtime.md) - [Sender 交互、落地计划与验证](.oo/rfcs/0006-standard-voice-sender-plan.md) +## 插件扩展面 RFC + +- [总览与结论](.oo/rfcs/0011-plugin-extensibility.md) +- [现有扩展面盘点](.oo/rfcs/0011-plugin-extensibility-current-surface.md) +- [DSH / Cordis 结构对照](.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md) +- [边界与设计纪律](.oo/rfcs/0011-plugin-extensibility-boundaries.md) +- [行动项与优先级](.oo/rfcs/0011-plugin-extensibility-actions.md) + ## 插件运行时 RFC - [目录结构、manifest 与共享契约](.oo/rfcs/plugin-runtime-layout-manifest.md) From f68d724079300665be77ef96b48ad5a34c2b20d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:07:23 +0000 Subject: [PATCH 2/8] docs(rfc): add hook/plugin convergence design (RFC 0012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse @oneworks/hooks and the plugin runtime into one extension surface. The hook subprocess becomes a normalising reporter; plugin code moves into whichever process drives the task, consuming one internal event stream. - events-api: ctx.events with three modes narrowed from Cordis's five. emit/parallel/serial fold into `notify` (awaiting is the dispatcher's choice, not the event's); `waterfall` becomes `transform`; `bail` is replaced by `decide` — an order-independent, monotonically-tightening adjudication that encodes "capabilities add, permissions subtract" into dispatch semantics rather than leaving it to each event's implementation - events: the vocabulary, renamed to DSH's namespace/kebab convention for migration parity, with per-source availability grading so unsupported subscriptions fail loud; four gap points identified against DSH, all in the model-request and around-dispatch layers - runtime: reporter contract, endpoint resolution (no daemon needed — the process driving the task is alive by construction), permission layering where host baseline is synchronous and plugins can only tighten, and an explicit priority contract replacing the current array-order guarantee - migration: five reversible steps, compat shim mapping for the old /hooks entry, and an honest capability matrix for a DSH plugin shim Also corrects the hook event count in RFC 0011 from 15 to 14. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- ...11-plugin-extensibility-current-surface.md | 4 +- .oo/rfcs/0011-plugin-extensibility.md | 2 +- ...0012-hook-plugin-convergence-events-api.md | 156 ++++++++++++++++++ .../0012-hook-plugin-convergence-events.md | 113 +++++++++++++ .../0012-hook-plugin-convergence-migration.md | 141 ++++++++++++++++ .../0012-hook-plugin-convergence-runtime.md | 115 +++++++++++++ .oo/rfcs/0012-hook-plugin-convergence.md | 76 +++++++++ rfc.md | 8 + 8 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-events-api.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-events.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-migration.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-runtime.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence.md diff --git a/.oo/rfcs/0011-plugin-extensibility-current-surface.md b/.oo/rfcs/0011-plugin-extensibility-current-surface.md index 67d8e28e4..ad998b6cb 100644 --- a/.oo/rfcs/0011-plugin-extensibility-current-surface.md +++ b/.oo/rfcs/0011-plugin-extensibility-current-surface.md @@ -76,7 +76,7 @@ ## 4. Agent loop 拦截(`@oneworks/hooks`) -15 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: +14 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: | 事件 | 插件能做什么 | | --------------------------------------------------- | ----------------------------------------------------------------------------- | @@ -132,6 +132,6 @@ export type Plugin = | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | "插件之间不能声明依赖,没有 inject 语义" | `children` 是组合依赖;`extensionPoints.onAvailable` + `pluginApis.call` 是完整的运行时依赖装配,含等待语义、自动回收、epoch 竞态保护 | | "没有视图侧扩展点" | `toolUsePresentations` 是完整的声明式渲染扩展,已在 cua-driver / browser-driver / external-browser-driver 生产使用 | -| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 15 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | +| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 14 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | 三次误判都是在能读到完整代码库的前提下发生的,根因是能力面分散在手写文档(`.oo/docs/usage/plugins/ui-runtime.md` 400+ 行)、SKILL.md 和源码之间,没有单一事实源。 diff --git a/.oo/rfcs/0011-plugin-extensibility.md b/.oo/rfcs/0011-plugin-extensibility.md index b78ff3024..470c8c849 100644 --- a/.oo/rfcs/0011-plugin-extensibility.md +++ b/.oo/rfcs/0011-plugin-extensibility.md @@ -38,7 +38,7 @@ One Works 的插件扩展面是分多轮长出来的:`plugins` 配置与 manif ## 结论摘要 -**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 15 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 +**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 14 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 **真正缺失的是"注册型 seam"。** 现有 seam 全部是拦截型:宿主流程跑到某个点回调插件,插件可否决或增补,但不提供实现。缺的是"插件提供一个实现并成为运行时一部分"——典型是 model provider。这不是遗漏,而是 hook 的跨进程传输形态(每事件一次子进程往返)天然只能承载拦截型。要开注册型 seam 需走常驻 server plugin runtime,不是扩展 hook 事件表。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md new file mode 100644 index 000000000..5c03bda74 --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -0,0 +1,156 @@ +# RFC 0012: 通用事件 API 设计 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章设计 `ctx.events` —— 插件系统的通用事件派发 API。设计参照 Cordis 的多 mode 事件模型(`cordiverse/cordis@f46ae95` 的 `packages/core/src/events.ts`),但在三处刻意收窄。 + +## 这不是新增原语,是收敛 + +RFC 0011 纪律 1 的配套约定是"不新增第三种跨插件通信原语"。本章不违反该约定,因为 `ctx.events` **取代**而非新增: + +| 现状 | 收敛后 | +| ------------------------------------------------------- | ------------------------------------------------------ | +| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `transform` / `decide` mode | +| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `notify` mode | +| `pluginApis.register/call` | **保留不动** —— 它是 1:1 有返回值的 RPC,不是事件 | +| `extensionPoints.register/contribute/getContributions` | **保留不动** —— 它是结构化贡献 registry,不是 dispatch | + +净效果是原语数量不变:hook 那套私有链被通用事件取代,`pluginApis` 与 `extensionPoints` 各司其职。 + +## Cordis 的五个 mode,我们取三个 + +Cordis `EventsService` 提供 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`)。逐条评估: + +| Cordis mode | 我们的结论 | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| `emit`(同步 fire-and-forget) | **不可用**。Cordis 单进程内同步派发;我们跨 client / server / 上报器三处,所有派发必须异步 | +| `parallel`(await 全部) | **并入 `notify`**。"派发方要不要等"是调用点的事,不该是事件定义的属性 | +| `serial`(顺序,无 `next()`) | **并入 `notify`**,用 dispatch 选项控制 fail-fast | +| `waterfall`(链式改写) | **保留**,改名 `transform` | +| `bail`(首个非空返回胜出) | **不采用**,用 `decide` 取代。理由见下 | + +### 为什么不要 `bail` + +`bail` 的语义是"第一个返回非空值的监听器胜出并短路"。在可信插件场景(Cordis / DSH 的前提是插件等同 shell 权限)这没问题;在 marketplace 分发场景下它是提权通道——**任何第三方插件都能抢占一个宿主关心的决策**,且抢占是静默的。 + +替代方案 `decide` 把"只能收紧"编码进 dispatch 语义本身,而不是指望每个事件的实现自觉。 + +## 三个 mode + +### `notify` —— 通知 + +```ts +type NotifyListener

= (payload: P) => void | Promise +``` + +监听器互相独立,任一失败不影响同侪,不影响派发结果。派发方通过选项决定是否等待全部完成。 + +用于:状态变更广播、审计、遥测、UI 更新。 + +### `transform` —— 链式改写 + +```ts +type TransformListener

= ( + payload: P, + next: (payload: P) => Promise

+) => Promise

+``` + +顺序执行,**必须调 `next()`**,不调即短路整条链。每个监听器可在 `await next()` 前后改写 payload。 + +这就是现有 hook chain 的语义,等价于 Cordis 的 `waterfall`。 + +用于:system prompt 组装、上下文注入、请求改写。 + +### `decide` —— 单向收紧的裁决 + +```ts +type DecideListener = ( + payload: P +) => D | undefined | Promise +``` + +**不是链式。** 所有监听器并行拿到同一份 payload,各自独立给出判定,宿主按事件定义的**收紧格**(meet)合并。返回 `undefined` 表示"无意见"。 + +关键性质: + +- **合并结果不可能比宿主基线更宽松。** 宿主判定是格的上界,插件只能向下拉。 +- **超时 = 无意见。** 慢插件不会拖垮 agent,也不会静默放宽(基线仍在)。 +- **顺序无关。** 合并是可交换的,因此不存在"谁先注册谁赢"的隐式依赖。 + +事件定义必须声明判定格。以工具权限为例: + +``` +allow ⊐ ask ⊐ deny +``` + +宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`(`allow` 在格中不低于 `deny`,取 meet 后不变)。 + +用于:权限裁决、内容策略、合规拦截。 + +**`decide` 是本设计相对 Cordis 的主要改进**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。 + +## API 形状 + +mode 声明在**事件定义**上,不在派发调用点。理由:定义方知道该事件如何派发,调用方不该能改;订阅方从定义即可知道自己的契约(要不要 `next`、能不能否决);且定义可被生成进能力目录。 + +```ts +// 定义(宿主或插件,事件 id 为 /) +ctx.events.define({ + name: 'before-save', + mode: 'transform', + payload: payloadSchema, + result: resultSchema, + summary: '保存前改写文档内容' +}) + +// 订阅(任意插件) +const off = ctx.events.on('demo/before-save', async (payload, next) => { + const result = await next({ ...payload, content: rewrite(payload.content) }) + return result +}) + +// 派发(仅定义方 scope 可派发) +const output = await ctx.events.dispatch('demo/before-save', payload) + +// 能力查询 +ctx.events.availability('agent/request') // 'both' | 'bridge' | 'native:' +``` + +### 约束 + +- **派发权归定义方。** 只有定义该事件的 scope 能 `dispatch`,否则第三方可以伪造宿主事件。 +- **`decide` 事件的定义必须带判定格**,否则 `define` 失败(fail loud,纪律 4)。 +- **订阅不支持的事件必须报诊断**,不得静默不触发。可用性经 `availability()` 查询。 +- **`transform` 监听器不调 `next()` 即短路** —— 这是刻意保留 Cordis 的语义,但必须在文档中明写,且短路事件要进诊断(避免"某插件悄悄吃掉了整条链")。 +- **顺序契约用显式 priority**,不用 Cordis 的 `prepend` 布尔。`notify` 顺序无关;`transform` 按 priority 升序;`decide` 顺序无关(合并可交换)。宿主内置监听器占用保留的 priority 段,第三方无法插到它前面。 + +## 与现有原语的边界 + +新人最容易混淆的是"什么时候用 events,什么时候用 pluginApis"。判据: + +| 场景 | 用什么 | +| ------------------------------------ | ---------------------- | +| 我要**通知**别人发生了什么 | `events` + `notify` | +| 我要让别人**改写**我的数据 | `events` + `transform` | +| 我要让别人**收紧**我的判定 | `events` + `decide` | +| 我要**调用**某个特定插件拿返回值 | `pluginApis.call` | +| 我要让别人**注册结构化贡献**供我读取 | `extensionPoints` | + +一句话:events 是一对多的派发,`pluginApis` 是一对一的调用,`extensionPoints` 是贡献登记。 + +## Hook 事件是内置事件集 + +收敛后,[事件词汇表](0012-hook-plugin-convergence-events.md)里的全部事件都是 `ctx.events` 的内置定义(由宿主 `define`,可用性按 source 分级)。插件订阅它们和订阅其他插件的事件走同一套 API,不存在"hook 插件"这个独立形态。 + +原 mode 词汇的映射: + +| 事件词汇表中的 mode | 本章 mode | +| ------------------- | -------------------------------------- | +| `waterfall` | `transform` | +| `emit` | `notify` | +| `serial` | `notify`(dispatch 时 fail-fast) | +| `parallel` | `notify`(dispatch 时 await all) | +| —— | `decide`(权限类事件专用,DSH 无对应) | + +据此,词汇表中 `tools/pre-execute` 标为 **`decide`** 而非 DSH 的 `waterfall`——它在我们这里是权限裁决而非数据改写。这是与 DSH 唯一的 mode 分歧,源于信任模型不同,兼容垫片需显式处理(见[迁移与兼容](0012-hook-plugin-convergence-migration.md))。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events.md b/.oo/rfcs/0012-hook-plugin-convergence-events.md new file mode 100644 index 000000000..9d6ef060b --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events.md @@ -0,0 +1,113 @@ +# RFC 0012: 事件词汇表 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章定义收敛后的内部事件标准。事件名对齐 DSH(`deepseek-ai/deepseek-harness@99f6f02`),差异处显式标注。 + +## Mode 词汇 + +派发语义与 API 形状见[通用事件 API 设计](0012-hook-plugin-convergence-events-api.md)。本章使用收窄后的三个 mode: + +| mode | 语义 | 对应 Cordis / DSH | +| ----------- | ------------------------- | ------------------------------ | +| `notify` | 通知,监听器互相独立 | `emit` / `parallel` / `serial` | +| `transform` | 链式改写,必须调 `next()` | `waterfall` | +| `decide` | 单向收紧的裁决,顺序无关 | 无对应(我们特有) | + +Mode 是事件定义的一等字段,插件作者不需要从名字推断,宿主据此决定如何派发与合并。 + +**与 DSH 的一处刻意分歧**:DSH 把 `tools/pre-execute` 标为 `waterfall`,我们标为 `decide`。原因是它在我们这里是权限裁决而非数据改写——DSH 的插件等同 shell 权限所以 waterfall 无妨,我们是 marketplace 分发,必须保证插件只能收紧。 + +## 可用性分级 + +`native` 源的事件由上游适配器 CLI 的 hook 协议决定;`bridge` 源由我们自己合成(`packages/hooks/src/bridge.ts`)。**并非所有事件在所有 source 下都可用。** + +| 级别 | 含义 | +| ------------------ | ----------------------------------------- | +| `both` | native 与 bridge 均可用 | +| `bridge` | 仅 bridge 源可用(上游 CLI 不暴露该点位) | +| `native:` | 仅特定适配器可用 | + +插件订阅一个当前 source 不支持的事件时,宿主**必须报出诊断而非静默不触发**(RFC 0011 纪律 4:禁止 accepted-then-ignored)。能力查询走 `ctx.events.availability(name)`。 + +## 迁移映射:现有 14 个事件 + +| 现有名 | 新名 | mode | 可用性 | 备注 | +| ---------------------- | ------------------------ | ---------- | ------ | ----------------------------------------------------------------------------------------------------- | +| `PreToolUse` | `tools/pre-execute` | **decide** | both | 名称对齐;**mode 刻意分歧**(DSH 为 waterfall),见上 | +| `PostToolUse` | `tools/post-execute` | transform | both | 与 DSH 完全对齐 | +| `GenerateSystemPrompt` | `system-prompt/assemble` | transform | both | 与 DSH 完全对齐 | +| `Stop` | `agent/turn-stopping` | notify | both | 名称对齐;DSH 为 `serial`,我们用 `notify` + fail-fast 派发 | +| `StopFailure` | `agent/error` | notify | both | 对齐 | +| `SubagentStop` | `subagent/end` | notify | both | 对齐 | +| `SessionStart` | `agent/session-start` | notify | both | 对齐 | +| `SessionEnd` | `session/disposed` | notify | both | 对齐 | +| `UserPromptSubmit` | `agent/prompt-submit` | transform | both | **无 1:1 对应**。DSH 最近的 `agent/pre-step` 语义更宽(每步触发)。用自有名字但守同一风格,不假装对齐 | +| `PreCompact` | `compaction/pre` | transform | both | **DSH 无此事件**(它走 `ctx.compaction` 服务)。我们粒度更细,保留 | +| `Notification` | `agent/notification` | notify | both | 我们自有 | +| `TaskStart` | `task/started` | notify | both | 我们自有(适配器概念) | +| `TaskStop` | `task/stopped` | notify | both | 我们自有 | +| `StartTasks` | `task/batch-start` | notify | both | 我们自有 | + +## 新增:建议补的四个点位 + +这四个是与 DSH 对照后确认的高价值缺口。共同特征是它们都在**模型请求那一层**或**工具执行的环绕层**,我们当前完全没有对应物。 + +### `agent/request` — transform — 可用性 `bridge` + +DSH 描述:"Replace the frozen call configuration." + +出站模型请求的最后一道关。插件可改写 system、tools、参数,也可完整审计请求内容。 + +**这是 RFC 0011 纪律 6「model-visible ⟺ logged」的天然落点**——凡进入模型请求的内容都从这里过,可复现性与审计天然成立。 + +可用性受限的原因:我们不自己发模型请求,`native` 源下这一层在适配器 CLI 的进程里,除非上游暴露该点位。**这一条必须诚实标注,不能假装 both。** + +### `agent/request-error` — transform — 可用性 `bridge` + +DSH 描述:"Handle one failed model-request attempt before the loop retries or closes its step." + +单次模型请求失败后、重试前的处理。DSH 的 `llm-retry` 就是纯靠这一个事件实现的插件。我们当前的重试逻辑散在各适配器里,无法统一策略或让用户覆盖。 + +### `tools/execute` — transform — 可用性 `bridge` + +DSH 描述:"Around-dispatch waterfall for timeout, retry, or metrics." + +环绕整个 dispatch。超时、重试、metrics 用一个事件解决,不必用 pre + post 手工拼状态机。 + +### `tools/result` — notify — 可用性 `both` + +DSH 描述:"Observe the frozen, lossless-JSON final outcome." + +与 `tools/post-execute` 分开的价值:post-execute 可改写结果,result 是**冻结只读**的。审计类消费者拿不到修改权,不会误伤。 + +## 中等价值缺口(本期不做,记录待评估) + +| DSH 事件 | mode | 价值 | +| ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `fs/write-intent` / `fs/edit-intent` | decide | 比工具粒度更细的单次文件写入决策。我们有 `packages/fs-authority-native`,需先查清是否已有等价机制未暴露 | +| `approval/request` | decide | 让插件参与审批**应答**,而非只能返回 `'ask'` 把球踢给用户 | +| `tools/change` / `skills/change` / `commands/change` / `system-prompt/change` | notify | 能力面变更通知,插件可感知工具集变化 | + +## 明确不跟的 + +`cordis/*`(自指运行时反射)、`workflow/*`、`goal/*`、`domain/changed`、`typert*`、`spill*`、`session/flush`、`agent/inbox/*` —— 对应子系统我们没有或形态不同。 + +## 事件定义的形式要求 + +每个事件的定义必须携带: + +- `name` —— `namespace/kebab-verb` +- `mode` —— `notify | transform | decide` +- `availability` —— `both | bridge | native:` +- `payload` —— 结构化 schema +- `result` —— `transform` 的返回契约 / `decide` 的判定格;`notify` 事件此字段为空 +- `summary` —— 一句话语义 + +这份定义是[RFC 0011 行动项 P0-2「生成式能力目录」](0011-plugin-extensibility-actions.md)的输入之一:事件表应由源码 AST 生成,`--check` 模式接入 CI,避免与实现漂移。DSH 的做法可参照(`scripts/gen-cordis-api.ts` + `verify-cordis-api`),但需注意其生成文档仍存在轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),生成 + 门禁能大幅降低漂移而非消除。 + +## 与 session log 的关系 + +统一事件流应同时喂三个消费者:插件、session log、UI 实时流(`apps/server/src/services/client-events.ts` 已有 `publishClientEvent` 基建)。 + +三者共用同一份事件定义,而非各自造一套。这是本次收敛的附带收益,也是纪律 6 落地的实际路径。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-migration.md b/.oo/rfcs/0012-hook-plugin-convergence-migration.md new file mode 100644 index 000000000..ab8abe72f --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-migration.md @@ -0,0 +1,141 @@ +# RFC 0012: 迁移与兼容 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +## 落地顺序 + +分五步,每步可独立评审与回滚。前两步无行为变更。 + +### 第 1 步:定事件词汇表(无行为变更) + +产出 `packages/types` 下的事件定义:名称、mode、availability、payload schema、判定格。仅新增类型与常量,不接线。 + +同时产出 `scripts/gen-plugin-api.ts` 的事件部分与 `--check` 门禁(RFC 0011 行动项 P0-2)。**先有生成器再有实现**,避免定义与实现从第一天就分叉。 + +### 第 2 步:`ctx.events` API(无行为变更) + +在 `PluginServerContext` 上实现 `define` / `on` / `dispatch` / `availability`,语义见[通用事件 API 设计](0012-hook-plugin-convergence-events-api.md)。此时尚无内置事件被 `define`,插件可用它做插件间通信。 + +### 第 3 步:上报器 + endpoint 解析 + +`oneworks-call-hook` 改为归一化上报;桌面走 workspace server,CLI 走自身。宿主 `define` 全部内置事件。 + +**此步开始行为变更**,需要: + +- 新旧双跑一段时间(旧链仍执行,新链只上报不生效),比对两侧判定结果 +- 回归用例覆盖 `builtin-permissions` 的等价性 + +### 第 4 步:权限收紧语义 + builtin 迁移 + +`tools/pre-execute` 切到 `decide`,宿主基线判定从 hook 插件形态改为运行时内置的同步判定。第三方插件的 `allow` 返回值失效(**必须报诊断,不能静默忽略** —— 纪律 4)。 + +### 第 5 步:旧入口下线 + +`/hooks` 导出保留一个 minor 版本做兼容(经垫片映射到新 API),随后下线。 + +## 现有 hook 插件的迁移 + +### 内置 + +`packages/hooks/src/builtin-permissions.ts` 是宿主自己的权限执行器。它不走"迁移"路径,而是**改写为运行时内置的同步基线判定**(见[运行时与裁决语义](0012-hook-plugin-convergence-runtime.md))。这是第 4 步的核心工作量。 + +### 第三方 / 一方插件 + +旧形态: + +```js +// /hooks +export default { + name: 'my-plugin', + async PreToolUse(ctx, input, next) { + if (isDangerous(input.toolName)) { + return { hookSpecificOutput: { permissionDecision: 'deny', ... } } + } + return next() + } +} +``` + +新形态: + +```js +// plugin.server.entry +export function activatePlugin(ctx) { + ctx.events.on('tools/pre-execute', (payload) => { + if (isDangerous(payload.toolName)) { + return { decision: 'deny', reason: '...' } + } + return undefined // 无意见 + }) +} +``` + +变化点: + +- 入口从 `/hooks` 移到 `plugin.server.entry`,与 server 插件同一形态 +- `decide` 不再有 `next()`,返回 `undefined` 表示无意见 +- ctx 从只有 `logger` 变成完整的 `PluginServerContext`(scope / options / pluginRoot / registerChannel / 常驻状态) +- **返回 `allow` 不再生效**,会得到一条诊断 + +### 兼容垫片 + +第 5 步之前,`/hooks` 导出由宿主的兼容层加载并映射到新 API。映射规则: + +| 旧 | 新 | +| -------------------------------- | ------------------------------------ | +| `PreToolUse` 返回 `deny` / `ask` | `tools/pre-execute` 的 `decide` 判定 | +| `PreToolUse` 返回 `allow` | 丢弃 + 诊断 | +| 其余 `transform` 类 | 同名新事件,`next()` 语义不变 | +| `continue: false` | 对应事件的终止语义 | + +垫片只保证**语义等价的子集**能跑,不保证全部。不能等价映射的必须报错而非静默降级。 + +## DSH 插件兼容垫片 + +目标形态:`@oneworks/plugin-dsh-compat`,让 DSH 的**纯监听型**插件在我们这里运行。 + +### 可行的部分 + +DSH 插件的典型形态: + +```ts +export const name = 'my-dsh-plugin' +export const inject = ['tools'] +export function apply(ctx: Context) { + ctx.on('tools/pre-execute', async (exec, next) => {/* ... */}) +} +``` + +垫片提供一个 shim `ctx`,把 `ctx.on(name, handler)` 转发到我们的 `ctx.events.on`。因为事件名对齐,大部分监听型插件的主体逻辑可以不改。 + +### 不可行的部分 + +必须在垫片文档里写清楚,避免"看起来能跑实际半残": + +| DSH 能力 | 垫片状态 | +| ------------------------------------------------------ | ----------------------------------------------- | +| `ctx.on('<对齐的事件名>')` | ✅ 可映射 | +| `ctx.logger` | ✅ 可映射 | +| `ctx.effect()` 生命周期 | ⚠️ 部分——映射到我们的 dispose,但无 fiber 状态机 | +| `inject` 服务依赖 | ⚠️ 仅当依赖的服务我们有对应物 | +| `ctx.llm` / `ctx.subagents` / `ctx.tools` 等注册型服务 | ❌ 我们没有注册型 seam(RFC 0011 P2) | +| `ctx.plugin()` 动态加载子插件 | ❌ 违反 RFC 0011 纪律 1,永不支持 | +| `tools/pre-execute` 返回 `allow` | ❌ 我们是 `decide` 单向收紧 | +| payload 形状(`ToolExecution` 等) | ⚠️ 需逐事件适配,非自动 | + +**垫片的价值判断**:它买到的是"概念可移植 + 迁移成本可控",不是 drop-in。是否值得实现取决于 DSH 生态里有多少纯监听型插件是我们想要的——这个应在实现前做一次抽样调查,而不是先建垫片再找用户。 + +### 反向:让我们的插件跑在 DSH + +不在本 RFC 范围。但事件名与 mode 对齐之后,反向垫片在理论上同样可行,可作为后续选项保留。 + +## 风险与回滚 + +| 风险 | 缓解 | +| -------------------------------------- | ------------------------------------------------------------------------------------- | +| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切 `notify` 类事件,`decide` 类留在旧链 | +| `builtin-permissions` 迁移后判定不等价 | 回归用例先行;双跑期比对两侧判定,不一致即阻断 | +| 现有插件生态被打断 | 兼容垫片保留一个 minor 版本;下线前在 `/plugins` 详情页对使用旧入口的插件显示迁移提示 | +| 事件定义与实现漂移 | 第 1 步先建生成器与 CI 门禁,早于实现 | + +每一步都可独立回滚:第 1、2 步无行为变更;第 3 步双跑期可关闭新链;第 4 步可退回 hook 形态的 builtin;第 5 步是纯删除。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md new file mode 100644 index 000000000..48191645f --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md @@ -0,0 +1,115 @@ +# RFC 0012: 运行时与裁决语义 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章定义上报器、runtime endpoint 解析、权限裁决与顺序契约。 + +## 上报器 + +`oneworks-call-hook` 从"插件执行宿主"降级为"归一化上报器"。职责三条,不多不少: + +1. 读上游 CLI 传入的 hook 输入 +2. 归一化为[事件词汇表](0012-hook-plugin-convergence-events.md)定义的事件 +3. 上报到 runtime endpoint;若该事件是 `transform` / `decide`,等待回执并按上游协议格式写回 + +**上报器内不加载任何插件代码。** 这条是硬约束——一旦上报器开始加载插件,两套系统就会重新分叉。 + +### 常驻 worker 的新定位 + +原设计中常驻 worker 解决的是"原生 hook 被反复调用时,插件上下文重复加载的性能问题"。新模型下插件常驻在 runtime 里,一次加载、跨事件持有状态,该约束被更彻底地解决。 + +常驻 worker 仍保留,但职责收窄为**省去上报器自身的 Node 冷启动**。判断保留与否的依据应是实测:上报器变薄后冷启动成本可能已低于维护 worker 池的复杂度。**这是实现期的实测决策,不在本 RFC 预先拍板。** + +## Runtime endpoint 解析 + +上报器不关心"谁在听",只往解析出的 endpoint 报。endpoint 取决于谁在驱动这次任务: + +| 运行形态 | endpoint | +| ------------------ | ---------------- | +| 桌面 / Web | workspace server | +| `npx oneworks ...` | **CLI 进程自身** | + +CLI 的 `run` 命令本来就在自己进程内驱动任务,已有 `apps/cli/src/commands/run/runtime-event-sink.ts`、`permission-decision.ts`、`input-bridge.ts` 等基建;`resolveServerBaseUrl` + `daemon` 选项的模式也已存在(`apps/cli/src/commands/plugin-cli.ts:185`、`channel.ts:682`)。 + +### 为什么不需要 daemon + +**只要有 agent 在跑,驱动它的进程必然活着** —— 否则没人消费 agent 的输出。因此不存在"没有 server"的场景: + +- 无需为 hook 拉起 daemon +- 无需设计降级路径 +- 插件在桌面与 CLI 两种模式下看到的 ctx 完全一致 + +这一条是整个方案的承重墙。若未来出现"任务驱动方可以先于任务结束而退出"的形态(例如 fire-and-forget 后台任务),必须重新论证本节,而不是给上报器加降级分支。 + +## 权限裁决 + +### 分层 + +``` +宿主基线判定(同步、本地、必答) + ↓ 作为 decide 事件的初始值 +插件判定(各自独立、只能收紧、可超时) + ↓ 按判定格取 meet +最终判定 +``` + +**宿主内置权限判定是地基**:读权限镜像文件(现由 `packages/hooks/src/builtin-permissions.ts` 实现),同步本地、不依赖插件、不会超时。 + +**插件只能收紧**:宿主 allow + 插件 deny = deny;宿主 deny + 插件 allow = **仍然 deny**。 + +### 超时语义 + +`decide` 事件的插件监听器超时 = **该插件这次没有意见**,按已有判定走。 + +- 不是 fail-open —— 宿主基线仍然生效 +- 不是 fail-closed —— 慢插件不会拖垮 agent + +超时**必须产生一条可见诊断**,不得静默。反复超时的插件应在 `/plugins` 详情页可见,让用户能定位是哪个插件在拖慢。 + +### 为什么这样切 + +`PreToolUse` 在热路径上——每次工具调用都要跑。若采用链式裁决 + 超时兜底,就必须在"慢插件让 agent 拒绝一切"和"慢插件让权限系统失效"之间二选一,两个都是不可接受的失败模式。 + +把插件限制为单向收紧之后,这个二选一消失了。代价是插件不能用于"放宽权限"——但那本来就不该是第三方插件的能力。 + +这与 `toolUsePresentations` 的 `origin` 设计同源:**能力做加法,权限做减法**。 + +## 顺序契约 + +现状是数组顺序 + builtin 排第一(`packages/hooks/src/runtime.ts:81-89`)。收敛后必须显式化: + +| mode | 顺序语义 | +| ----------- | ----------------------------------------------------- | +| `notify` | 顺序无关,监听器互相独立 | +| `transform` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `decide` | 顺序无关(判定合并可交换) | + +**宿主内置监听器占用保留的 priority 段,第三方插件无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证。 + +不采用 Cordis 的 `prepend` 布尔选项——它只能表达"最前",无法表达多个插件之间的相对顺序,且两个都传 `prepend` 时结果取决于注册顺序。 + +## 事件流的三个消费者 + +统一事件流同时喂: + +1. **插件** —— 经 `ctx.events.on` 订阅 +2. **session log** —— 落实 RFC 0011 纪律 6「model-visible ⟺ logged」 +3. **UI 实时流** —— `apps/server/src/services/client-events.ts` 的 `publishClientEvent` 已有 EventEmitter 基建 + +三者共用同一份事件定义。当前 hook 层对前端完全是黑盒,收敛后可在插件详情页与会话视图里看到实际发生了什么。 + +## 可见性与权限呈现 + +RFC 0011 行动项 P1 记录的问题在此一并解决: + +- 插件订阅的事件进 `/plugins` 详情页,与 contributions 并列展示 +- **订阅 `decide` 类事件应作为"该插件请求的权限"显式呈现给用户**,因为那意味着它能否决工具调用 +- 事件订阅进[生成式能力目录](0011-plugin-extensibility-actions.md)(P0-2) + +## 待实测确认 + +以下项目需在实现期用实测数据决定,本 RFC 不预设结论: + +1. **上报器变薄后是否仍需常驻 worker** —— 对比冷启动成本与 worker 池维护复杂度 +2. **`decide` 事件的超时预算** —— 需要 `tools/pre-execute` 端到端延迟基线;当前无实测数据 +3. **`builtin-permissions` 迁移后的等价性** —— 需要一组回归用例证明新旧判定结果一致 diff --git a/.oo/rfcs/0012-hook-plugin-convergence.md b/.oo/rfcs/0012-hook-plugin-convergence.md new file mode 100644 index 000000000..870e77fc4 --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence.md @@ -0,0 +1,76 @@ +# RFC 0012: Hook 与插件系统收敛 + +返回入口:[RFC 索引](../../rfc.md) + +Status: 设计草案,待评审\ +前置: [RFC 0011 插件扩展面盘点与边界](0011-plugin-extensibility.md)\ +对照上游: `deepseek-ai/deepseek-harness@99f6f02`\ +Reviewed: 2026-08-18 + +## 问题 + +`@oneworks/hooks` 与插件运行时目前是两套几乎零交集的系统。它们共用同一棵插件实例树(都经 `resolveConfiguredPluginInstances`),除此之外没有任何共享。 + +具体差距: + +1. **两个 ctx 不对等。** `HookContext` 只有 `{ logger }` 一个字段(`packages/hooks/src/context.ts:7-9`);`PluginServerContext` 有 scope / pluginRoot / workspaceFolder / projectHome / options / sessions / registerCommand / registerApi / registerLocalService / dispose / runtime.registerChannel。hook 侧能经工厂形态 `(config) => Partial` 拿到 options(`loader.ts:39-41`),但 scope、pluginRoot 与自己 server 端注册的一切都拿不到。 +2. **同一个插件包要写两套形状的代码。** hook 是 `/hooks` 导出 `Partial`;server 是 `plugin.server.entry` 导出 `activatePlugin(ctx)`。 +3. **没有跨侧通道。** `packages/hooks/src/` 全部源码中没有 `serverBaseUrl` / `runtimeEndpoint` 等任何指向宿主的东西。 +4. **生命周期语义不一致。** server 插件有 activate / dispose / watch reload;hook 侧契约上是无状态的每事件调用。 +5. **可见性不一致。** server / client 的 contributions 在 `/plugins` 详情页可见;运行时 hook 插件在 `plugin-entry-cache.ts:43-53` 无条件收进链,而它握有 `PreToolUse` 否决权。 + +## 为什么不能直接照抄 DSH + +DSH 没有独立的 hook 子系统——它的 `docs/cookbook/extension-cookbook.md` 把"Hook 系统"直接映射到监听 `agent/session-start`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping` 这些 ctx 事件。所谓 hook 插件(`hooks-claude-code` / `hooks-codex`)只是读外部 hook 配置文件、桥接到这条内部总线上的普通插件。 + +**它能这么做是因为它自己就是 agent,hook 点在它自己的 loop 里,是进程内事件。** 我们是驱动 16 个外部 CLI 的宿主,`native` 源的 hook 点在那些 CLI 的进程里,由它们 spawn 我们的 `oneworks-call-hook`。这个进程位置不由我们决定。 + +可迁移的是**"一个插件只有一种心智模型"**这个结果,不是"进程内事件"这个实现。 + +## 方案 + +**hook 子进程降级为上报器,不再承载任何插件代码;插件在"驱动这次任务的那个进程"里消费统一事件流。** + +``` +适配器 CLI ──spawn──> oneworks 上报器(薄) + │ 归一化 + 上报(裁决型再等回执) + ▼ + 驱动这次任务的进程内的插件运行时 + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ + 插件消费 session log UI 实时流 +``` + +三点结论: + +**1. 不需要 daemon。** 上报器只往环境里给的 runtime endpoint 报,谁是那个 endpoint 取决于谁在驱动任务:桌面/Web 是 workspace server,`npx oneworks ...` 是 CLI 进程自己(`apps/cli/src/commands/run/` 已有 `runtime-event-sink.ts` / `permission-decision.ts`,本来就在进程内跑任务并处理权限决策)。 + +**只要有 agent 在跑,驱动它的进程必然活着**——否则没人消费 agent 的输出。因此不存在"没有 server"的场景,无需为此拉 daemon,也无需降级路径。插件在两种模式下看到的 ctx 完全一致。 + +**2. 常驻 worker 的职责收窄。** 原设计中常驻 worker 承担的是"原生 hook 反复调用时避免重复加载插件上下文"。新模型下上报器不跑插件代码,插件常驻在 runtime 里一次加载、跨事件持有状态——这个约束被更彻底地解决了。常驻 worker 仍可保留以省去上报器自身的 Node 冷启动,但职责从"承载执行上下文"降为"省一次进程启动"。 + +**3. 第三方插件对权限只有否决权。** 宿主内置权限判定是同步本地的(读权限镜像文件),不依赖插件、不会超时,是地基;插件只能在其上收紧。宿主 allow + 插件 deny = deny;宿主 deny + 插件 allow = **仍然 deny**。 + +于是"插件超时"= 该插件这次没有意见 = 按宿主判定走。既不是 fail-open(地基仍在),也不是 fail-closed(慢插件不会拖垮 agent)。这与 `toolUsePresentations` 的 `origin` 设计同源——**能力做加法,权限做减法**。 + +## 命名与迁移 + +采用 DSH 的 `namespace/kebab` 事件命名与 `emit | waterfall | serial | parallel` mode 词汇。理由有二:该约定本身更好(带命名空间、可扩展、不撞名),且顺带买到迁移友好。 + +**但要写清楚它买到的是什么。** DSH 插件是 Cordis 插件(`apply(ctx)` + `ctx.on(...)`),ctx 是完全不同的对象,payload 形状也不同。命名对齐买到的是"概念可移植 + 机械适配层可行",不是 drop-in。 + +目标形态:同时对齐**事件名 + mode 词汇 + 重叠事件的 payload 形状**,使 `@oneworks/plugin-dsh-compat` 垫片对**纯监听型插件**可行。详见[迁移与兼容](0012-hook-plugin-convergence-migration.md)。 + +## 章节 + +- [通用事件 API 设计](0012-hook-plugin-convergence-events-api.md) —— `ctx.events` 的三个 mode 与派发语义 +- [事件词汇表](0012-hook-plugin-convergence-events.md) —— 名称、mode、payload、按 source 的可用性分级 +- [运行时与裁决语义](0012-hook-plugin-convergence-runtime.md) —— 上报器、endpoint 解析、权限收紧、顺序契约 +- [迁移与兼容](0012-hook-plugin-convergence-migration.md) —— 落地顺序、旧入口下线、DSH 垫片 + +## 非目标 + +- 不开放注册型 seam(model provider / adapter provider)。那属于 RFC 0011 行动项的 P2,需要产品决策,与本 RFC 无关。 +- 不改变插件的分发、安装与卸载模型。 +- 不引入 Cordis 或任何 IoC 容器。本 RFC 只收敛事件面,不动插件装配模型。 diff --git a/rfc.md b/rfc.md index ca7b27251..8dd0a9cc4 100644 --- a/rfc.md +++ b/rfc.md @@ -25,6 +25,14 @@ - [边界与设计纪律](.oo/rfcs/0011-plugin-extensibility-boundaries.md) - [行动项与优先级](.oo/rfcs/0011-plugin-extensibility-actions.md) +## Hook 与插件系统收敛 RFC + +- [总览](.oo/rfcs/0012-hook-plugin-convergence.md) +- [通用事件 API 设计](.oo/rfcs/0012-hook-plugin-convergence-events-api.md) +- [事件词汇表](.oo/rfcs/0012-hook-plugin-convergence-events.md) +- [运行时与裁决语义](.oo/rfcs/0012-hook-plugin-convergence-runtime.md) +- [迁移与兼容](.oo/rfcs/0012-hook-plugin-convergence-migration.md) + ## 插件运行时 RFC - [目录结构、manifest 与共享契约](.oo/rfcs/plugin-runtime-layout-manifest.md) From ea1b8dc05e66435ce1ef13d69640142dccaf0f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:46:28 +0000 Subject: [PATCH 3/8] docs(rfc): keep all five Cordis modes, constrain at define time Revise RFC 0012's event API: instead of narrowing Cordis's five dispatch modes down to three, keep all of them under their original names and move the restrictions onto the event definition, where they can be checked mechanically at define time. Three earlier exclusions were wrong: - `emit` was dropped by over-generalising "hook events cross a process boundary" to "all events do". Plugin-to-plugin events live in one runtime, where synchronous dispatch is both valid and preferable. - `parallel` and `serial` were folded together on the premise that awaiting is the dispatcher's choice. That conflated two things: the real distinction is whether listeners can observe each other's side effects, which is a property of the event, not the call site. - `bail` was banned for a real hazard applied too broadly. First-responder resolution is legitimate; only permission adjudication is unsafe, and that already has `decide`. Constraints now: `emit` cannot be cross-process, and `security: true` events accept only `decide`. Keeping Cordis's names also restores full mode parity with DSH apart from `tools/pre-execute`, which is `security: true` and therefore `decide` on our side. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- ...0012-hook-plugin-convergence-events-api.md | 107 ++++++++---------- .../0012-hook-plugin-convergence-events.md | 55 +++++---- .../0012-hook-plugin-convergence-migration.md | 4 +- .../0012-hook-plugin-convergence-runtime.md | 9 +- 4 files changed, 83 insertions(+), 92 deletions(-) diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md index 5c03bda74..20adedfbc 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -10,59 +10,60 @@ RFC 0011 纪律 1 的配套约定是"不新增第三种跨插件通信原语"。 | 现状 | 收敛后 | | ------------------------------------------------------- | ------------------------------------------------------ | -| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `transform` / `decide` mode | -| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `notify` mode | +| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `waterfall` / `decide` | +| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `emit` / `parallel` / `serial` | | `pluginApis.register/call` | **保留不动** —— 它是 1:1 有返回值的 RPC,不是事件 | | `extensionPoints.register/contribute/getContributions` | **保留不动** —— 它是结构化贡献 registry,不是 dispatch | 净效果是原语数量不变:hook 那套私有链被通用事件取代,`pluginApis` 与 `extensionPoints` 各司其职。 -## Cordis 的五个 mode,我们取三个 +## 六个 mode:Cordis 五个 + `decide` -Cordis `EventsService` 提供 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`)。逐条评估: +全部保留 Cordis 的 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`),命名不改,另加一个我们特有的 `decide`。 -| Cordis mode | 我们的结论 | -| ------------------------------ | ------------------------------------------------------------------------------------------ | -| `emit`(同步 fire-and-forget) | **不可用**。Cordis 单进程内同步派发;我们跨 client / server / 上报器三处,所有派发必须异步 | -| `parallel`(await 全部) | **并入 `notify`**。"派发方要不要等"是调用点的事,不该是事件定义的属性 | -| `serial`(顺序,无 `next()`) | **并入 `notify`**,用 dispatch 选项控制 fail-fast | -| `waterfall`(链式改写) | **保留**,改名 `transform` | -| `bail`(首个非空返回胜出) | **不采用**,用 `decide` 取代。理由见下 | +不砍 mode,改为**在事件定义上加约束**——约束可在 `define` 时机械校验,比削减词汇表更精确,也保住了与 DSH 的命名对齐。 -### 为什么不要 `bail` +| mode | 语义 | 监听器契约 | +| ----------- | ----------------------------------- | ------------------------------- | +| `emit` | 同步 fire-and-forget | `(payload) => void` | +| `parallel` | 并发启动,await 全部 | `(payload) => Promise` | +| `serial` | 顺序执行,后者可观察前者副作用 | `(payload) => Promise` | +| `bail` | 首个返回非 `undefined` 者胜出并短路 | `(payload) => R \| undefined` | +| `waterfall` | 链式改写,必须调 `next()` | `(payload, next) => Promise

` | +| `decide` | 单向收紧合并,顺序无关 | `(payload) => D \| undefined` | -`bail` 的语义是"第一个返回非空值的监听器胜出并短路"。在可信插件场景(Cordis / DSH 的前提是插件等同 shell 权限)这没问题;在 marketplace 分发场景下它是提权通道——**任何第三方插件都能抢占一个宿主关心的决策**,且抢占是静默的。 +`parallel` 与 `serial` 的区别不是"派发方等不等",而是**监听器之间能否观察到彼此的副作用**:serial 中第二个监听器跑在第一个完成之后,parallel 中两者交错。这是事件的语义属性,因此保留为独立 mode。 -替代方案 `decide` 把"只能收紧"编码进 dispatch 语义本身,而不是指望每个事件的实现自觉。 +## 约束表 -## 三个 mode - -### `notify` —— 通知 - -```ts -type NotifyListener

= (payload: P) => void | Promise +``` +availability: 'in-process' 仅同一 runtime 内派发 +availability: 'cross-process' 需经上报器跨进程(全部 hook 内置事件) +security: true 该事件的结果影响权限或安全边界 ``` -监听器互相独立,任一失败不影响同侪,不影响派发结果。派发方通过选项决定是否等待全部完成。 +| mode | `cross-process` | `security: true` | +| ----------- | --------------- | ---------------- | +| `emit` | ❌ 拒绝 | ❌ 拒绝 | +| `parallel` | ✅ | ❌ 拒绝 | +| `serial` | ✅ | ❌ 拒绝 | +| `bail` | ✅ | ❌ **拒绝** | +| `waterfall` | ✅ | ❌ 拒绝 | +| `decide` | ✅ | ✅ **唯一合法** | -用于:状态变更广播、审计、遥测、UI 更新。 +`define` 时校验,违反即失败(fail loud,纪律 4): -### `transform` —— 链式改写 +- `emit` + `cross-process` → 跨进程无法同步派发 +- 非 `decide` + `security: true` → 权限类事件只能用 `decide` +- `decide` 缺判定格 → 无法合并 -```ts -type TransformListener

= ( - payload: P, - next: (payload: P) => Promise

-) => Promise

-``` - -顺序执行,**必须调 `next()`**,不调即短路整条链。每个监听器可在 `await next()` 前后改写 payload。 +### 为什么权限类禁用 `bail` -这就是现有 hook chain 的语义,等价于 Cordis 的 `waterfall`。 +`bail` 是"首个返回非 `undefined` 者胜出并短路"。用于 resolver 类场景(谁能处理这个 URL、谁能解析这个文件类型)完全正当,且 `decide` 表达不了这种"首个响应者"语义。 -用于:system prompt 组装、上下文注入、请求改写。 +但用于权限裁决时它是提权通道:任何第三方插件都能抢占宿主关心的决策,且抢占静默。这是**用途问题不是 mode 问题**,所以约束打在 `security: true` 这个维度上,而不是砍掉 `bail`。 -### `decide` —— 单向收紧的裁决 +## `decide` —— 我们相对 Cordis 新增的一个 ```ts type DecideListener = ( @@ -76,7 +77,7 @@ type DecideListener = ( - **合并结果不可能比宿主基线更宽松。** 宿主判定是格的上界,插件只能向下拉。 - **超时 = 无意见。** 慢插件不会拖垮 agent,也不会静默放宽(基线仍在)。 -- **顺序无关。** 合并是可交换的,因此不存在"谁先注册谁赢"的隐式依赖。 +- **顺序无关。** 合并可交换,不存在"谁先注册谁赢"的隐式依赖。 事件定义必须声明判定格。以工具权限为例: @@ -84,11 +85,11 @@ type DecideListener = ( allow ⊐ ask ⊐ deny ``` -宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`(`allow` 在格中不低于 `deny`,取 meet 后不变)。 +宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`。 用于:权限裁决、内容策略、合规拦截。 -**`decide` 是本设计相对 Cordis 的主要改进**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。 +**这是本设计相对 Cordis 的唯一新增**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。DSH 把 `tools/pre-execute` 标为 `waterfall`(其插件等同 shell 权限,无妨),我们标为 `decide`——这是与 DSH 唯一的 mode 分歧。 ## API 形状 @@ -98,7 +99,7 @@ mode 声明在**事件定义**上,不在派发调用点。理由:定义方 // 定义(宿主或插件,事件 id 为 /) ctx.events.define({ name: 'before-save', - mode: 'transform', + mode: 'waterfall', payload: payloadSchema, result: resultSchema, summary: '保存前改写文档内容' @@ -122,20 +123,20 @@ ctx.events.availability('agent/request') // 'both' | 'bridge' | 'native:` +- `mode` —— `emit | parallel | serial | bail | waterfall | decide` +- `availability` —— 传输可达性(`in-process | cross-process`)与 source 分级(`both | bridge | native:`) +- `security` —— 该事件结果是否影响权限或安全边界 - `payload` —— 结构化 schema -- `result` —— `transform` 的返回契约 / `decide` 的判定格;`notify` 事件此字段为空 +- `result` —— `waterfall` / `bail` 的返回契约、`decide` 的判定格;`emit` / `parallel` / `serial` 此字段为空 - `summary` —— 一句话语义 这份定义是[RFC 0011 行动项 P0-2「生成式能力目录」](0011-plugin-extensibility-actions.md)的输入之一:事件表应由源码 AST 生成,`--check` 模式接入 CI,避免与实现漂移。DSH 的做法可参照(`scripts/gen-cordis-api.ts` + `verify-cordis-api`),但需注意其生成文档仍存在轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),生成 + 门禁能大幅降低漂移而非消除。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-migration.md b/.oo/rfcs/0012-hook-plugin-convergence-migration.md index ab8abe72f..98a095f41 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-migration.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-migration.md @@ -85,7 +85,7 @@ export function activatePlugin(ctx) { | -------------------------------- | ------------------------------------ | | `PreToolUse` 返回 `deny` / `ask` | `tools/pre-execute` 的 `decide` 判定 | | `PreToolUse` 返回 `allow` | 丢弃 + 诊断 | -| 其余 `transform` 类 | 同名新事件,`next()` 语义不变 | +| 其余 `waterfall` 类 | 同名新事件,`next()` 语义不变 | | `continue: false` | 对应事件的终止语义 | 垫片只保证**语义等价的子集**能跑,不保证全部。不能等价映射的必须报错而非静默降级。 @@ -133,7 +133,7 @@ export function apply(ctx: Context) { | 风险 | 缓解 | | -------------------------------------- | ------------------------------------------------------------------------------------- | -| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切 `notify` 类事件,`decide` 类留在旧链 | +| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切无返回契约的事件,`decide` 类留在旧链 | | `builtin-permissions` 迁移后判定不等价 | 回归用例先行;双跑期比对两侧判定,不一致即阻断 | | 现有插件生态被打断 | 兼容垫片保留一个 minor 版本;下线前在 `/plugins` 详情页对使用旧入口的插件显示迁移提示 | | 事件定义与实现漂移 | 第 1 步先建生成器与 CI 门禁,早于实现 | diff --git a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md index 48191645f..2c28e31a7 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md @@ -10,7 +10,7 @@ 1. 读上游 CLI 传入的 hook 输入 2. 归一化为[事件词汇表](0012-hook-plugin-convergence-events.md)定义的事件 -3. 上报到 runtime endpoint;若该事件是 `transform` / `decide`,等待回执并按上游协议格式写回 +3. 上报到 runtime endpoint;若该事件有返回契约(`waterfall` / `bail` / `decide`),等待回执并按上游协议格式写回 **上报器内不加载任何插件代码。** 这条是硬约束——一旦上报器开始加载插件,两套系统就会重新分叉。 @@ -80,8 +80,11 @@ CLI 的 `run` 命令本来就在自己进程内驱动任务,已有 `apps/cli/s | mode | 顺序语义 | | ----------- | ----------------------------------------------------- | -| `notify` | 顺序无关,监听器互相独立 | -| `transform` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `emit` | 同步派发,按 priority 升序 | +| `parallel` | 并发启动,顺序无关 | +| `serial` | 按 priority 升序,后者可观察前者副作用 | +| `bail` | 按 priority 升序,首个非 `undefined` 者短路 | +| `waterfall` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | | `decide` | 顺序无关(判定合并可交换) | **宿主内置监听器占用保留的 priority 段,第三方插件无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证。 From 372570e2abff9f3d29da11941a89c5697d141db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 13:58:42 +0000 Subject: [PATCH 4/8] docs(rfc): correct the rationale for emit being in-process only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constraint stands but the stated reason was wrong. "Cross-process cannot dispatch synchronously" is inaccurate: Atomics.wait() over a SharedArrayBuffer with receiveMessageOnPort() gives real synchronous blocking between worker_threads, and Node permits Atomics.wait on the main thread. Only true separate processes are impractical, since a SharedArrayBuffer cannot cross an IPC pipe. The actual reason is semantic redundancy. What `emit` buys — no microtask scheduling, listener on the dispatcher's stack so exceptions propagate directly, deterministic uninterleaved order — is entirely lost once a process boundary is crossed: the IPC round trip dwarfs scheduling cost, the stack is already severed and errors must be serialised, and ordering comes from serial transport rather than synchronous semantics. A blocking notification is precisely `serial`, which already exists. This rationale is transport-independent, so it survives a future move of the hook worker from child_process to worker_threads. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rfcs/0012-hook-plugin-convergence-events-api.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md index 20adedfbc..6e8a625f1 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -53,10 +53,20 @@ security: true 该事件的结果影响权限或安全边界 `define` 时校验,违反即失败(fail loud,纪律 4): -- `emit` + `cross-process` → 跨进程无法同步派发 +- `emit` + `cross-process` → 语义冗余,见下 - 非 `decide` + `security: true` → 权限类事件只能用 `decide` - `decide` 缺判定格 → 无法合并 +### 为什么 `emit` 不可跨进程 + +**不是因为技术上做不到。** 同进程的 `worker_threads` 之间可以用 `Atomics.wait()` + `SharedArrayBuffer` + `receiveMessageOnPort()` 实现真同步阻塞(`synckit` 即此模式;Node 允许主线程 `Atomics.wait`,浏览器禁止)。真跨进程(`child_process`)则基本不可行——`SharedArrayBuffer` 需要共享内存映射,传不过 IPC 管道;剩下 `execSync` 每事件起进程、阻塞 fd 读不可移植,都不该用。 + +**真正的理由是语义冗余。** `emit` 的价值有三条:无 microtask 调度、派发点与监听器同栈使异常直接传播、顺序确定无交错。跨进程后三条全部失效——已有 IPC 往返使调度开销可忽略,栈本来就断了且异常必须序列化,顺序确定来自串行传输而非同步语义。 + +因此跨进程的"同步 `emit`"实质是一个**阻塞的通知**,而那已经有名字了:`serial`。保留两个名字只会让插件作者在等价选项间做无意义的选择。 + +该论证不依赖传输方式:即便未来把 hook worker 从 `child_process` 换成 `worker_threads`(为省冷启动,见[运行时与裁决语义](0012-hook-plugin-convergence-runtime.md)的待实测项),`Atomics.wait` 可用了,结论仍不变。 + ### 为什么权限类禁用 `bail` `bail` 是"首个返回非 `undefined` 者胜出并短路"。用于 resolver 类场景(谁能处理这个 URL、谁能解析这个文件类型)完全正当,且 `decide` 表达不了这种"首个响应者"语义。 From e06a6b2253ef59ba24d0010342e94f166e894515 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:44:02 +0000 Subject: [PATCH 5/8] docs(rules): start the plugin system spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin system had no entry under .oo/rules/ — its normative content was scattered across three RFC series, a hand-written usage doc, and the code. Establish .oo/rules/PLUGIN-SYSTEM.md plus a plugin-system/ directory following the same shape as HOOKS.md, so rules carry the specification and RFCs stay as the record of why each decision was made. This commit lands the entry, the overview, and the communication chapter. The overview doubles as a gap map: every capability is tagged implemented, in-design, or missing, so nobody writes plugins against a design. Four inconsistencies surfaced while writing it, all recorded in the overview: - executeCommand accepts a scoped / and will HTTP to another plugin's server command, while the usage doc describes only the same-scope path - client can reach across plugins but server cannot: invokeChannel binds the scope host-side, so a server plugin only ever calls its own channel - RFC 0012's three-level availability grading is coarser than reality — hooks/events.md already carries a per-adapter, per-event matrix with a canBlock dimension, and the event spec should defer to it - same-scope command id collisions remain unverified Remaining chapters are listed as pending rather than linked, so the index has no dead links. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rules/PLUGIN-SYSTEM.md | 33 ++++++++ .oo/rules/plugin-system/README.md | 77 +++++++++++++++++++ .oo/rules/plugin-system/communication.md | 97 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 .oo/rules/PLUGIN-SYSTEM.md create mode 100644 .oo/rules/plugin-system/README.md create mode 100644 .oo/rules/plugin-system/communication.md diff --git a/.oo/rules/PLUGIN-SYSTEM.md b/.oo/rules/PLUGIN-SYSTEM.md new file mode 100644 index 000000000..93b81ec72 --- /dev/null +++ b/.oo/rules/PLUGIN-SYSTEM.md @@ -0,0 +1,33 @@ +--- +alwaysApply: false +description: 当任务涉及插件系统的 manifest、解析装配、生命周期、ctx 能力面、插件间通信、UI 贡献、事件、信任边界或分发时加载的插件系统入口。 +--- + +# 插件系统入口 + +详细规范已下沉到 [`plugin-system/README.md`](./plugin-system/README.md)。 + +先按任务继续阅读: + +- 总览与状态图:[`plugin-system/README.md`](./plugin-system/README.md) +- 插件包与 manifest:`plugin-system/manifest.md`(待写) +- 解析与装配:`plugin-system/resolution.md`(待写) +- 生命周期:`plugin-system/lifecycle.md`(待写) +- ctx 能力面:`plugin-system/context.md`(待写) +- 插件间通信:[`plugin-system/communication.md`](./plugin-system/communication.md) +- UI 贡献:`plugin-system/contributions.md`(待写) +- 事件系统:`plugin-system/events.md`(待写) +- 信任与安全边界:`plugin-system/trust.md`(待写) +- 分发与可见性:`plugin-system/distribution.md`(待写) + +相邻主题: + +- hook 事件在各 adapter 的支持矩阵与 `canBlock` 语义:[`hooks/events.md`](./hooks/events.md) +- adapter 的 mock home、原生 skills、MCP:[`ADAPTERS.md`](./ADAPTERS.md) +- 配置加载与合并:[`CONFIG.md`](./CONFIG.md) + +设计论证与决策依据不在本规范内,见 RFC: + +- [RFC 0011 插件扩展面盘点与边界](../../rfc.md)(含七条设计纪律) +- [RFC 0012 Hook 与插件系统收敛](../../rfc.md) +- Plugin Runtime RFC(目录结构、server、client UI、落地计划) diff --git a/.oo/rules/plugin-system/README.md b/.oo/rules/plugin-system/README.md new file mode 100644 index 000000000..b6b12a9b8 --- /dev/null +++ b/.oo/rules/plugin-system/README.md @@ -0,0 +1,77 @@ +--- +alwaysApply: false +description: 插件系统规范总览:核心模型、分章导航与各能力的实现状态。 +--- + +# 插件系统规范 + +本目录是插件系统的**规范**(这么定的),设计论证见 RFC 0011 / 0012(为什么这么定)。 + +## 核心模型 + +One Works 是**有特权宿主 + 只能做加法的扩展**,不是"一切皆插件"。 + +- 宿主拥有:discovery、runtime、marketplace、HMR、权限判定、进程与生命周期 +- 插件只能:往宿主开放的点位上挂东西、向其他插件贡献或调用 +- 插件**不能**:创造插件、注册顶层路由、直接访问文件系统(前端)、放宽权限 + +四条不可协商的边界: + +1. 插件不能实例化其他插件。动态插件图由宿主经 overlay 注入,发生在配置解析层。 +2. 插件只能收紧权限,不能放宽。 +3. 能力不支持时必须 fail loud,禁止 accepted-then-ignored。 +4. `scope` 是逻辑隔离(防命名冲突),**不是安全边界**。真正的边界见 `trust.md`(待写)。 + +## 分章 + +| 章节 | 覆盖 | +| ---------------------------------------- | ------------------------------------------------- | +| `manifest.md`(待写) | 插件包结构、manifest 字段、入口导出约定 | +| `resolution.md`(待写) | 发现来源、children、scope 分配、overlay、冲突处理 | +| `lifecycle.md`(待写) | activate / dispose / reload / watch、失败态 | +| `context.md`(待写) | client / server / view 三端 ctx 的完整能力面 | +| [`communication.md`](./communication.md) | 插件间通信的三条通道及其边界 | +| `contributions.md`(待写) | slots、views、routes、themes、声明式渲染 | +| `events.md`(待写) | 事件 mode、约束、可用性分级 | +| `trust.md`(待写) | 信任模型、安全边界、什么不是保证 | +| `distribution.md`(待写) | marketplace、安装卸载、版本、可见性与诊断 | + +## 实现状态 + +规范同时标注**当前实现状态**。未实现的内容以 `设计中` / `缺失` 标注,不得按"已有"编写插件或引用。 + +| 能力 | 状态 | 位置 | +| ---------------------------------------------------- | ------------------ | -------------------------------------------------- | +| manifest / 包结构 / 双入口 | ✅ 已实现 | `packages/types/src/plugin.ts` | +| 解析、children、scope、环检测 | ✅ 已实现 | `packages/utils/src/plugin-resolver.ts` | +| 任务级 overlay | ✅ 已实现 | `PluginOverlayConfig` + `overlaySource` | +| client 生命周期 / watch reload | ✅ 已实现 | `apps/client/src/plugins/PluginProvider.tsx` | +| extension points(含等待语义、epoch) | ✅ 已实现 | `apps/client/src/plugins/plugin-registry.ts` | +| plugin APIs(含挂起队列、timeout) | ✅ 已实现 | 同上 | +| 跨 scope command 调用 | ⚠️ 已实现但未文档化 | `plugin-registry.ts:405` | +| UI slots / views / routes / themes | ✅ 已实现 | `plugin-runtime.ts` | +| 声明式渲染(`toolUsePresentations`) | ✅ 已实现 | `apps/client/src/plugins/plugin-tool-use.ts` | +| hook 事件(14 个,跨 adapter 统一) | ✅ 已实现 | `packages/hooks/` | +| marketplace 安装 / 卸载 / 账本 | ✅ 已实现 | `apps/server/src/services/plugins/marketplace*.ts` | +| **`ctx.events` 通用事件 API** | 🚧 设计中 | RFC 0012 | +| **server 侧跨插件通信** | ❌ 缺失 | 见 [`communication.md`](./communication.md) | +| **注册型 seam(model provider / adapter provider)** | ❌ 缺失 | RFC 0011 行动项 P2 | +| **视图槽(组件级贡献)** | ❌ 缺失,且不优先 | RFC 0011 纪律 2 | +| **ErrorBoundary** | ❌ 缺失 | RFC 0011 行动项 P0-3 | +| **生成式能力目录** | ❌ 缺失 | RFC 0011 行动项 P0-2 | + +## 已知不一致 + +规范化过程中发现、尚未消解的实现与文档分歧。每条都应有对应 issue 或 RFC 待办: + +1. **跨 scope command 调用未文档化。** `executeCommand` 接受 `/` 形式并会 HTTP 打到目标 scope;`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。需确认是正式通道还是实现顺带,然后补文档或加限制。 +2. **client 能跨插件,server 不能。** `invokeChannel` 的 scope 由宿主绑死(`runtime.ts:3519`),server 插件只能调自己的 channel。这与 RFC 0012 把插件代码收回常驻 runtime 的方向存在张力。 +3. **可用性分级粒度不足。** RFC 0012 用 `both | bridge | native:` 三级,而 [`hooks/events.md`](../hooks/events.md) 的真实矩阵是逐 adapter 逐事件,且带 `canBlock` 维度。事件规范应以该矩阵为准。 +4. **同 scope 内 command id 撞名的处理未核实。** `runtime.ts:2802` 的冲突检查针对内置 route key,同 scope 内的撞名路径待查。 + +## 编写规范时的约定 + +- 每章标注状态,不把设计当现状 +- 每条限制写清楚**为什么**,或链到 RFC 的对应论证 +- 矩阵类内容以代码或已有 rules 文档为准,不凭记忆写 +- 与 [`hooks/events.md`](../hooks/events.md) 等既有 rules 重叠时,引用而非复制 diff --git a/.oo/rules/plugin-system/communication.md b/.oo/rules/plugin-system/communication.md new file mode 100644 index 000000000..58622fd9f --- /dev/null +++ b/.oo/rules/plugin-system/communication.md @@ -0,0 +1,97 @@ +--- +alwaysApply: false +description: 插件间通信的三条通道、边界判据与当前缺口。 +--- + +# 插件间通信 + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +## 通道总表 + +| 通道 | 方向 | 跨 scope | 有返回值 | 端 | 状态 | +| ------------------------- | --------- | -------- | -------- | --------------- | ---------- | +| extension points | 1→N 登记 | ✅ | ❌ | client | ✅ | +| plugin APIs | 1→1 调用 | ✅ | ✅ | client | ✅ | +| commands | 1→1 调用 | ✅ | ✅ | client → server | ⚠️ 未文档化 | +| contribution 的 `command` | 宿主中介 | ✅ | —— | client | ✅ | +| runtime channel | 自己→自己 | ❌ | ✅ | server | ✅ | +| `ctx.events` | 1→N 派发 | ✅ | 视 mode | 两端 | 🚧 设计中 | + +## 选哪条 + +| 意图 | 用 | +| ------------------------------------------------ | ---------------------- | +| 让别人往我这里**登记结构化贡献**,我自己读取渲染 | extension points | +| **调用**某个特定插件拿返回值 | plugin APIs | +| **通知**多方 / 让多方**改写**或**收紧** | `ctx.events`(设计中) | + +一句话:extension points 是贡献登记,plugin APIs 是一对一调用,events 是一对多派发。 + +## Extension points + +```js +ctx.extensionPoints.register({ id, title, contributionSchema }) +ctx.extensionPoints.onAvailable('/', point => {/* 返回 cleanup */}) +ctx.extensionPoints.contribute('/', contribution) +view.extensions.getContributions('') // owner 在 React view 内读回 +``` + +规则: + +- 目标 id 单段视为当前 scope,两段为 `/`;超过两段报诊断。 +- **注册贡献必须用 `onAvailable`,不得用 `has()` 做一次性判断**——激活顺序不保证。目标已存在时 `onAvailable` 立即触发,不存在则挂起等待。 +- `onAvailable` 回调返回的 cleanup 在目标扩展点卸载时由宿主执行。 +- manifest 的静态 `extensionContributions` 同样经 `onAvailable` 装配,因此声明式贡献也不怕顺序。 +- owner 拿到的是**数据记录**,不是可执行对象。要触发行为须经 contribution 携带的 `command`。 + +## Plugin APIs + +```js +ctx.pluginApis.register({ id, title, inputSchema, outputSchema, handler }) +await ctx.pluginApis.call('/', input, { timeoutMs }) +``` + +规则: + +- handler 的 `meta` 携带 `callerScope` / `targetScope` / `apiId`,提供方据此决定是否服务。 +- 目标未注册时 `call` **挂起等待**而非报错,注册时排空;支持 `timeoutMs` 与 `AbortSignal`。 +- 调用方插件卸载时,其挂起的调用被 reject。 +- 重复注册同一 id 报 duplicate 诊断,后者不生效。 + +## Commands(跨 scope 部分未文档化) + +`ctx.commands.execute(commandId)` 的 scope 由宿主绑定为当前插件,但 `commandId` 接受 `/` 形式: + +```js +const key = commandId.includes('/') ? commandId : scopedKey(scope, commandId) +// 本地 registry 未命中 → HTTP POST /api/plugins//commands/ +``` + +即**传入带 scope 的 id 即可调用其他插件的命令**。`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。 + +**在澄清前,新插件不应依赖这条路径**——它可能被收紧为同 scope 限定。跨插件调用请用 plugin APIs(有 schema、有超时、有 caller 身份)。 + +## Server 侧:当前没有跨插件通道 + +``` +runtime.ts:3519 + invokeChannel: (channelId, invocation) => + this.invokeRuntimeChannel(scope, channelId, invocation) +``` + +scope 由宿主绑死,插件传不进去,因此 server 插件**只能调用自己的 channel**。`registerApi` 的 proxy 同样锁在 `/api/plugins/:scope/*` 下。 + +这是一处真实缺口:**client 能跨插件,server 不能**。RFC 0012 计划把插件代码收回常驻 runtime,届时 server 会成为主要执行端,该缺口需要一并解决。设计时应优先考虑让 `ctx.events` 覆盖 server 侧,而不是给 `invokeChannel` 开跨 scope 参数——后者会绕过 schema 与 caller 身份。 + +## 明确不存在的 + +- 插件间事件 / 广播(`ctx.events` 设计中) +- 插件间共享状态 +- client `api.fetch` 跨 scope——它拒绝绝对 URL、协议相对 URL 与顶层 `/api/*`,只能访问自己 scope 下的 scoped API + +## 不新增第四种原语 + +跨插件通信原语固定为 extension points / plugin APIs / events 三条。新需求应落到这三条之一,或走宿主中介。 + +`ctx.events` 不算新增——它取代 `@oneworks/hooks` 的私有中间件链,净原语数不变。论证见 RFC 0012。 From f31007ec96df9b7125edec29e9e857021c7cdc17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:16:05 +0000 Subject: [PATCH 6/8] docs(rules): restructure the plugin system spec around one model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec was accumulating chapters without a spine — twelve documents across two RFC series and a rules directory, each capability argued separately, no single sentence saying what a plugin is. Give it one. A plugin stands in exactly three relations to the host and to other plugins, distinguished by who owns the result: - contribute — hands over data, the receiver decides how to use it - participate — gets called inside someone else's flow, the host merges - provide — exposes a named capability, the plugin itself decides The three are mutually exclusive and complete, so a new capability must classify into one; if it doesn't, the model is wrong and changes first. Crossed with where code runs (client / server / reporter), this yields a matrix where the blanks are the gaps, and it reframes the open work in one line each: - contribute is healthy on both ends - participate sits in the wrong place: it belongs on the resident server but currently runs in the reporter subprocess with a logger-only ctx, which is exactly what RFC 0012 moves - provide is crippled server-side, since channels and registerApi bind scope host-side and a server plugin can only ever call its own Chapters now follow the model: contribute.md, participate.md, provide.md replace the earlier communication.md, whose content spanned two relations and so obscured the distinction. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rules/PLUGIN-SYSTEM.md | 9 +- .oo/rules/plugin-system/README.md | 129 ++++++++++++++--------- .oo/rules/plugin-system/communication.md | 97 ----------------- .oo/rules/plugin-system/contribute.md | 103 ++++++++++++++++++ .oo/rules/plugin-system/participate.md | 110 +++++++++++++++++++ .oo/rules/plugin-system/provide.md | 82 ++++++++++++++ 6 files changed, 378 insertions(+), 152 deletions(-) delete mode 100644 .oo/rules/plugin-system/communication.md create mode 100644 .oo/rules/plugin-system/contribute.md create mode 100644 .oo/rules/plugin-system/participate.md create mode 100644 .oo/rules/plugin-system/provide.md diff --git a/.oo/rules/PLUGIN-SYSTEM.md b/.oo/rules/PLUGIN-SYSTEM.md index 93b81ec72..d130e5240 100644 --- a/.oo/rules/PLUGIN-SYSTEM.md +++ b/.oo/rules/PLUGIN-SYSTEM.md @@ -1,6 +1,6 @@ --- alwaysApply: false -description: 当任务涉及插件系统的 manifest、解析装配、生命周期、ctx 能力面、插件间通信、UI 贡献、事件、信任边界或分发时加载的插件系统入口。 +description: 当任务涉及插件系统的统一模型(贡献/参与/提供)、manifest、解析装配、生命周期、事件、信任边界或分发时加载的插件系统入口。 --- # 插件系统入口 @@ -13,10 +13,9 @@ description: 当任务涉及插件系统的 manifest、解析装配、生命周 - 插件包与 manifest:`plugin-system/manifest.md`(待写) - 解析与装配:`plugin-system/resolution.md`(待写) - 生命周期:`plugin-system/lifecycle.md`(待写) -- ctx 能力面:`plugin-system/context.md`(待写) -- 插件间通信:[`plugin-system/communication.md`](./plugin-system/communication.md) -- UI 贡献:`plugin-system/contributions.md`(待写) -- 事件系统:`plugin-system/events.md`(待写) +- 贡献(UI、声明式渲染、extension points、资产):[`plugin-system/contribute.md`](./plugin-system/contribute.md) +- 参与(事件、mode、裁决):[`plugin-system/participate.md`](./plugin-system/participate.md) +- 提供(plugin APIs、commands、channels、注册型 seam):[`plugin-system/provide.md`](./plugin-system/provide.md) - 信任与安全边界:`plugin-system/trust.md`(待写) - 分发与可见性:`plugin-system/distribution.md`(待写) diff --git a/.oo/rules/plugin-system/README.md b/.oo/rules/plugin-system/README.md index b6b12a9b8..3a133ec1c 100644 --- a/.oo/rules/plugin-system/README.md +++ b/.oo/rules/plugin-system/README.md @@ -1,77 +1,106 @@ --- alwaysApply: false -description: 插件系统规范总览:核心模型、分章导航与各能力的实现状态。 +description: 插件系统规范总览:统一模型、能力矩阵、实现状态与分章导航。 --- # 插件系统规范 本目录是插件系统的**规范**(这么定的),设计论证见 RFC 0011 / 0012(为什么这么定)。 -## 核心模型 +## 统一模型 -One Works 是**有特权宿主 + 只能做加法的扩展**,不是"一切皆插件"。 +插件与宿主、与其他插件之间**只有三种关系**。任何插件能力都归属且只归属其中一种。 -- 宿主拥有:discovery、runtime、marketplace、HMR、权限判定、进程与生命周期 -- 插件只能:往宿主开放的点位上挂东西、向其他插件贡献或调用 -- 插件**不能**:创造插件、注册顶层路由、直接访问文件系统(前端)、放宽权限 +| 关系 | 插件做什么 | 谁决定结果 | 权限强度 | +| -------------------- | ------------------------------------------ | ---------------- | -------- | +| **贡献** Contribute | 交出一份数据,别人拿去渲染或使用 | 接收方 | 最弱 | +| **参与** Participate | 在别人的流程里被回调,可观察 / 改写 / 收紧 | 宿主按 mode 合并 | 中 | +| **提供** Provide | 挂出一个具名能力,别人来调用 | 插件自己 | 最强 | -四条不可协商的边界: +判据是**谁拥有结果**:贡献交出数据、接收方决定怎么用;参与在既定流程里插一脚、宿主决定怎么合并;提供是插件成为被依赖的一方、它自己说了算。 -1. 插件不能实例化其他插件。动态插件图由宿主经 overlay 注入,发生在配置解析层。 -2. 插件只能收紧权限,不能放宽。 -3. 能力不支持时必须 fail loud,禁止 accepted-then-ignored。 -4. `scope` 是逻辑隔离(防命名冲突),**不是安全边界**。真正的边界见 `trust.md`(待写)。 +三者互斥且完备。**新能力必须先归类,归不进去说明模型有问题,需要先改模型而不是加第四类。** + +正交的两个维度: + +- **在哪儿跑**:client(浏览器/渲染进程)、server(常驻运行时)、上报器(`oneworks-call-hook` 子进程) +- **谁触发**:用户、agent、宿主、另一个插件 + +## 能力矩阵 + +三种关系 × 三个运行位置。空格即缺口。 + +| | client | server | 上报器 | +| -------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------- | +| **贡献** | slots / views / routes / themes / `toolUsePresentations` / `extensionContributions` ✅ | assets(skills / rules / specs / entities / mcp / hooks 目录)✅ | — | +| **参与** | ❌ 不存在 | 🚧 `ctx.events`(设计中) | ⚠️ 现状:hook 插件代码在这里跑 | +| **提供** | plugin APIs ✅ / commands ✅ | channels ⚠️ 只能自调 / `registerApi` ⚠️ 只能自调 | — | + +矩阵读出三个结论: + +1. **贡献这一行是健康的**,client 与 server 各司其职。 +2. **参与这一行位置错了。** 它应当在 server(常驻、有完整 ctx、能持有状态),现在却在上报器(每事件一次子进程、ctx 只有 `logger`)。RFC 0012 做的事用这个模型说就是一句话:**把"参与"从上报器搬到 server**。 +3. **提供这一行 server 侧是残的。** channels 与 `registerApi` 的 scope 由宿主绑死,server 插件只能调自己的,无法向其他插件提供能力。 + +## 四条不可协商的边界 + +1. **插件不能实例化其他插件。** 动态插件图由宿主经 overlay 注入,发生在配置解析层。 +2. **插件只能收紧权限,不能放宽。** 宿主基线判定是地基,插件判定取 meet。 +3. **能力不支持时必须 fail loud。** 禁止 accepted-then-ignored。 +4. **`scope` 是逻辑隔离,不是安全边界。** 真正的边界见 `trust.md`(待写)。 ## 分章 -| 章节 | 覆盖 | -| ---------------------------------------- | ------------------------------------------------- | -| `manifest.md`(待写) | 插件包结构、manifest 字段、入口导出约定 | -| `resolution.md`(待写) | 发现来源、children、scope 分配、overlay、冲突处理 | -| `lifecycle.md`(待写) | activate / dispose / reload / watch、失败态 | -| `context.md`(待写) | client / server / view 三端 ctx 的完整能力面 | -| [`communication.md`](./communication.md) | 插件间通信的三条通道及其边界 | -| `contributions.md`(待写) | slots、views、routes、themes、声明式渲染 | -| `events.md`(待写) | 事件 mode、约束、可用性分级 | -| `trust.md`(待写) | 信任模型、安全边界、什么不是保证 | -| `distribution.md`(待写) | marketplace、安装卸载、版本、可见性与诊断 | +章节按统一模型组织:三种关系各一章,其余是横切关注点。 + +| 章节 | 覆盖 | +| ------------------------------------ | ----------------------------------------------------------------------------- | +| [`contribute.md`](./contribute.md) | 贡献:UI slots、views、routes、themes、声明式渲染、extension points、资产目录 | +| [`participate.md`](./participate.md) | 参与:事件 mode、约束表、可用性分级、裁决语义 | +| [`provide.md`](./provide.md) | 提供:plugin APIs、commands、channels、注册型 seam | +| `manifest.md`(待写) | 插件包结构、manifest 字段、入口导出约定 | +| `resolution.md`(待写) | 发现来源、children、scope 分配、overlay、冲突处理 | +| `lifecycle.md`(待写) | activate / dispose / reload / watch、失败态 | +| `trust.md`(待写) | 信任模型、安全边界、什么不是保证 | +| `distribution.md`(待写) | marketplace、安装卸载、版本、可见性与诊断 | ## 实现状态 -规范同时标注**当前实现状态**。未实现的内容以 `设计中` / `缺失` 标注,不得按"已有"编写插件或引用。 - -| 能力 | 状态 | 位置 | -| ---------------------------------------------------- | ------------------ | -------------------------------------------------- | -| manifest / 包结构 / 双入口 | ✅ 已实现 | `packages/types/src/plugin.ts` | -| 解析、children、scope、环检测 | ✅ 已实现 | `packages/utils/src/plugin-resolver.ts` | -| 任务级 overlay | ✅ 已实现 | `PluginOverlayConfig` + `overlaySource` | -| client 生命周期 / watch reload | ✅ 已实现 | `apps/client/src/plugins/PluginProvider.tsx` | -| extension points(含等待语义、epoch) | ✅ 已实现 | `apps/client/src/plugins/plugin-registry.ts` | -| plugin APIs(含挂起队列、timeout) | ✅ 已实现 | 同上 | -| 跨 scope command 调用 | ⚠️ 已实现但未文档化 | `plugin-registry.ts:405` | -| UI slots / views / routes / themes | ✅ 已实现 | `plugin-runtime.ts` | -| 声明式渲染(`toolUsePresentations`) | ✅ 已实现 | `apps/client/src/plugins/plugin-tool-use.ts` | -| hook 事件(14 个,跨 adapter 统一) | ✅ 已实现 | `packages/hooks/` | -| marketplace 安装 / 卸载 / 账本 | ✅ 已实现 | `apps/server/src/services/plugins/marketplace*.ts` | -| **`ctx.events` 通用事件 API** | 🚧 设计中 | RFC 0012 | -| **server 侧跨插件通信** | ❌ 缺失 | 见 [`communication.md`](./communication.md) | -| **注册型 seam(model provider / adapter provider)** | ❌ 缺失 | RFC 0011 行动项 P2 | -| **视图槽(组件级贡献)** | ❌ 缺失,且不优先 | RFC 0011 纪律 2 | -| **ErrorBoundary** | ❌ 缺失 | RFC 0011 行动项 P0-3 | -| **生成式能力目录** | ❌ 缺失 | RFC 0011 行动项 P0-2 | +规范同时标注**当前实现状态**。未实现的内容以 `设计中` / `缺失` 标注,**不得按"已有"编写插件或引用**。 + +| 能力 | 关系 | 状态 | 位置 | +| --------------------------------------- | ---- | -------------------- | -------------------------------------------------- | +| manifest / 包结构 / 双入口 | —— | ✅ | `packages/types/src/plugin.ts` | +| 解析、children、scope、环检测 | —— | ✅ | `packages/utils/src/plugin-resolver.ts` | +| 任务级 overlay | —— | ✅ | `PluginOverlayConfig` + `overlaySource` | +| client 生命周期 / watch reload | —— | ✅ | `apps/client/src/plugins/PluginProvider.tsx` | +| UI slots / views / routes / themes | 贡献 | ✅ | `apps/client/src/plugins/plugin-runtime.ts` | +| 声明式渲染 `toolUsePresentations` | 贡献 | ✅ | `apps/client/src/plugins/plugin-tool-use.ts` | +| extension points(等待语义、epoch) | 贡献 | ✅ | `apps/client/src/plugins/plugin-registry.ts` | +| 资产目录投影 | 贡献 | ✅ | `packages/workspace-assets/` | +| hook 事件(14 个,跨 adapter 统一) | 参与 | ⚠️ 位置错(在上报器) | `packages/hooks/` | +| `ctx.events` 通用事件 API | 参与 | 🚧 设计中 | RFC 0012 | +| plugin APIs(挂起队列、timeout) | 提供 | ✅ | `apps/client/src/plugins/plugin-registry.ts` | +| 跨 scope command 调用 | 提供 | ⚠️ 已实现但未文档化 | `plugin-registry.ts:405` | +| server channels / `registerApi` | 提供 | ⚠️ 只能自调 | `apps/server/src/services/plugins/runtime.ts:3519` | +| 注册型 seam(model / adapter provider) | 提供 | ❌ 缺失 | RFC 0011 行动项 P2 | +| marketplace 安装 / 卸载 / 账本 | —— | ✅ | `apps/server/src/services/plugins/marketplace*.ts` | +| ErrorBoundary | —— | ❌ 缺失 | RFC 0011 行动项 P0-3 | +| 生成式能力目录 | —— | ❌ 缺失 | RFC 0011 行动项 P0-2 | ## 已知不一致 -规范化过程中发现、尚未消解的实现与文档分歧。每条都应有对应 issue 或 RFC 待办: +规范化过程中发现、尚未消解的实现与文档分歧: -1. **跨 scope command 调用未文档化。** `executeCommand` 接受 `/` 形式并会 HTTP 打到目标 scope;`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。需确认是正式通道还是实现顺带,然后补文档或加限制。 -2. **client 能跨插件,server 不能。** `invokeChannel` 的 scope 由宿主绑死(`runtime.ts:3519`),server 插件只能调自己的 channel。这与 RFC 0012 把插件代码收回常驻 runtime 的方向存在张力。 -3. **可用性分级粒度不足。** RFC 0012 用 `both | bridge | native:` 三级,而 [`hooks/events.md`](../hooks/events.md) 的真实矩阵是逐 adapter 逐事件,且带 `canBlock` 维度。事件规范应以该矩阵为准。 -4. **同 scope 内 command id 撞名的处理未核实。** `runtime.ts:2802` 的冲突检查针对内置 route key,同 scope 内的撞名路径待查。 +1. **跨 scope command 调用未文档化。** `executeCommand` 接受 `/` 并会 HTTP 打到目标 scope;`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。需确认是正式通道还是实现顺带。 +2. **"提供"关系在 server 侧不完整。** `invokeChannel` 的 scope 由宿主绑死(`runtime.ts:3519`)。RFC 0012 把"参与"收回 server 后,server 会成为主要执行端,该缺口必须一并解决。 +3. **可用性分级粒度不足。** RFC 0012 用 `both | bridge | native:` 三级,而 [`hooks/events.md`](../hooks/events.md) 的真实矩阵是逐 adapter 逐事件、且带 `canBlock` 维度。事件规范应以该矩阵为准。 +4. **同 scope 内 command id 撞名的处理未核实。** `runtime.ts:2802` 的冲突检查针对内置 route key。 ## 编写规范时的约定 +- 新能力先归类到三种关系之一,归不进去先改模型 - 每章标注状态,不把设计当现状 - 每条限制写清楚**为什么**,或链到 RFC 的对应论证 -- 矩阵类内容以代码或已有 rules 文档为准,不凭记忆写 -- 与 [`hooks/events.md`](../hooks/events.md) 等既有 rules 重叠时,引用而非复制 +- 矩阵类内容以代码或既有 rules 文档为准,不凭记忆写 +- 与 [`hooks/events.md`](../hooks/events.md) 等既有 rules 重叠时引用而非复制 diff --git a/.oo/rules/plugin-system/communication.md b/.oo/rules/plugin-system/communication.md deleted file mode 100644 index 58622fd9f..000000000 --- a/.oo/rules/plugin-system/communication.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -alwaysApply: false -description: 插件间通信的三条通道、边界判据与当前缺口。 ---- - -# 插件间通信 - -返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) - -## 通道总表 - -| 通道 | 方向 | 跨 scope | 有返回值 | 端 | 状态 | -| ------------------------- | --------- | -------- | -------- | --------------- | ---------- | -| extension points | 1→N 登记 | ✅ | ❌ | client | ✅ | -| plugin APIs | 1→1 调用 | ✅ | ✅ | client | ✅ | -| commands | 1→1 调用 | ✅ | ✅ | client → server | ⚠️ 未文档化 | -| contribution 的 `command` | 宿主中介 | ✅ | —— | client | ✅ | -| runtime channel | 自己→自己 | ❌ | ✅ | server | ✅ | -| `ctx.events` | 1→N 派发 | ✅ | 视 mode | 两端 | 🚧 设计中 | - -## 选哪条 - -| 意图 | 用 | -| ------------------------------------------------ | ---------------------- | -| 让别人往我这里**登记结构化贡献**,我自己读取渲染 | extension points | -| **调用**某个特定插件拿返回值 | plugin APIs | -| **通知**多方 / 让多方**改写**或**收紧** | `ctx.events`(设计中) | - -一句话:extension points 是贡献登记,plugin APIs 是一对一调用,events 是一对多派发。 - -## Extension points - -```js -ctx.extensionPoints.register({ id, title, contributionSchema }) -ctx.extensionPoints.onAvailable('/', point => {/* 返回 cleanup */}) -ctx.extensionPoints.contribute('/', contribution) -view.extensions.getContributions('') // owner 在 React view 内读回 -``` - -规则: - -- 目标 id 单段视为当前 scope,两段为 `/`;超过两段报诊断。 -- **注册贡献必须用 `onAvailable`,不得用 `has()` 做一次性判断**——激活顺序不保证。目标已存在时 `onAvailable` 立即触发,不存在则挂起等待。 -- `onAvailable` 回调返回的 cleanup 在目标扩展点卸载时由宿主执行。 -- manifest 的静态 `extensionContributions` 同样经 `onAvailable` 装配,因此声明式贡献也不怕顺序。 -- owner 拿到的是**数据记录**,不是可执行对象。要触发行为须经 contribution 携带的 `command`。 - -## Plugin APIs - -```js -ctx.pluginApis.register({ id, title, inputSchema, outputSchema, handler }) -await ctx.pluginApis.call('/', input, { timeoutMs }) -``` - -规则: - -- handler 的 `meta` 携带 `callerScope` / `targetScope` / `apiId`,提供方据此决定是否服务。 -- 目标未注册时 `call` **挂起等待**而非报错,注册时排空;支持 `timeoutMs` 与 `AbortSignal`。 -- 调用方插件卸载时,其挂起的调用被 reject。 -- 重复注册同一 id 报 duplicate 诊断,后者不生效。 - -## Commands(跨 scope 部分未文档化) - -`ctx.commands.execute(commandId)` 的 scope 由宿主绑定为当前插件,但 `commandId` 接受 `/` 形式: - -```js -const key = commandId.includes('/') ? commandId : scopedKey(scope, commandId) -// 本地 registry 未命中 → HTTP POST /api/plugins//commands/ -``` - -即**传入带 scope 的 id 即可调用其他插件的命令**。`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。 - -**在澄清前,新插件不应依赖这条路径**——它可能被收紧为同 scope 限定。跨插件调用请用 plugin APIs(有 schema、有超时、有 caller 身份)。 - -## Server 侧:当前没有跨插件通道 - -``` -runtime.ts:3519 - invokeChannel: (channelId, invocation) => - this.invokeRuntimeChannel(scope, channelId, invocation) -``` - -scope 由宿主绑死,插件传不进去,因此 server 插件**只能调用自己的 channel**。`registerApi` 的 proxy 同样锁在 `/api/plugins/:scope/*` 下。 - -这是一处真实缺口:**client 能跨插件,server 不能**。RFC 0012 计划把插件代码收回常驻 runtime,届时 server 会成为主要执行端,该缺口需要一并解决。设计时应优先考虑让 `ctx.events` 覆盖 server 侧,而不是给 `invokeChannel` 开跨 scope 参数——后者会绕过 schema 与 caller 身份。 - -## 明确不存在的 - -- 插件间事件 / 广播(`ctx.events` 设计中) -- 插件间共享状态 -- client `api.fetch` 跨 scope——它拒绝绝对 URL、协议相对 URL 与顶层 `/api/*`,只能访问自己 scope 下的 scoped API - -## 不新增第四种原语 - -跨插件通信原语固定为 extension points / plugin APIs / events 三条。新需求应落到这三条之一,或走宿主中介。 - -`ctx.events` 不算新增——它取代 `@oneworks/hooks` 的私有中间件链,净原语数不变。论证见 RFC 0012。 diff --git a/.oo/rules/plugin-system/contribute.md b/.oo/rules/plugin-system/contribute.md new file mode 100644 index 000000000..da52a901e --- /dev/null +++ b/.oo/rules/plugin-system/contribute.md @@ -0,0 +1,103 @@ +--- +alwaysApply: false +description: 贡献关系:插件交出数据、由接收方渲染或使用的全部能力。 +--- + +# 贡献(Contribute) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件交出一份数据,接收方决定怎么用。** 单向,无返回值,权限强度最弱。 + +接收方可能是宿主(UI slots、资产目录)或另一个插件(extension points)。无论哪种,**插件都不控制最终呈现**。 + +## 为什么贡献是首选形态 + +同一个需求若能用贡献表达,就不该用参与或提供: + +- 接收方完全掌控渲染与使用,插件出错不影响宿主结构 +- 数据可被 schema 校验、可被 i18n / 主题自动处理、可进能力目录 +- 不需要信任插件的执行行为 + +`toolUsePresentations` 是这条原则的样板:**看似"必须自定义渲染"的需求,多数实际是"宿主的声明式格式不够用"**。遇到"插件要塞组件"的诉求,先问缺哪个 format。 + +## 向宿主贡献 + +### UI 贡献点 + +| 类型 | API | 说明 | +| ------ | -------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| slots | `ctx.slots.register(slot, contribution)` | `nav.items`、`nav.moreMenu`、`chat.header.actions`、`workbench.tabs`、`launcher.searchProviders` 等 | +| views | `ctx.views.register(viewId, { renderNode })` | React view;DOM view 为兼容路径 | +| routes | `ctx.routes.register({ id, title, viewId })` | 落在 `/plugins//` | +| themes | `ctx.themes.*` | 主题 token | + +规则: + +- 复用宿主 UI 必须走 `view.ui.*` 声明式组件,不得复制宿主组件 DOM +- 必须复用宿主 React 单例,不得 bundle 第二份 React +- 所有 DOM 事件、style、timer、subscription 必须在返回的 `dispose()` 里清理 + +### 声明式渲染描述 + +`toolUsePresentations` 让插件提交**结构化渲染指令**,宿主据此渲染任意工具调用(`apps/client/src/plugins/plugin-tool-use.ts`): + +```json +{ + "path": "steps", + "title": "Steps", + "format": "records", + "item": { + "titlePath": "node_id", + "subtitlePath": "op", + "metaPath": "context" + } +} +``` + +- 输入格式封闭:`inline | text | code | list | chips | records | json` +- 结果格式:`auto | text | code | json | markdown`,另有 `mode: auto | declared | hidden` 做渐进披露 +- **不允许可执行模板、任意 HTML 或插件私有 React renderer** + +权限设计值得复用:`origin` 默认只能接管**自己 scope 下**的工具(经 base64 编码的 `oneworks-` 命名空间反解校验);接管别家工具须显式 `origin: 'any'`,且匹配优先级更低(20/10 vs 40/30)。**表达力做加法,权限做减法。** + +### 资产目录 + +manifest 的 `assets` 声明目录,宿主投影进工作区:`apps` / `rules` / `skills` / `specs` / `entities` / `mcp` / `hooks`。 + +插件还可依赖外部 skill 文档,经 lockfile 的 `pluginSkills` 挂到插件实例名下,标记 `plugin-skill-dependency-lock`。 + +## 向其他插件贡献:extension points + +owner 开点、contributor 贡献、owner 自行读取渲染。 + +```js +// owner +ctx.extensionPoints.register({ id, title, contributionSchema }) +view.extensions.getContributions('') // React view 内读回 + +// contributor +ctx.extensionPoints.onAvailable('/', point => {/* 返回 cleanup */}) +ctx.extensionPoints.contribute('/', contribution) +``` + +规则: + +- 目标 id 单段视为当前 scope,两段为 `/`,超过两段报诊断 +- **注册贡献必须用 `onAvailable`,不得用 `has()` 做一次性判断** —— 激活顺序不保证。目标已存在时立即触发,不存在则挂起等待 +- `onAvailable` 回调返回的 cleanup 在目标扩展点卸载时由宿主执行 +- manifest 的静态 `extensionContributions` 同样经 `onAvailable` 装配,声明式贡献也不怕顺序 +- **owner 拿到的是数据记录,不是可执行对象**。要触发行为须经 contribution 携带的 `command`(那属于[提供](./provide.md)) + +## 不存在:组件级贡献 + +**无法把 React 组件贡献进别人的视图。** `view.extensions.getContributions()` 返回数据记录。 + +这是刻意的。若要开视图槽,四个前置条件(论证见 RFC 0011 纪律 2): + +1. **ErrorBoundary 先行** —— 现状 `apps/client/src/plugins/` 与 `components/plugins/` 下零个 `ErrorBoundary`,`PluginHost.tsx:275` 裸渲染。此项与视图槽无关,应独立先做 +2. **挂载权归宿主** —— owner 拿到的必须是宿主包好的不透明节点,否则 contributor 代码会跑在 owner 的 viewContext 里,`view.options.update()` 会写到 owner 头上 +3. **扩展点须显式声明接受视图**并携带布局约束,默认保持数据模式 +4. **顺序稳定可预期** —— 按 `order` 或 `pluginScope` 字典序,不能是 Map 插入顺序 + +**决策顺序永远是:先扩格式词汇表 → 再把声明式渲染推广到其他槽 → 视图槽只留给真正无法声明化的场景**(自由画布、图编辑器、地图)。 diff --git a/.oo/rules/plugin-system/participate.md b/.oo/rules/plugin-system/participate.md new file mode 100644 index 000000000..1a4334536 --- /dev/null +++ b/.oo/rules/plugin-system/participate.md @@ -0,0 +1,110 @@ +--- +alwaysApply: false +description: 参与关系:插件在宿主流程里被回调,可观察、改写或收紧。 +--- + +# 参与(Participate) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件在别人的流程里被回调,宿主按 mode 合并结果。** 插件能影响流程,但不拥有结果。 + +## 现状:位置是错的 + +| | 应当 | 现状 | +| -------------- | -------------------------- | ----------------- | +| 插件代码跑在哪 | server 常驻运行时 | 上报器子进程 | +| 插件拿到的 ctx | 完整 `PluginServerContext` | 只有 `{ logger }` | +| 能否持有状态 | 能(常驻) | 契约上不能 | +| 入口 | `plugin.server.entry` | `/hooks` | + +RFC 0012 的全部工作用统一模型说就是一句话:**把"参与"从上报器搬到 server**。上报器降级为归一化上报,不再加载任何插件代码。 + +本章描述**目标形态**。当前实现见 `packages/hooks/`,迁移路径见 RFC 0012。 + +## 六个 mode + +沿用 Cordis 命名(与 DSH 对齐,便于插件迁移),另加我们特有的 `decide`。 + +| mode | 语义 | 监听器契约 | +| ----------- | ----------------------------------- | ------------------------------- | +| `emit` | 同步 fire-and-forget | `(payload) => void` | +| `parallel` | 并发启动,await 全部 | `(payload) => Promise` | +| `serial` | 顺序执行,后者可观察前者副作用 | `(payload) => Promise` | +| `bail` | 首个返回非 `undefined` 者胜出并短路 | `(payload) => R \| undefined` | +| `waterfall` | 链式改写,必须调 `next()` | `(payload, next) => Promise

` | +| `decide` | 单向收紧合并,顺序无关 | `(payload) => D \| undefined` | + +`parallel` 与 `serial` 的区别不是"派发方等不等",而是**监听器之间能否观察到彼此的副作用**。这是事件的语义属性,因此是两个独立 mode。 + +## 约束表 + +约束打在**事件定义**上,`define` 时机械校验,违反即失败。 + +| mode | `cross-process` | `security: true` | +| ----------- | --------------- | ---------------- | +| `emit` | ❌ | ❌ | +| `parallel` | ✅ | ❌ | +| `serial` | ✅ | ❌ | +| `bail` | ✅ | ❌ | +| `waterfall` | ✅ | ❌ | +| `decide` | ✅ | ✅ 唯一合法 | + +**`emit` 不可跨进程的理由是语义冗余,不是技术做不到。** 跨进程后 `emit` 的三条价值(无调度开销、同栈异常传播、无交错)全部失效,它退化成"阻塞的通知"——而那已经叫 `serial`。该论证不依赖传输方式。 + +**`bail` 禁用于权限类事件**是用途约束不是 mode 约束。`bail` 用于 resolver 场景(谁能处理这个 URL)完全正当;用于权限裁决则是提权通道,任何插件都能静默抢占宿主的决策。 + +## `decide`:单向收紧 + +所有监听器并行拿到同一份 payload,各自独立判定,宿主按事件定义的**收紧格**取 meet。返回 `undefined` 表示无意见。 + +以工具权限为例,格为 `allow ⊐ ask ⊐ deny`: + +- 宿主 allow + 插件 deny = **deny** +- 宿主 deny + 插件 allow = **仍然 deny** +- 插件超时 = 无意见 = 按已有判定走 + +三条性质是推论:合并结果不可能比宿主基线宽松;超时既非 fail-open(基线仍在)也非 fail-closed(慢插件不拖垮 agent);顺序无关(合并可交换)。 + +**这把"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制**,与[贡献](./contribute.md)里 `toolUsePresentations` 的 `origin` 设计同源。 + +超时**必须产生可见诊断**。反复超时的插件应在 `/plugins` 详情页可见。 + +## 顺序契约 + +| mode | 顺序语义 | +| ----------- | ----------------------------------------------------- | +| `emit` | 按 priority 升序 | +| `parallel` | 顺序无关 | +| `serial` | 按 priority 升序,后者可观察前者副作用 | +| `bail` | 按 priority 升序,首个非 `undefined` 者短路 | +| `waterfall` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `decide` | 顺序无关 | + +**宿主内置监听器占用保留的 priority 段,第三方无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证(`packages/hooks/src/runtime.ts:81-89`)。 + +不采用 Cordis 的 `prepend` 布尔——它只能表达"最前",两个插件都传时退化成看注册顺序。 + +## 可用性分级 + +**并非所有事件在所有 adapter 下都可用。** 权威矩阵是 [`hooks/events.md`](../hooks/events.md):逐 adapter 逐事件标注 native / bridge / 不支持,并带 `canBlock: true|false`。 + +- `native` —— 上游 CLI 原生 hook 透传 +- `bridge` —— 由 `packages/hooks/src/bridge.ts` 从会话消息与工具事件合成 +- `canBlock: false` —— 该事件在该 adapter 下**只能观察,不能阻断** + +订阅一个当前 source 不支持的事件时,宿主**必须报诊断而非静默不触发**。 + +**不要在事件规范里另造一套粗粒度分级。** 以 `hooks/events.md` 的矩阵为准。 + +## 事件词汇表 + +事件名采用 `namespace/kebab-verb`,与 DSH 对齐以便插件迁移。现有 14 个事件的改名映射、以及与 DSH 对照后确认的四个缺口点位(`agent/request`、`agent/request-error`、`tools/execute`、`tools/result`),见 RFC 0012 的事件词汇表章节。 + +事件定义必须携带:`name`、`mode`、`availability`、`security`、`payload`、`result`、`summary`。这份定义应由源码 AST 生成并接入 CI 门禁(RFC 0011 行动项 P0-2),避免与实现漂移。 + +## 事件流的三个消费者 + +统一事件流同时喂:**插件**(`ctx.events.on`)、**session log**(落实 model-visible ⟺ logged)、**UI 实时流**(`apps/server/src/services/client-events.ts` 的 `publishClientEvent`)。 + +三者共用同一份事件定义,不各自造一套。 diff --git a/.oo/rules/plugin-system/provide.md b/.oo/rules/plugin-system/provide.md new file mode 100644 index 000000000..0f6a32a43 --- /dev/null +++ b/.oo/rules/plugin-system/provide.md @@ -0,0 +1,82 @@ +--- +alwaysApply: false +description: 提供关系:插件挂出具名能力供他方调用,含当前 server 侧缺口。 +--- + +# 提供(Provide) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件挂出一个具名能力,别人来调用,插件自己决定结果。** 三种关系里权限最强的一种——插件成为被依赖的一方。 + +因此提供类能力必须满足:调用方身份可见、契约可校验、超时可控、销毁可回收。 + +## Plugin APIs(client,✅) + +一对一、带 schema 的调用,是**跨插件调用的首选**。 + +```js +ctx.pluginApis.register({ id, title, inputSchema, outputSchema, handler }) +await ctx.pluginApis.call('/', input, { timeoutMs }) +``` + +规则: + +- handler 的 `meta` 携带 `callerScope` / `targetScope` / `apiId`,**提供方据此决定是否服务** +- 目标未注册时 `call` **挂起等待**而非报错,注册时排空(`drainPendingPluginApiCalls`) +- 支持 `timeoutMs` 与 `AbortSignal`;调用方插件卸载时其挂起的调用被 reject +- 重复注册同一 id 报 duplicate 诊断,后者不生效 + +四条性质(caller 身份、schema、超时、卸载回收)正是"提供"关系该有的护栏,新增提供型能力应对齐这套。 + +## Commands(client → server,⚠️ 跨 scope 未文档化) + +`ctx.commands.execute(commandId)` 的 scope 由宿主绑定,但 `commandId` 接受 `/`: + +``` +plugin-registry.ts:405 + const key = commandId.includes('/') ? commandId : scopedKey(scope, commandId) + // 本地 registry 未命中 → HTTP POST /api/plugins//commands/ +``` + +即**传入带 scope 的 id 即可调用其他插件的命令**。`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。 + +**澄清前新插件不应依赖这条路径**——它可能被收紧为同 scope 限定。跨插件调用请用 plugin APIs:有 schema、有超时、有 caller 身份,commands 三样都没有。 + +command 的正当用途是**被贡献引用**:contribution 携带 `command: '/'`,owner 渲染、用户点击时由宿主调用 contributor 的命令。那是宿主中介,不是插件直接互调。 + +## Server channels(⚠️ 只能自调) + +``` +runtime.ts:3519 + invokeChannel: (channelId, invocation) => + this.invokeRuntimeChannel(scope, channelId, invocation) +``` + +scope 由宿主绑死,插件传不进去,因此 **server 插件只能调用自己的 channel**。`registerApi` 的 proxy 同样锁在 `/api/plugins/:scope/*` 下。 + +### 这是真实缺口 + +**"提供"关系在 server 侧不完整:client 能跨插件,server 不能。** + +RFC 0012 把"参与"收回 server 之后,server 会成为主要执行端——届时"server 插件无法向其他插件提供能力"会从边角问题变成主要障碍。 + +**修法不是给 `invokeChannel` 开跨 scope 参数**——那会绕过 schema 与 caller 身份,把 channel 变成没有护栏的 RPC。正确方向是把 plugin APIs 那套契约(caller 身份、schema、超时、卸载回收)平移到 server 侧。 + +## 注册型 seam(❌ 缺失) + +插件提供一个**实现**并成为运行时的一部分,典型是 model provider、adapter provider。 + +当前完全没有:`packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表,适配器是编译期内置(根 `package.json` devDependencies + 静态 import)。第三方要加只能改仓库。 + +**这不是遗漏,是传输形态决定的。** hook 是"每事件一次子进程往返",对拦截型契合、对注册型不成立——LLM adapter 要维持流式连接、跨多次调用持有状态。要开注册型 seam 必须落在常驻 runtime,不是扩事件表。 + +上游 DSH 提供了可行形态:`SubagentProvider` 的 `start()` 只负责"怎么起、怎么说话",真正的长连接与进程生命周期由宿主的 `ctx.subprocess` 托管。**插件提供协议适配,宿主拥有进程和生命周期。** + +若要开,最该先开的是 model provider(数据面而非控制面),且必须带凭证 seam——**插件拿 ref 不拿明文 key**。属于 RFC 0011 行动项 P2,需要产品决策。 + +## 不新增第四种原语 + +提供型通道固定为 plugin APIs / commands / channels,加上未来可能的注册型 seam。新需求应落到已有通道,或改走[贡献](./contribute.md)与[参与](./participate.md)。 + +`ctx.events` 不算新增——它取代 `@oneworks/hooks` 的私有中间件链,净原语数不变。 From 698e4b1986c14afc40804afcb335d3cec34d7c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:12:28 +0000 Subject: [PATCH 7/8] docs(rules): hoist transport/security to the whole plugin system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode constraint table only covered participate-type channels, but transport reachability and security grading are properties of every communication channel, not just events. - README: define `transport` and a three-tier `security` grading, plus where the security property attaches per relation (channel for contribute/participate, ctx capability surface for provide) - contribute: asset directories are `advisory`, not `none` — skills and rules reach the system prompt, mcp reaches the toolset - provide: provide-type channels carry no `security` field; the handler is the plugin's own code, so the real bound is what ctx exposes - participate: frame the mode table as a specialization of the general rule rather than a standalone invention Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rules/plugin-system/README.md | 36 ++++++++++++++++++++++++++ .oo/rules/plugin-system/contribute.md | 17 +++++++++++- .oo/rules/plugin-system/participate.md | 6 +++-- .oo/rules/plugin-system/provide.md | 14 ++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.oo/rules/plugin-system/README.md b/.oo/rules/plugin-system/README.md index 3a133ec1c..1c1c8a2b3 100644 --- a/.oo/rules/plugin-system/README.md +++ b/.oo/rules/plugin-system/README.md @@ -26,6 +26,42 @@ description: 插件系统规范总览:统一模型、能力矩阵、实现状 - **在哪儿跑**:client(浏览器/渲染进程)、server(常驻运行时)、上报器(`oneworks-call-hook` 子进程) - **谁触发**:用户、agent、宿主、另一个插件 +## 两个正交约束维度 + +三种关系回答"谁拥有结果"。**每条通信通道还必须声明两个正交属性**,它们共同决定哪些形态合法。 + +### `transport` —— 传输可达性 + +| 值 | 含义 | +| --------------- | ----------------------------------------------- | +| `in-process` | 仅同一 runtime 内派发 | +| `cross-process` | 需跨进程(上报器 ↔ server,或 client ↔ server) | + +跨进程通道**不得使用同步派发**。理由是语义冗余而非技术限制:跨进程后同步派发的三条价值(无调度开销、同栈异常传播、无交错)全部失效,它退化成"阻塞的通知",而那已有对应形态。 + +### `security` —— 安全分级 + +| 值 | 含义 | 约束 | +| --------------- | ------------------------------------------------------------------- | ------------------------- | +| `none` | 不影响权限,也不影响模型可见内容 | 无 | +| `advisory` | 影响**模型可见内容**(system prompt、工具集、上下文),但不直接授权 | 必须可从 session log 重建 | +| `authoritative` | 直接产生**权限判定** | 只能单向收紧,不得放宽 | + +### 三种关系上的落点不同 + +**这是关键区分,也是此前设计里漏掉的部分。** + +| 关系 | security 属性挂在哪 | 说明 | +| ---- | ------------------- | -------------------------------------------------------------------------------------------------------------------- | +| 贡献 | **通道上** | 数据本身有 security 性质。UI 贡献是 `none`;**资产目录是 `advisory`**——skills / rules 进 system prompt,mcp 进工具集 | +| 参与 | **通道上** | 事件定义声明。`tools/pre-execute` 是 `authoritative`,故只能用 `decide` | +| 提供 | **ctx 能力面上** | 通道本身无从分级——插件自己的代码能做什么,取决于宿主给了它什么 API,不取决于它怎么暴露 | + +因此: + +- **贡献与参与**的约束可在 `define` / 注册时机械校验 +- **提供**的约束只能落在 ctx 能力面的设计上,见 `trust.md`(待写) + ## 能力矩阵 三种关系 × 三个运行位置。空格即缺口。 diff --git a/.oo/rules/plugin-system/contribute.md b/.oo/rules/plugin-system/contribute.md index da52a901e..ab8b322d2 100644 --- a/.oo/rules/plugin-system/contribute.md +++ b/.oo/rules/plugin-system/contribute.md @@ -7,10 +7,25 @@ description: 贡献关系:插件交出数据、由接收方渲染或使用的 返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) -**插件交出一份数据,接收方决定怎么用。** 单向,无返回值,权限强度最弱。 +**插件交出一份数据,接收方决定怎么用。** 单向,无返回值。 接收方可能是宿主(UI slots、资产目录)或另一个插件(extension points)。无论哪种,**插件都不控制最终呈现**。 +## 安全分级不是一律 `none` + +按[统一模型的两个正交维度](./README.md#两个正交约束维度),贡献的 security 属性挂在**通道**上,且并非一律最弱: + +| 通道 | `transport` | `security` | +| --------------------------------------------------------------- | ---------------------- | -------------- | +| UI slots / views / routes / themes | `in-process`(client) | `none` | +| 声明式渲染 `toolUsePresentations` | `in-process`(client) | `none` | +| extension points | `in-process`(client) | `none` | +| **资产目录**(skills / rules / specs / entities / mcp / hooks) | 构建期投影 | **`advisory`** | + +**资产是例外,必须单独对待。** `skills` / `rules` / `specs` / `entities` 进 system prompt,`mcp` 进工具集,`hooks` 进适配器的原生 hook 配置——它们直接决定模型看到什么、能调什么。 + +因此资产贡献受 `advisory` 约束:**必须可从 session log 重建**(model-visible ⟺ logged)。新增资产类型时,同时要有对应的 session event,否则无法复现"agent 当时为什么这么做"。 + ## 为什么贡献是首选形态 同一个需求若能用贡献表达,就不该用参与或提供: diff --git a/.oo/rules/plugin-system/participate.md b/.oo/rules/plugin-system/participate.md index 1a4334536..9ba322d40 100644 --- a/.oo/rules/plugin-system/participate.md +++ b/.oo/rules/plugin-system/participate.md @@ -39,7 +39,7 @@ RFC 0012 的全部工作用统一模型说就是一句话:**把"参与"从上 ## 约束表 -约束打在**事件定义**上,`define` 时机械校验,违反即失败。 +参与关系的 security 属性挂在**通道**上(事件定义),因此可在 `define` 时机械校验,违反即失败。通用维度定义见[统一模型](./README.md#两个正交约束维度),下表是它在事件 mode 上的特化。 | mode | `cross-process` | `security: true` | | ----------- | --------------- | ---------------- | @@ -50,7 +50,9 @@ RFC 0012 的全部工作用统一模型说就是一句话:**把"参与"从上 | `waterfall` | ✅ | ❌ | | `decide` | ✅ | ✅ 唯一合法 | -**`emit` 不可跨进程的理由是语义冗余,不是技术做不到。** 跨进程后 `emit` 的三条价值(无调度开销、同栈异常传播、无交错)全部失效,它退化成"阻塞的通知"——而那已经叫 `serial`。该论证不依赖传输方式。 +`security: true` 一列对应通用分级的 `authoritative`;`advisory` 级事件(改写模型可见内容但不授权,如 `system-prompt/assemble`)用 `waterfall`,但必须可从 session log 重建。 + +`emit` 那一列是[通用 `transport` 规则](./README.md#两个正交约束维度)"跨进程不得同步派发"在事件上的实例:跨进程的 `emit` 退化成"阻塞的通知",而那已经叫 `serial`。该论证不依赖传输方式,即便未来把上报器换成 worker_threads 使 `Atomics.wait` 可用,结论不变。 **`bail` 禁用于权限类事件**是用途约束不是 mode 约束。`bail` 用于 resolver 场景(谁能处理这个 URL)完全正当;用于权限裁决则是提权通道,任何插件都能静默抢占宿主的决策。 diff --git a/.oo/rules/plugin-system/provide.md b/.oo/rules/plugin-system/provide.md index 0f6a32a43..cb48be4ed 100644 --- a/.oo/rules/plugin-system/provide.md +++ b/.oo/rules/plugin-system/provide.md @@ -11,6 +11,20 @@ description: 提供关系:插件挂出具名能力供他方调用,含当前 因此提供类能力必须满足:调用方身份可见、契约可校验、超时可控、销毁可回收。 +## security 边界不在通道上 + +按[统一模型的两个正交维度](./README.md#两个正交约束维度),提供关系与另外两种关系有个本质差别: + +**通道本身无从分级。** plugin API 与 command 的 handler 是插件自己的代码,它能做什么完全取决于**宿主给了它什么 ctx 能力**,与它把这个能力怎么暴露出去无关。给通道打 `security` 标记既不可校验也无意义——插件可以声明 `none` 然后在 handler 里做任何 ctx 允许的事。 + +推论: + +- **提供型通道不设 `security` 字段**,`define` 时也无从校验 +- 真正的约束落在 **ctx 能力面的设计**上:宿主不给的能力,插件无论如何暴露都拿不到 +- 因此新增 ctx 能力比新增提供型通道更需要评审——前者扩大了所有插件的能力上界 + +`transport` 维度仍然适用:plugin APIs 是 `in-process`(client),commands 是 `cross-process`(client → server),channels 是 `in-process`(server)。 + ## Plugin APIs(client,✅) 一对一、带 schema 的调用,是**跨插件调用的首选**。 From cc2a45b03568ab8f8007992d7600059e0b7d41de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:16:10 +0000 Subject: [PATCH 8/8] docs(rules): add the plugin system trust model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README and provide.md both assert that the real bound on a plugin is the ctx capability surface, with nothing behind the claim. This writes that chapter. The starting point is that there is no sandbox: client, server, and the hook reporter all load plugin code through a plain dynamic import into the host's own module graph. So "plugins cannot X" throughout the spec means the host offers no channel for X, not that the host prevents it. Documents the four boundaries that are real — the first-party capability gate in createServerContext, the source-group whitelist that gate rests on, the realpath-based scoped path check, and the loopback-only proxy with credential header stripping — plus the build-time source boundary and diagnostic redaction. Adds the review checklist for new ctx capabilities, and an explicit list of guarantees we do not make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rules/PLUGIN-SYSTEM.md | 2 +- .oo/rules/plugin-system/README.md | 6 +- .oo/rules/plugin-system/provide.md | 4 +- .oo/rules/plugin-system/trust.md | 134 +++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .oo/rules/plugin-system/trust.md diff --git a/.oo/rules/PLUGIN-SYSTEM.md b/.oo/rules/PLUGIN-SYSTEM.md index d130e5240..7df4ed513 100644 --- a/.oo/rules/PLUGIN-SYSTEM.md +++ b/.oo/rules/PLUGIN-SYSTEM.md @@ -16,7 +16,7 @@ description: 当任务涉及插件系统的统一模型(贡献/参与/提供 - 贡献(UI、声明式渲染、extension points、资产):[`plugin-system/contribute.md`](./plugin-system/contribute.md) - 参与(事件、mode、裁决):[`plugin-system/participate.md`](./plugin-system/participate.md) - 提供(plugin APIs、commands、channels、注册型 seam):[`plugin-system/provide.md`](./plugin-system/provide.md) -- 信任与安全边界:`plugin-system/trust.md`(待写) +- 信任与安全边界:[`plugin-system/trust.md`](./plugin-system/trust.md) - 分发与可见性:`plugin-system/distribution.md`(待写) 相邻主题: diff --git a/.oo/rules/plugin-system/README.md b/.oo/rules/plugin-system/README.md index 1c1c8a2b3..796d9e8e9 100644 --- a/.oo/rules/plugin-system/README.md +++ b/.oo/rules/plugin-system/README.md @@ -60,7 +60,7 @@ description: 插件系统规范总览:统一模型、能力矩阵、实现状 因此: - **贡献与参与**的约束可在 `define` / 注册时机械校验 -- **提供**的约束只能落在 ctx 能力面的设计上,见 `trust.md`(待写) +- **提供**的约束只能落在 ctx 能力面的设计上,见 [`trust.md`](./trust.md) ## 能力矩阵 @@ -83,7 +83,7 @@ description: 插件系统规范总览:统一模型、能力矩阵、实现状 1. **插件不能实例化其他插件。** 动态插件图由宿主经 overlay 注入,发生在配置解析层。 2. **插件只能收紧权限,不能放宽。** 宿主基线判定是地基,插件判定取 meet。 3. **能力不支持时必须 fail loud。** 禁止 accepted-then-ignored。 -4. **`scope` 是逻辑隔离,不是安全边界。** 真正的边界见 `trust.md`(待写)。 +4. **`scope` 是逻辑隔离,不是安全边界。** 真正的边界只有四道,见 [`trust.md`](./trust.md)。 ## 分章 @@ -97,7 +97,7 @@ description: 插件系统规范总览:统一模型、能力矩阵、实现状 | `manifest.md`(待写) | 插件包结构、manifest 字段、入口导出约定 | | `resolution.md`(待写) | 发现来源、children、scope 分配、overlay、冲突处理 | | `lifecycle.md`(待写) | activate / dispose / reload / watch、失败态 | -| `trust.md`(待写) | 信任模型、安全边界、什么不是保证 | +| [`trust.md`](./trust.md) | 信任:没有沙箱、四道真实边界、ctx 能力评审清单、什么不是保证 | | `distribution.md`(待写) | marketplace、安装卸载、版本、可见性与诊断 | ## 实现状态 diff --git a/.oo/rules/plugin-system/provide.md b/.oo/rules/plugin-system/provide.md index cb48be4ed..160cf1b41 100644 --- a/.oo/rules/plugin-system/provide.md +++ b/.oo/rules/plugin-system/provide.md @@ -20,8 +20,8 @@ description: 提供关系:插件挂出具名能力供他方调用,含当前 推论: - **提供型通道不设 `security` 字段**,`define` 时也无从校验 -- 真正的约束落在 **ctx 能力面的设计**上:宿主不给的能力,插件无论如何暴露都拿不到 -- 因此新增 ctx 能力比新增提供型通道更需要评审——前者扩大了所有插件的能力上界 +- 真正的约束落在 **ctx 能力面的设计**上:宿主不给的能力,插件无论如何暴露都拿不到(首方能力门见 [`trust.md`](./trust.md#1-ctx-能力面最重要的一道)) +- 因此新增 ctx 能力比新增提供型通道更需要评审——前者扩大了所有插件的能力上界,评审清单见 [`trust.md`](./trust.md#新增-ctx-能力的评审清单) `transport` 维度仍然适用:plugin APIs 是 `in-process`(client),commands 是 `cross-process`(client → server),channels 是 `in-process`(server)。 diff --git a/.oo/rules/plugin-system/trust.md b/.oo/rules/plugin-system/trust.md new file mode 100644 index 000000000..61fc7a7e6 --- /dev/null +++ b/.oo/rules/plugin-system/trust.md @@ -0,0 +1,134 @@ +--- +alwaysApply: false +description: 信任模型:真实的安全边界在哪、什么不是边界、新增 ctx 能力的评审要求。 +--- + +# 信任(Trust) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件代码与宿主同 realm、同进程运行。唯一真实的能力边界是宿主给不给 ctx 能力。** + +这一章托底另外三章:[README](./README.md#两个正交约束维度) 说"提供关系的约束只能落在 ctx 能力面上",[provide.md](./provide.md#security-边界不在通道上) 说"宿主不给的能力,插件无论如何暴露都拿不到"——两句话的依据都在这里。 + +## 前提:没有沙箱 + +| 位置 | 加载方式 | 插件能拿到什么 | +| ------ | -------------------------------------------------------- | ------------------------------ | +| client | `await import(entryUrl)`(`plugin-runtime.ts:713`) | 渲染进程完整 realm | +| server | `await import(pathToFileURL(entry))`(`runtime.ts:482`) | Node 进程完整能力 | +| 上报器 | `oneworks-call-hook` 子进程 | Node 完整能力,ctx 只有 logger | + +三处都是宿主自己的模块图里的一次动态 import,**没有 realm 隔离、没有 VM、没有权限降级**。因此: + +- client 插件可直接摸 `window` / `document` / `fetch`,绕过 ctx 调任何宿主没打算给它的东西 +- server 插件可直接 `import('node:fs')`,绕过 `resolveScopedPath` 读任意文件 + +**规范里所有"插件不能 X"的表述,含义是"不该 X,宿主不提供 X 的通道,越界即视为破坏契约",不是"宿主能阻止 X"。** 写规范和评审时不要把它当强制隔离引用。 + +## `scope` 不是安全边界 + +`scope` 解决的是**命名与归属**:注册表 key、路由前缀 `/plugins//`、资产投影目录、诊断归因、caller 身份(`meta.callerScope`)。 + +它让"谁注册的、出错该找谁"可判定,但拦不住恶意插件——同 realm 下 scope 只是个字符串。**跨插件调用的 `callerScope` 是给提供方做业务判断的信息,不是认证。** + +## 真实存在的四道边界 + +这四道是宿主代码里可指认的、越过去需要另外的手段,不是靠约定。 + +### 1. ctx 能力面(最重要的一道) + +`createServerContext`(`runtime.ts:3494`)按条件决定往 ctx 上挂什么。首方能力需要**三个条件同时成立**: + +``` +hasFirstPartyPluginCapability(instance, manifest, capability) + === instance.sourceGroup === 'builtIn' + && manifest.plugin.server.capabilities.includes(capability) +&& runtimeEndpoint.role === 'workspace' +``` + +- `sourceGroup === 'builtIn'` —— 由宿主在发现期赋值,插件无法自称(见下节) +- manifest 显式声明 —— 内置插件也要写出来,避免默认全给 +- `role === 'workspace'` —— manager 端不给 + +当前受此门限制的能力:`oneworksChannel`、`roomTunnel`(`PluginServerCapability`)。 + +**这是"提供关系的 security 落在 ctx 能力面上"的具体形态:不是给通道打标记,而是控制 ctx 上有没有那个字段。** + +### 2. 源分组(`PluginRuntimeSourceGroup`) + +`builtIn | global | project | localDev`,在 `discovery.ts:184-201` 由**路径与包名**决定,不读 manifest: + +| 值 | 判据 | +| ---------- | ------------------------------------------------------------------- | +| `localDev` | 位于 `.oo/plugins.dev` 下 | +| `global` | 位于全局 assets 的 `plugins` 下 | +| `builtIn` | packageId 命中 `bundledImmutableTrustPluginPackageIds` 或官方内置集 | +| `project` | 其余 | + +`bundledImmutableTrustPluginPackageIds` 是**硬编码的白名单**(`discovery.ts:31`),随构建产物固定,配置改不动。这是首方能力门能成立的根据。 + +**新增首方能力时,能力门必须复用这套判据,不得引入第二套"可信"定义。** + +### 3. 路径边界 + +`resolveScopedPath`(`runtime.ts:3464`):拒绝 `\0` 与绝对路径 → `path.resolve` → **两端都 `realpath`** → 比较相对路径是否逃逸。先 realpath 再比较是关键,否则符号链接可绕。 + +`proxyToLoopbackTarget` 同样拒绝含 `.` / `..` 段的路径。 + +这道边界约束的是**通道**,不是插件进程本身——server 插件直接用 `node:fs` 不受此限(见"没有沙箱")。它的意义在于:**宿主提供的通道不会成为逃逸的便利路径**。 + +### 4. 网络出口边界 + +`registerApi` 的 `proxy.target` 必须是 loopback(`isLoopbackProxyTarget`:http/https + 回环 host),`devServer`、`serverBaseUrl` 同样校验。 + +转发时 `normalizeHeaders`(`proxy.ts:29`)剥掉 `authorization` / `cookie` / `proxy-authorization` 等——**宿主的凭证不会顺着插件代理流出去**。 + +## 另外两道:构建期与信息面 + +不是运行时能力边界,但同属信任模型的一部分。 + +- **client 源码边界**(`client-source-boundary.ts`):构建期 rollup 插件,限制 `new URL(..., import.meta.url)` 只能引静态、未转义、在 sourceRoot 内的资源,CSS 另有校验。防的是构建产物越界引用,不防运行时行为。 +- **诊断脱敏**:`privateRoots` 收集本地绝对路径,`native-host.ts` 在把诊断与插件信息转成 public 视图时按它做替换。防的是**信息泄露**,与能力无关。 + +CSP(`apps/client/index.html:6`)`script-src 'self' 'unsafe-inline'` **不构成插件边界**——插件产物由宿主同源提供,天然满足 `'self'`。它挡的是外部注入,不是已加载的插件。 + +## 三种关系上的信任落点 + +| 关系 | 边界在哪 | 可机械校验 | +| ---- | -------------------------------------------- | --------------- | +| 贡献 | 通道的数据 schema + `security` 分级 | ✅ 注册时 | +| 参与 | 事件定义的 `mode` × `transport` × `security` | ✅ `define` 时 | +| 提供 | **ctx 能力面**(有没有那个字段) | ❌ 只能在评审时 | + +前两者能在代码里卡住;**提供关系只能靠评审卡在"要不要往 ctx 上加这个字段"这一步**。所以下面这条清单是硬性的。 + +## 新增 ctx 能力的评审清单 + +新增一个 ctx 字段等于**抬高所有插件的能力上界**,比新增一个通道严重得多。逐条回答: + +1. **能不能降级成贡献?** 插件交数据、宿主执行,通常能覆盖需求且不扩能力面。 +2. **该不该进首方能力门?** 涉及跨会话数据、外部网络、凭证、进程控制的,默认走 `PluginServerCapability` + `builtIn` 门,而不是无条件挂在 ctx 上。 +3. **凭证怎么给?** 插件**拿 ref 不拿明文**。凡是让插件直接接触 API key / token 的设计一律打回——这是 model provider seam 至今没开的主因(见 [provide.md](./provide.md#注册型-seam--缺失))。 +4. **失败怎么表现?** 不支持必须 fail loud,禁止 accepted-then-ignored。 +5. **卸载怎么回收?** 能力持有的连接、订阅、子进程必须挂进 `record.disposables`。 +6. **有没有绕过既有边界?** 尤其是路径边界与 loopback 限制——新能力不能成为它们的旁路。 + +## 什么不是保证 + +写规范、写文档、答疑时都不要给出以下承诺: + +- ❌ 插件之间互相隔离 —— 同 realm,只是命名不同 +- ❌ 恶意插件跑不了任意代码 —— 装上就等于授予了宿主进程的全部能力 +- ❌ `scope` 能防越权 —— 它是归属信息,不是认证 +- ❌ 超时/沙箱能兜住恶意行为 —— 超时只兜"慢",不兜"坏" +- ❌ 权限判定不可被绕过 —— `decide` 的单向收紧保证的是**遵守契约的插件**不会放宽权限(见 [participate.md](./participate.md#decide单向收紧)),不是不守契约的插件也绕不过 + +**真正的信任决策发生在安装那一刻。** 因此可见性是本模型的一部分:`/plugins` 必须能看出每个插件的 `sourceGroup`、声明的 capabilities、注册了哪些通道。这属于 `distribution.md`(待写)。 + +## 已知缺口 + +1. **安装期完整性校验未确认。** `services/plugins/marketplace*.ts` 中未见 checksum 或签名校验。需在写 `distribution.md` 前核实下载与解包路径,确认是缺失还是在别处。 +2. **首方能力门只覆盖 server。** client ctx 无对应分级,`sourceGroup` 在 client 侧未参与任何能力判定。 +3. **ErrorBoundary 缺失。** 不是安全边界但同源——插件抛错会掀掉宿主视图(RFC 0011 行动项 P0-3)。 +4. **上报器的 ctx 只有 `logger` 却有完整 Node 能力**,是"能力面收紧但没有实际约束力"的典型例证。RFC 0012 把参与关系收回 server 后这一层消失。