第 6 章 · 03 Canvas Extensions 扩展体系


第 6 章 · 03 Canvas Extensions 扩展体系

本节摘要:插件架第三层、也是最强的一层:Canvas Extensions——能改变 App 本身的扩展。Skills 改变 agent、MCP 给 agent 发工具,而 Canvas Extensions 注入 UI 组件、注册路由、添加功能面板:它扩展的不是驾驶舱里的飞行员,而是驾驶舱本身(给驾驶舱加仪表)。本节精读设计规格 specs/canvas-extensions.md:产品定义、六条核心决策(受信同域代码、安装与启用分离、热启用、后端持有扩展等)、canvas-extension.json 包契约与 activate 模块协议;再对照前端运行时源码(canvas-extensions-runtime.tsx 的激活签名与生命周期、Blob URL 模块加载器)与 docs/CANVAS_EXTENSIONS_TESTING.md 的手动测试流;最后以「三套体系分工总结」收束全章。
内容来源:specs/canvas-extensions.mdsrc/components/features/canvas-extensions/canvas-extensions-runtime.tsxsrc/extensions/canvas-extension-module-loader.tsdocs/CANVAS_EXTENSIONS_TESTING.mdsrc/api/canvas-extensions-service.ts
⚠️ 注意:Canvas Extensions 是运行第三方代码的体系,且 v1 刻意不做沙箱——没有 iframe、没有 worker、没有细粒度权限。这不是疏忽而是明示的信任模型,理解它才能理解规格里的每一条决策。

学习目标

  1. 复述产品定义:扩展改变的是 App 而非 agent。
  2. 逐条理解六条核心决策与信任披露。
  3. 读懂包契约(manifest+activate)与 Agent Server API 面。
  4. 描述前端运行时的激活流程、签名防抖与 Blob URL 加载。
  5. 用一张对照表说清三套插件体系的分工。

一、产品定义:改变 App 本身

specs/canvas-extensions.md 的开篇定义值得逐字读:

Canvas Extensions are installable packages that change Agent Canvas itself. They contribute UI and local product behavior such as routed pages, conversation panels, renderers, slots, and themes. Skills and plugins change the agent; Canvas Extensions change the app.

可安装、可改变 Canvas 本身、贡献路由页面/会话面板/渲染器/插槽/主题。规格同时确定了产品入口的命名:Customize 区域是 Skills、Plugins、MCP、Canvas Extensions 的唯一清单(inventory),条目名叫「Extensions」,「addon」只是非正式别名。这句话划出了与另外两套体系的楚河汉界:Skills 的作用域是提示词,MCP 的作用域是工具接口,而 Canvas Extensions 的作用域是这个 React 应用的运行时

二、六条核心决策精读

规格的 Decisions 一节是全文的宪法,逐条解读:

**决策一:当前活跃的 Agent Server 拥有扩展。**扩展安装在运行 Agent Server 的机器或容器上——源码、解析出的修订版、文件、manifest、启用状态全部存在那里;Canvas 只从当前活跃后端发现并加载扩展,切换后端就整体换掉扩展集。这与第 3 章「设置按后端存储」一脉相承:连「装了什么插件」都是后端属性。

决策二:扩展代码是受信的同域代码。没有 iframe、worker 沙箱或权限系统;启用后扩展拥有与 Canvas 代码同样的浏览器环境权限。Shadow DOM 以后可能作为可选的样式隔离提供,但永远不是安全边界。

决策三:安装与启用分离。安装永远产出禁用的扩展;agent 可以安装或更新扩展,但必须由用户回到 Customize → Extensions 显式启用。v1 中这是产品层面的同意不变量(consent invariant),不是对抗智能体的技术证明。

决策四:启用是热的。启用即刻加载激活、无需重启 App 或 Agent Server;禁用则卸载已注册界面并调用生命周期清理。但由于代码是受信同域 JS,清理是尽力而为——扩展制造的全局副作用宿主无法吊销。

**决策五:分发跟随插件,而非运行时。**安装坐标是 source(如 github:owner/repository)、可选 refrepo_path,由 Agent Server 解析并钉住修订版;backend-local 路径在后端机器上解释,绝不在前端进程里。

**决策六:更新保留启用状态。**刷新会原子地解析安装新修订版并保留先前的启用态;因为是受信代码模型,没有误导性的权限 diff 审批门。

配套的信任披露(trust disclosure)写得罕见地直白:启用前 Canvas 明说「此扩展能访问并修改 Canvas 页面、能以当前浏览器会话的身份发起已认证请求」;审查屏展示源、请求 ref、解析修订版、manifest 与贡献面,不展示虚构的细粒度权限。安装与更新是选择信任哪个代码修订版的动作,启用是真正执行它的时刻。

三、包契约与模块协议

manifest 文件名固定为 canvas-extension.json

{ "schema_version": 1, "name": "example-dashboard", "display_name": "Example dashboard", "version": "0.1.0", "description": "A backend-specific project dashboard.", "entrypoint": "dist/extension.js", "contributes": { "pages": [ { "id": "dashboard", "title": "Dashboard", "path": "/dashboard", "nav_label": "Dashboard" } ] } }

规则要点:name 与贡献 id 用小写字母数字连字符;页面 path 是绝对 kebab-case 路由,Canvas 挂载在 /extensions/{name} 之下;entrypoint 必须是一个自包含的浏览器 ESM bundle——不允许裸 import、不允许外部 chunk,依赖与 CSS 全部内联;后端校验 manifest、入口与资源路径都不得逃出安装根目录(防路径穿越)。v1 的模块协议只有一个导出:

export function activate(host: CanvasExtensionHost): void | (() => void) { return host.registerPage("dashboard", ({ container, path, navigate }) => { container.textContent = `Extension route: ${path}`; return () => container.replaceChildren(); }); }

activate 接收宿主 API(首版含 apiVersion: "1"、扩展与后端的不可变元数据、registerPage(id, mount)navigate(path)agentServer.request(...)——一个指向所属后端的已认证请求助手),返回可选的清理函数。注意「声明先行」:manifest 里没声明的页面 id,运行时注册会被拒(下节源码可见)。

Agent Server 的 API 面刻意镜像插件分发管理:GET/POST /api/canvas-extensions/installed(列表/安装,安装恒为禁用)、GET/PATCH/DELETE /api/canvas-extensions/installed/{name}(读取/改启用态/卸载)、GET .../bundle(以 JavaScript 文本返回入口)。后端必须拒绝安装请求里夹带 enabled: true;前端在 API 尚未上线时把 HTTP 404 视为「此后端不支持 Canvas Extensions」——且不得把「不支持」「连不上」「清单为空」折叠成同一个状态。

四、前端运行时源码:激活签名与 Blob 加载

src/components/features/canvas-extensions/canvas-extensions-runtime.tsx(265 行)是规格的前端落地。最精巧的是激活签名——防止引用抖动导致扩展反复卸载重装:

// canvas-extensions-runtime.tsx(节选) // `useActiveBackend` 每次渲染都可能合成新的 backend 对象,refetch 也会 // 产出内容相同的新数组。激活 effect 因此以这个值签名为键——后端身份 // 加已启用清单——当前对象从 ref 读取,引用抖动永远不会拆掉重激活扩展。 const activationSignature = React.useMemo( () => JSON.stringify({ backendId: active.backend.id, backendKind: active.backend.kind, connectionRevision: active.backend.connectionRevision ?? 0, orgId: active.orgId, extensions: enabledExtensions.map((extension) => ({ name: extension.name, version: extension.version, resolvedRef: extension.resolved_ref ?? null, pages: extension.manifest?.contributes?.pages ?? [], })), }), [/* deps */], );

激活流程严格对应规格:对每个已启用扩展——取回已认证的入口文本(CanvasExtensionsService.fetchBundle)→ 加载模块 → 构造宿主对象(其中 registerPage 先查 getDeclaredPage()未在 manifest 声明的页面直接抛错;重复注册同一 id 也抛错)→ 调 activate 收集清理函数。effect 的清理函数逆序执行全部 disposer 并清空页面注册;cancelled 标志保证竞态下(比如激活中途切换后端)已激活的扩展立即自我销毁。

模块加载器 src/extensions/canvas-extension-module-loader.ts 回答了「为什么用 Blob URL」:bundle 是经已认证 HTTP取回的文本,直接 <script src>import(backendUrl) 无法携带 X-Session-API-Key,所以只能先取文本、再从临时 Blob URL 动态 import:

// src/extensions/canvas-extension-module-loader.ts(节选) export async function loadCanvasExtensionModule(source: string): Promise<CanvasExtensionModule> { const blob = new Blob([source], { type: "text/javascript" }); const moduleUrl = URL.createObjectURL(blob); try { const imported: unknown = await import(/* @vite-ignore */ moduleUrl); assertCanvasExtensionModule(imported); // 必须导出 activate 函数 return imported; } finally { URL.revokeObjectURL(moduleUrl); } }

路由形态为 /extensions/{extension-name}/{declared-page-path},全部经 React Router 挂载(VITE_BASE_PATH 继续有效)。

五、测试:MSW mock 与 demo fixture

docs/CANVAS_EXTENSIONS_TESTING.md 描述了在后端端点落地前的前端测试路径:用 npm run dev:mock 起 MSW mock 模式(端口 3102,避开正常栈的 3001),浏览器内的 MSW 拦截扩展请求并以内存实现回应 API,同时经同一套前端服务与运行时提供 checked-in 的 demo 扩展包(src/fixtures/canvas-extensions/demo-page)。手动走查清单覆盖完整生命周期:以 src/fixtures/canvas-extensions/demo-page 为 source 安装→确认出现且为禁用态(安装不得执行 bundle、不得加导航项)→开启并接受受信代码确认→左侧导航出现「Extension demo」→直接访问嵌套路由→禁用即消失、再启用即回归(无需重启)→卸载清空。此外还有 mock-LLM 端到端 spec(tests/e2e/mock-llm/canvas-extensions/)驱动生产构建跑完 install→enable→page render→disable→uninstall 全链路,先经 Playwright 路由拦截提供 API 契约,后端上线后撤掉 stub 对真实后端重跑同一套步骤。规格的「Explicit non-goals for v1」同样值得抄录:不做沙箱隔离、不做安装期生命周期脚本、不允许 agent 自动启用、不做市场排名与签名、第一刀不含会话标签页/插槽/渲染器替换、不把 @openhands/extensions 当成可安装的运行时格式(它是构建期依赖,与本章第 1 节呼应)。

六、三套体系分工总结

全章收束成一张表——插件架的三层各管什么:

维度 Skills(第 1 节) MCP(第 2 节) Canvas Extensions(本节)
一句话 给 agent 技能(怎么做事) 给 agent 工具(能做什么) 给 App 扩展能力(界面有什么)
本体 Markdown 指令模板+资源 工具提供 server 自包含 ESM bundle+manifest
分发 @openhands/extensions 构建期打包 INTEGRATION_CATALOG 市场+自定义 git/后端本地路径,后端解析钉版
作用对象 agent 的提示词上下文 agent 的工具面 Canvas 的 UI 与路由
治理 允许/拒绝双清单 secret 凭据+连通体检 安装/启用分离+受信披露
运行时 命中任务注入 agent-server 连接 Canvas 浏览器内同域执行
安全模型 提示词资产治理 凭据脱敏与替换 受信同域代码,无沙箱

三者还有一个共同的母题:数据先行、宿主校验。Skills 目录与 MCP 市场来自同一个 extensions 包,Canvas Extensions 的 manifest 由后端校验路径与声明——宿主永远把外部数据当不可信输入(第 5 章清单准入同款思路)。至此,「驾驶舱」的三种进化方式齐了:换飞行员(ACP)、换飞行手册(Skills)、换仪表盘(Extensions)。

💡 驾驶舱要点:Canvas Extensions 的设计胆识在于承认信任模型的真相而不是假装有沙箱——「扩展拥有与宿主同等的权限,启用是你执行它的时刻」。于是工程重点从「隔离」转向「诚实」:信任披露直说能力、审查屏不造假权限、安装永远禁用、启用才执行。给扩展系统设计者的启示:可执行的同意流程比虚假的权限清单更安全。

本节要点回顾

  • 产品定义:Canvas Extensions 是改变 Agent Canvas 本身的可安装包(路由页面/会话面板/渲染器/插槽/主题);Skills 与 plugins 改变 agent,Extensions 改变 app。
  • 六条决策:后端持有扩展(切后端换扩展集)、受信同域代码(无沙箱、Shadow DOM 非安全边界)、安装与启用分离(agent 可装、人启用)、热启用(清理尽力而为)、分发按 git/本地路径由后端钉版、更新保留启用态。
  • 包契约:canvas-extension.json 声明 contributes.pages,入口必须是自包含 ESM bundle;模块只导出 activate(host),宿主 API 含 registerPagenavigateagentServer.request;未声明的注册会被拒。
  • 前端运行时:以「后端身份+启用清单」的 JSON 签名做激活键防引用抖动;bundle 经已认证 HTTP 取文本、Blob URL 动态 import(直连 URL 带不上会话 key);路由挂 /extensions/{name}/...
  • 测试:MSW mock+demo fixture 驱动完整生命周期走查,mock-LLM e2e 先以拦截提供契约、后端落地后同步骤对真跑。
  • 三套体系:Skills=脑(提示词资产)、MCP=手(工具面)、Extensions=脸(App 本身);共同母题是数据先行、宿主校验。

下一章:协议管线——@openhands/typescript-client 如何把「前端不直连后端」立成纪律。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U