OpenWork 开源桌面应用的扩展清单:字段构成、服务端加载与四份内置示范
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
**OpenWork 把「扩展」做成了一份纯声明式的清单对象,而这份清单本身不执行任何东西——它只描述「装了什么、缺什么、装完要重载什么」,真正干活的代码在另外三个地方,清单和执行体之间靠字符串 ref 对上。**理解这一点,你才不会在读它的扩展目录时到处找「扩展的入口函数」。它没有入口函数。
先做个消歧:这里说的 OpenWork 是 different-ai 在 GitHub 上开源的那个桌面应用项目,不是同名的职场点评站,也不是「开放式办公」这类泛指。仓库 README 这样定位自己:一个免费开源的桌面应用,用于分享 AI 工作流,是 Claude Cowork 与 Codex 在 macOS、Windows、Linux 上的开源替代品——这是项目自己的说法,本文只做转述,不替它背书。
这篇和站内几篇相邻文章的分工是:MCP 扩展框架的通用做法讲的是协议层面怎么组织扩展点,组件化 manifest 的写法拆的是另一套仓库的清单结构,跨平台扩展三体对比横着比几家的取向差异;本篇只钻一个仓库,把 OpenWork 这份清单的字段、服务端加载路径、四份内置样例逐个对着源码摊开。
一、它想解决的问题:让「来源」不再是用户要理解的概念
设计文档 docs/extensions-manifest-foundation.md 开头写得很直白:OpenWork 应该把 extension 作为面向用户的抽象,Claude/Anthropic 插件只是被适配进来的一种初始来源格式,不是另一个产品概念。
这句话决定了整个数据结构的形状。一个用户装了三样东西——从组织市场导入的插件、手动加的一个 MCP 服务器、内置的浏览器自动化——在设置页里应该长成同一种行,而不是三个互不相干的面板。所以清单的第一个字段就是 source,把来源退化成一个枚举值:
export type OpenWorkExtensionSourceFormat =
| "openwork-builtin"
| "openwork-extension-manifest"
| "claude-plugin"
| "opencode-plugin"
| "mcp-directory"
| "manual";
配套的 OpenWorkExtensionSource 还带 trusted 布尔与 origin(builtin / den / workspace / local)。文档里提到的一条策略正好卡在这两个字段上:内置策略只禁用受信任的内置扩展,不影响市场来的扩展。也就是说「能不能被管理员一键关掉」这件事,是从 source 推出来的,不是每个扩展自己写一遍。
对你的意义:如果你要给这类系统加第三方来源,先想清楚新来源在 trusted / origin 这两维上落在哪儿,而不是先想 UI 长什么样。
二、清单里写了什么:六块内容,块块对应一个运行时问题
apps/app/src/app/extensions.ts 里 OpenWorkExtensionManifest 类型是这份清单的全部字段。除去 id / name / description / icon 这些门面,实质内容分六块:
resources 是「装了什么」。类型枚举铺得很宽:skill、agent、command、tool、mcp、opencode-plugin、provider、hook、context、secret、file、local-service、native-binary。每条资源可带 packageName、envKey、providerId、mcpServerName、command(一个字符串数组)以及 required。注意 localCommandRef 这个字段的取值只有两个字面量:openwork.computerUseMcp 和 openwork.uiMcp——这是留给桌面端把 MCP 启动命令替换成 Electron 本地路径的钩子,不是通用扩展点。
setup 是「装完还缺什么」。它只有 instructions、primaryCta、secondaryCta、requiredEnv、testActionRef 五个可选字段。CTA 文案写在清单里,意味着设置页是通用渲染的,不为某个扩展写死按钮。
contributions 是「往哪些位置挂 UI 和路由」。类型同样是白名单:settings-panel、setup-instructions、composer-prompt、session-side-panel、session-rail-item、control-actions、server-route、native-capability、test-action,配合 location 指到 settings-detail、composer、session-right-pane、session-rail、server、native。挂载点是有限集合,扩展不能自己新增位置。
lifecycle 是「装完要重载什么」。reload 取值来自 ReloadReason:plugins、skills、mcp、config、agents、commands;detection 是一组形如 plugin:xxx / mcp:xxx / env:XXX / provider:xxx 的探测提示字符串。文件开头那行注释说明了这个类型的归属:重载词汇属于扩展清单契约的一部分,由这里拥有,types.ts 再转出去给应用其余部分用。
enablement 是「凭什么说它是活的」。这是六块里唯一带求值逻辑的:条件类型有 mcp-connected、plugin-loaded、provider-connected、env-set、permission-granted、toggle-enabled,每条带 ref 和给人看的 label。求值在 apps/app/src/app/enablement.ts 的 evaluateEnablement 里,逻辑简单到一眼能读完:全部条件 met 才算 active,条件为空则直接返回 active: false。上下文对象 EnablementContext 的每个字段都是可选的,注释写明「缺上下文即视为条件未满足」——这是个保守默认,宁可显示未就绪。
六块之外还剩一个 composer.prompt,就是往输入框里塞的那句半截提示词(四份内置清单都填了,且都跟 composer-prompt 那条贡献里的 prompt 写成一模一样的字符串——同一句话存了两遍)。最后是几个开关位:preview、defaultEnabled、defaultHidden、platform(darwin / linux / windows / web),以及 schemaVersion: 1 这个被写死成字面量类型的版本位。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 清单类型与内置目录 | 定义 OpenWorkExtensionManifest,并导出 BUILT_IN_OPENWORK_EXTENSION_MANIFESTS | apps/app/src/app/extensions.ts | 想加一个内置扩展、或想知道某字段合法取值时 |
| 启用条件求值 | evaluateEnablement 把 enablement 条件对着运行时上下文逐条判定 | apps/app/src/app/enablement.ts | 扩展一直显示未就绪,要查是哪一条没满足 |
| 设置页条目装配 | 把内置、市场、组织连接、MCP 目录、技能统一成 ExtensionItem 一种行 | apps/app/src/react-app/domains/settings/extension-items.ts | 排查扩展列表的分组与安装/就绪状态 |
| 服务端动作注册表 | 合并三组动作数组,按 extensionId + action 派发 | apps/server/src/extensions/index.ts | 新增一个服务端可调用的扩展动作 |
| 对外的两条路由 | 列动作与调动作的实验性 HTTP 端点 | apps/server/src/routes/core.ts | 从外部客户端调动作、排查 403/404 |
| 办公套件动作模块 | OAuth 授权、令牌金库、按 scope 拦截调用 | apps/server/src/extensions/google-workspace.ts | 接 Gmail/Drive/Calendar/Chat 时 |
| 图像生成动作模块 | 取密钥、调外部接口、把产物写进工作区 | apps/server/src/extensions/openai-image-generation.ts | 写一个「产出文件」型动作 |
| 云上传动作模块 | 把工作区文件直传出去,绕开模型上下文 | apps/server/src/extensions/cloud-uploads.ts | 需要传附件又不想把字节喂给模型 |
| 设计文档 | 说明抽象取舍与 PR 分层 | docs/extensions-manifest-foundation.md | 想搞清楚为什么这么设计 |
三、服务端这一侧:注册表数组 + 两条实验性路由
清单是渲染侧的事。服务端有另一套东西,两边靠 extensionId 这个字符串对上。
apps/server/src/extensions/index.ts 全文不到一百行,做的事就三件。第一,把三个模块导出的动作数组拼成一个常量 OPENWORK_EXPERIMENTAL_EXTENSION_ACTIONS,来源分别是 GOOGLE_WORKSPACE_EXTENSION_ACTIONS、OPENAI_IMAGE_GENERATION_EXTENSION_ACTIONS、OPENWORK_CLOUD_UPLOAD_ACTIONS。第二,listExperimentalExtensionActions 按 extensionId 过滤,顺带做一层门控。第三,callExperimentalExtensionAction 校验载荷、在数组里找到匹配项,然后一路 if 下去分发给对应模块。
这里有三个错误路径值得记住,因为你排查时看到的就是它们:载荷不是对象或缺 extensionId / action,抛 400 invalid_payload;在注册表里找不到,抛 404 extension_action_not_found;找得到但三个模块都返回 null,抛 501 extension_action_not_implemented,消息模板是「${registered.title} is registered but not implemented on openwork-server yet」。第三种情况说明这份注册表允许「先声明后实现」,注册表是契约,模块是实现,两者可以不同步。
对外暴露在 apps/server/src/routes/core.ts:GET /experimental/extensions/actions 返回动作列表,POST /experimental/extensions/call 执行。两条都注册在 client 这一层,且 call 路由开头就挡了一道——ctx.actor?.scope === "viewer" 时抛 403,消息是「Viewer tokens cannot call extension actions」。路径前缀是 experimental,这本身就是稳定性声明。
还有一层业务门控绕着 Google Workspace 转。shouldGateLegacyGoogleWorkspace 的判据只有一行:snapshot.connectCatalogEnabled && !snapshot.googleWorkspace.legacyConfigured。命中后,列表里这个扩展只剩 status 一个动作,其余动作调用会返回 ok: false 与 error: "use_openwork_cloud",并附上一段给模型看的引导词——googleWorkspaceConnectGuidance 里明确写着让模型把用户指向 Settings > Connect,不要指向 Settings > Extensions。把「该往哪个设置页引导」这种运营决策塞进返回体,是个很实际的做法,也意味着这段文案改一次就影响所有客户端。
四、内置样例各示范了一种写法
BUILT_IN_OPENWORK_EXTENSION_MANIFESTS 里躺着四份内置清单,它们不是四个功能,是四种模板。
OpenWork Browser 示范纯插件型:资源只有一条 opencode-plugin 类型、packageName 指向 opencode-chrome-devtools;贡献是设置面板加会话右侧面板加输入框提示词;enablement 只有一条 toggle-enabled;lifecycle.reload 是 ["plugins", "agents"],detection 是 ["plugin:opencode-chrome-devtools"]。它 defaultEnabled: true,platform 列了三个桌面系统。最简形态,没有密钥没有权限。
Computer Use 示范原生权限型,也是唯一 platform: ["darwin"] 的一份。它的资源有两条:一条 mcp,mcpServerName 是 computer-use,command 写着 ["npx", "-y", "@openwork/handsfree", "mcp"],同时挂 localCommandRef: "openwork.computerUseMcp";一条 native-binary 指向同一个包。enablement 三条全部要满足:MCP 连上、辅助功能权限、屏幕录制权限。setup.testActionRef 与贡献里的 test-action 用同一个 ref 串起来。这份清单诚实地把「要授辅助功能与屏幕录制两项 macOS 权限」写进了 setup.instructions,description 那句则直接把能力范围摊开说成语义可访问性引用、截图、后台安全点击、键盘输入几项,并标了 Mac only——一个能读屏、能后台点击、能敲键盘的组件,权限代价必须写在明处。它还带着 preview: true。
Voice Mode 示范密钥型:资源是两条 secret(OPENAI_REALTIME_API_KEY 非必需、OPENAI_API_KEY 必需)加一条 local-service,后者的 label 直说是 Realtime 客户端密钥的铸造服务。贡献里出现了整套清单里唯一的 server-route,ref 直接写成 POST /voice/realtime/session。lifecycle.reload 只有 config。
Ollama 示范本地服务加 provider 型。把跟本节相关的几块摘出来看(为便于阅读省去了 icon、composer、setup 和三条 contributions,原文在 extensions.ts 数组的最后一项):
{
schemaVersion: 1,
id: "ollama",
name: "Ollama",
description: "Local model provider at http://localhost:11434.",
source: { format: "openwork-builtin", origin: "builtin", trusted: true },
resources: [
{ type: "local-service", id: "ollama-api", label: "Ollama API", description: "http://localhost:11434", required: true },
{ type: "provider", id: "ollama", providerId: "ollama", packageName: "@ai-sdk/openai-compatible", required: true },
],
enablement: [
{ type: "provider-connected", ref: "ollama", label: "Ollama provider" },
],
lifecycle: { reload: ["config"], detection: ["provider:ollama"] },
}
服务端那三个动作模块是另一组示范。google-workspace.ts 是最重的一份:OAuth 走授权码加 PKCE,回调服务器临时监听在 127.0.0.1 的随机端口上,令牌用 aes-256-gcm 加密后落进配置目录下的 extensions/google-workspace/oauth.vault,密钥取自 OPENWORK_ENCRYPTION_KEY 或本地 vault-key 文件(写文件时显式 mode: 0o600)。它还有个明文金库模式,条件是 OPENWORK_DEV_MODE 与 OPENWORK_GOOGLE_WORKSPACE_ALLOW_PLAINTEXT_VAULT 同时为 "1",落到 oauth.dev-plaintext.json。基线授权范围是 calendar.readonly、gmail.compose、drive.file 加身份信息;Gmail 读、完整 Drive、日历写、Chat 四组是可选特征,且只在你换成自己的 OAuth 客户端时才允许申请,否则抛 google_extra_scopes_require_custom_client。每个需要额外范围的动作入口都会调 requireScope 一族做二次校验,缺范围直接 403。
openai-image-generation.ts 是「产出文件」型的样板:密钥按 OPENWORK_OPENAI_IMAGE_API_KEY → OPENAI_API_KEY → 同名进程环境变量的顺序回退,产物写进工作区的 artifacts/ 子目录,写之前用 resolveSafeChildPath 挡路径穿越,越界抛 invalid_path。cloud-uploads.ts 则示范了另一种取向:它的两个动作描述里明写「outside model context」,也就是文件字节根本不进模型上下文,只在服务端流过去。这三份的共性是,动作的 inputSchema 都是手写的 JSON Schema 对象,additionalProperties: false 一路带到嵌套层。
五、边界与代价:这套设计放弃了什么
**清单不是沙箱。**它描述的是「需要什么」,不是「只能做什么」。一份清单声明了 native-binary 和两项 macOS 权限,运行时该组件拿到的仍是完整的系统能力;enablement 只用来算 UI 上那个「是否活跃」的状态,不是运行时的权限检查点。真正的边界在别处:工作区根目录白名单、scope 校验、viewer 令牌拦截。别把清单当访问控制模型读。
**凭据是集中保管的。**Google 的刷新令牌、OpenAI 的 key、Realtime 的密钥,都汇到同一台机器的同一个配置目录下。加密金库、0o600 权限、断开时调 Google 的 revoke 端点,这些该做的它都做了,但暴露面的性质变了:以前泄一个 key 只丢一个服务,现在丢的是一台机器上的一整套授权。授出去的范围也要看清——gmail.compose 意味着它能替你建草稿(代码里反复强调不发送、返回 draftUrl 让你自己在 Gmail 里确认),完整 Drive 范围意味着搜索会覆盖整个云盘。这些判断建议对着最小权限设计和 API 密钥安全管理那两篇的检查项过一遍再决定开哪几项。
**它明确不管的事。**清单不管版本兼容——schemaVersion 定死成字面量 1,没有迁移逻辑;不管依赖解析——packageName 只是个字符串,谁去装、装哪个版本不在清单里;不管跨扩展冲突——两个扩展声明同名 MCP 服务器时清单层没有裁决机制;不管动作实现是否存在——注册了没实现就是 501。贡献点是白名单,你想挂个新位置就得改类型定义,不是配置能解决的。
**企业侧能看到什么,取决于你连不连组织。**这里必须点明许可证是分层的:根 LICENSE 开头就把仓库拆成三段——/ee 目录下的内容按 ee/LICENSE 里定义的那份许可证发布(根 LICENSE 把它括注为 Fair Source License,而 ee/LICENSE 原文的标题是 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT),并入仓库的第三方组件各随其原始许可证,剩下的部分才是 MIT(Copyright 2026 Different AI)。也就是说组织插件、组织市场、管理端这些「团队控制面」相关的代码并不都在 MIT 那一侧,不能笼统说成「MIT 开源」。至于能不能商用、能不能改、改完要不要带条款走,本文不提供法律意见,一律以 LICENSE 与 ee/LICENSE 两份原文为准。工程上你要留意的是:一旦接上组织侧,扩展条目的分组会由组织侧的就绪状态接管(extension-items.ts 里 orgMcpConnection 与 cloudReadiness 的优先级高于本地安装状态),你在本地看到的状态不再完全由本地决定。
六、上手与避坑清单
**别去找扩展的加载器函数。**清单是编译进代码的常量数组,不是运行时扫目录读 JSON。会踩是因为「manifest」这个词让人默认有个 loader;避法是直接从 BUILT_IN_OPENWORK_EXTENSION_MANIFESTS 这个导出反查引用,apps/app/src/app/constants.ts 里那次 .map(extensionManifestToDirectoryInfo) 就是它进入界面的唯一路口。
**别把渲染侧的 extensionId 和服务端动作的 extensionId 当成一套。**四份内置清单的 id 是 openwork-browser / computer-use / openwork-voice / ollama,服务端注册表里的 id 是 google-workspace / openai-image-generation / openwork-cloud-uploads,两组不重叠。会踩是因为字段同名让人以为是同一个注册表;避法是记住它们分别属于 apps/app 与 apps/server,一个描述装配,一个描述可调用动作。
**改动作数组时别忘了同步实现分支。**注册表和 if 分发是两处,只加数组项不加分支,线上表现是 501 而不是编译错误。会踩是因为 TypeScript 在这里帮不上忙;避法是加动作时同一个提交里把模块函数的 action === 分支一起补上,并跑一遍同目录的 .test.ts——google-workspace.test.ts 与 cloud-uploads.test.ts 都在。
**别拿默认 OAuth 客户端去测扩展范围。**代码里 customClient 的判据是 clientId 不等于内置那个桌面客户端 id,只要你没换,四组可选特征一律被拒。会踩是因为报错发生在发起授权那一步,看起来像配置没生效;避法是先确认 OPENWORK_GOOGLE_WORKSPACE_OAUTH_CLIENT_ID 换成了自己的值。
**别在开发机上顺手打开明文金库。**两个环境变量同时为 "1" 就会把刷新令牌以明文 JSON 落盘。会踩是因为调试时想直接看令牌内容;避法是把这两个变量当成一次性开关,用完立刻取消并删掉 oauth.dev-plaintext.json,不要写进任何常驻的 env 文件。
**别指望 enablement 告诉你「为什么不能用」的全部原因。**它只判定清单里列出的条件;服务端那层门控(比如 Connect 开着但传统配置缺失)不在这套条件里,表现是界面显示正常但调用返回 use_openwork_cloud。会踩是因为两层判断分居两端;避法是排查时两头都看,先 GET /experimental/extensions/actions 看动作还剩几个。
收束:三步自检
要在这套机制上动手,按这个顺序读文件最省时间:先 apps/app/src/app/extensions.ts 把类型和四份内置样例看完,你就知道清单能表达什么;再 apps/server/src/extensions/index.ts 看派发,你就知道一次调用怎么落到具体模块;最后挑一个服务端模块精读,办公套件那份最全,图像生成那份最短。
动手前问自己三句:我要加的东西是「装配声明」还是「可调用动作」,前者进清单、后者进注册表;它需要的凭据与授权范围,是不是每一项都有人会为它签字;以及,它在 /ee 之外吗——这决定了你要看哪一份许可证原文。想把这套机制跟 MCP 那层的授权加固对起来看,可以接着读MCP 授权加固。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 能力市场架构:开源桌面应用如何把技能发布并指派到人 和 OpenWork 开源桌面应用的团队控制面到底管什么、边界在哪。