OpenWork 开源桌面应用的扩展清单:字段构成、服务端加载与四份内置示范

2026-08-04

本文基于 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 布尔与 originbuiltin / den / workspace / local)。文档里提到的一条策略正好卡在这两个字段上:内置策略只禁用受信任的内置扩展,不影响市场来的扩展。也就是说「能不能被管理员一键关掉」这件事,是从 source 推出来的,不是每个扩展自己写一遍。

对你的意义:如果你要给这类系统加第三方来源,先想清楚新来源在 trusted / origin 这两维上落在哪儿,而不是先想 UI 长什么样。

二、清单里写了什么:六块内容,块块对应一个运行时问题

apps/app/src/app/extensions.tsOpenWorkExtensionManifest 类型是这份清单的全部字段。除去 id / name / description / icon 这些门面,实质内容分六块:

resources 是「装了什么」。类型枚举铺得很宽:skillagentcommandtoolmcpopencode-pluginproviderhookcontextsecretfilelocal-servicenative-binary。每条资源可带 packageNameenvKeyproviderIdmcpServerNamecommand(一个字符串数组)以及 required。注意 localCommandRef 这个字段的取值只有两个字面量:openwork.computerUseMcpopenwork.uiMcp——这是留给桌面端把 MCP 启动命令替换成 Electron 本地路径的钩子,不是通用扩展点。

setup 是「装完还缺什么」。它只有 instructionsprimaryCtasecondaryCtarequiredEnvtestActionRef 五个可选字段。CTA 文案写在清单里,意味着设置页是通用渲染的,不为某个扩展写死按钮。

contributions 是「往哪些位置挂 UI 和路由」。类型同样是白名单:settings-panelsetup-instructionscomposer-promptsession-side-panelsession-rail-itemcontrol-actionsserver-routenative-capabilitytest-action,配合 location 指到 settings-detailcomposersession-right-panesession-railservernative。挂载点是有限集合,扩展不能自己新增位置。

lifecycle 是「装完要重载什么」。reload 取值来自 ReloadReasonpluginsskillsmcpconfigagentscommandsdetection 是一组形如 plugin:xxx / mcp:xxx / env:XXX / provider:xxx 的探测提示字符串。文件开头那行注释说明了这个类型的归属:重载词汇属于扩展清单契约的一部分,由这里拥有,types.ts 再转出去给应用其余部分用。

enablement 是「凭什么说它是活的」。这是六块里唯一带求值逻辑的:条件类型有 mcp-connectedplugin-loadedprovider-connectedenv-setpermission-grantedtoggle-enabled,每条带 ref 和给人看的 label。求值在 apps/app/src/app/enablement.tsevaluateEnablement 里,逻辑简单到一眼能读完:全部条件 met 才算 active,条件为空则直接返回 active: false。上下文对象 EnablementContext 的每个字段都是可选的,注释写明「缺上下文即视为条件未满足」——这是个保守默认,宁可显示未就绪。

六块之外还剩一个 composer.prompt,就是往输入框里塞的那句半截提示词(四份内置清单都填了,且都跟 composer-prompt 那条贡献里的 prompt 写成一模一样的字符串——同一句话存了两遍)。最后是几个开关位:previewdefaultEnableddefaultHiddenplatformdarwin / linux / windows / web),以及 schemaVersion: 1 这个被写死成字面量类型的版本位。

组成部分它负责什么仓库位置你什么时候会碰到它
清单类型与内置目录定义 OpenWorkExtensionManifest,并导出 BUILT_IN_OPENWORK_EXTENSION_MANIFESTSapps/app/src/app/extensions.ts想加一个内置扩展、或想知道某字段合法取值时
启用条件求值evaluateEnablementenablement 条件对着运行时上下文逐条判定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_ACTIONSOPENAI_IMAGE_GENERATION_EXTENSION_ACTIONSOPENWORK_CLOUD_UPLOAD_ACTIONS。第二,listExperimentalExtensionActionsextensionId 过滤,顺带做一层门控。第三,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.tsGET /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: falseerror: "use_openwork_cloud",并附上一段给模型看的引导词——googleWorkspaceConnectGuidance 里明确写着让模型把用户指向 Settings > Connect,不要指向 Settings > Extensions。把「该往哪个设置页引导」这种运营决策塞进返回体,是个很实际的做法,也意味着这段文案改一次就影响所有客户端。

四、内置样例各示范了一种写法

BUILT_IN_OPENWORK_EXTENSION_MANIFESTS 里躺着四份内置清单,它们不是四个功能,是四种模板。

OpenWork Browser 示范纯插件型:资源只有一条 opencode-plugin 类型、packageName 指向 opencode-chrome-devtools;贡献是设置面板加会话右侧面板加输入框提示词;enablement 只有一条 toggle-enabledlifecycle.reload["plugins", "agents"]detection["plugin:opencode-chrome-devtools"]。它 defaultEnabled: trueplatform 列了三个桌面系统。最简形态,没有密钥没有权限。

Computer Use 示范原生权限型,也是唯一 platform: ["darwin"] 的一份。它的资源有两条:一条 mcpmcpServerNamecomputer-usecommand 写着 ["npx", "-y", "@openwork/handsfree", "mcp"],同时挂 localCommandRef: "openwork.computerUseMcp";一条 native-binary 指向同一个包。enablement 三条全部要满足:MCP 连上、辅助功能权限、屏幕录制权限。setup.testActionRef 与贡献里的 test-action 用同一个 ref 串起来。这份清单诚实地把「要授辅助功能与屏幕录制两项 macOS 权限」写进了 setup.instructionsdescription 那句则直接把能力范围摊开说成语义可访问性引用、截图、后台安全点击、键盘输入几项,并标了 Mac only——一个能读屏、能后台点击、能敲键盘的组件,权限代价必须写在明处。它还带着 preview: true

Voice Mode 示范密钥型:资源是两条 secretOPENAI_REALTIME_API_KEY 非必需、OPENAI_API_KEY 必需)加一条 local-service,后者的 label 直说是 Realtime 客户端密钥的铸造服务。贡献里出现了整套清单里唯一的 server-route,ref 直接写成 POST /voice/realtime/sessionlifecycle.reload 只有 config

Ollama 示范本地服务加 provider 型。把跟本节相关的几块摘出来看(为便于阅读省去了 iconcomposersetup 和三条 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_MODEOPENWORK_GOOGLE_WORKSPACE_ALLOW_PLAINTEXT_VAULT 同时为 "1",落到 oauth.dev-plaintext.json。基线授权范围是 calendar.readonlygmail.composedrive.file 加身份信息;Gmail 读、完整 Drive、日历写、Chat 四组是可选特征,且只在你换成自己的 OAuth 客户端时才允许申请,否则抛 google_extra_scopes_require_custom_client。每个需要额外范围的动作入口都会调 requireScope 一族做二次校验,缺范围直接 403。

openai-image-generation.ts 是「产出文件」型的样板:密钥按 OPENWORK_OPENAI_IMAGE_API_KEYOPENAI_API_KEY → 同名进程环境变量的顺序回退,产物写进工作区的 artifacts/ 子目录,写之前用 resolveSafeChildPath 挡路径穿越,越界抛 invalid_pathcloud-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 开源」。至于能不能商用、能不能改、改完要不要带条款走,本文不提供法律意见,一律以 LICENSEee/LICENSE 两份原文为准。工程上你要留意的是:一旦接上组织侧,扩展条目的分组会由组织侧的就绪状态接管(extension-items.tsorgMcpConnectioncloudReadiness 的优先级高于本地安装状态),你在本地看到的状态不再完全由本地决定。

六、上手与避坑清单

**别去找扩展的加载器函数。**清单是编译进代码的常量数组,不是运行时扫目录读 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/appapps/server,一个描述装配,一个描述可调用动作。

**改动作数组时别忘了同步实现分支。**注册表和 if 分发是两处,只加数组项不加分支,线上表现是 501 而不是编译错误。会踩是因为 TypeScript 在这里帮不上忙;避法是加动作时同一个提交里把模块函数的 action === 分支一起补上,并跑一遍同目录的 .test.ts——google-workspace.test.tscloud-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 开源桌面应用的团队控制面到底管什么、边界在哪

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。