OpenWork 能力市场架构:开源桌面应用如何把技能发布并指派到人

2026-08-04

本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。

把能力做成可分发的东西,卡点从来不是写那份技能文件,而是「一份内容从被发布出来,到落在某个具体的人手上,中间要经过多少层可以把它拦下来的检查」。 OpenWork(这里指 different-ai 的这个开源桌面应用项目,与同名的职场点评网站、以及泛指的「开放工作」无关)在 docs/marketplace-capabilities-architecture.md 里把这几层一层层写了出来,而且每一层都能在代码里找到对应文件。这套抽象比它具体的产品形态更值得单独读一遍。

一、它要解掉的那个具体麻烦

在这套设计之前,组织里共享一份技能的做法是拷文件:桌面端把内容复制进工作区的 .opencode/ 目录。apps/server/src/cloud-plugins.ts 里能看到这套映射的全貌,不同类型落到不同子目录:

.opencode/skills/<namespace>/<name>/SKILL.md
.opencode/agents/<namespace>/<name>.md
.opencode/commands/<namespace>/<name>.md
.opencode/mcps/<namespace>/<name>.json
.opencode/tools/<namespace>/<name>.ts

拷贝式分发的问题就那几个,用过的人都遇到过:版本会漂、谁装了什么没人知道、撤回一份内容要挨台机器去删。架构文档给出的北极星是把这件事翻过来——每个发布到组织市场的插件自动可以被 search_capabilities 搜到、经 execute_capability 调用,而「安装」降级成离线使用和版本固定的优化项,不再是用起来的前提。

文档把这条通道叫 rail,市场是它的第四个能力来源,前三个分别是 Den 的 REST 目录(原生 provider 能力也是打了 Capability Sources 标签的普通 REST 路由)、External MCP Connections,以及原生 provider 能力。加了第四个来源,对外暴露的工具面一个都没多:

harness
  └─ openwork-cloud /mcp/agent
       ├─ REST catalog (incl. native provider capability routes)
       ├─ External MCP Connections
       └─ Marketplace plugin capabilities   ← new, DB-only

这一点是整篇设计的骨架。仓库 README 对外也是这么讲的:它只暴露两个工具,search_capabilities 负责找,execute_capability 负责跑。新增内容来源不等于新增工具,这个约束一旦立住,后面所有的类型差异就只能用返回值里的字段去表达,而不能靠再加一个工具糊过去。

顺带说清本篇和站内几篇相近文章的分工:MCP 官方注册表怎么用讲的是公共生态里怎么发现一个 server,MCP server 能力卡片怎么写讲的是单个 server 对外描述自己的写法,技能组件化与 manifest讲的是把一份技能拆成可组合清单的做法;本篇只管一件事——组织内部把内容发布出去、授权下去、指派到人的那条分发链路。

二、一份能力要过的几层

先把层次摆平。下面这张表里的仓库位置都是实际读过的文件,你可以拿着路径直接去核。

组成部分它负责什么对应仓库位置你什么时候会碰到它
配置对象与版本表存一份内容的身份、类型、状态,以及独立成行的版本内容ee/packages/den-db/src/schema/sharables/plugin-arch.ts排查「对象在但内容没同步」
插件与市场归属把配置对象归进插件、把插件挂进市场同上(PluginConfigObjectTableMarketplacePluginTable决定分发粒度时
三级授权表市场级 / 插件级 / 对象级的授权,向下级联同上(MarketplaceAccessGrantTable 等三张)决定「谁能看到」时
授权解析纯函数由 grants + memberId + teamIds 算出角色ee/apps/den-api/src/routes/org/plugin-system/access.ts调试某人为什么搜不到
市场能力模块搜索、执行、命名、状态判定的主体ee/apps/den-api/src/mcp/marketplace-capabilities.ts想弄懂返回值字段含义时
rail 合并点把市场结果和其它来源按分数交错,并分派执行ee/apps/den-api/src/mcp/agent.ts结果排序不符合预期时
打分工具tokenize / scoreText,多来源共用一套ee/apps/den-api/src/mcp/search.ts调标题和描述的措辞时
桌面安装通道把云端内容落成 .opencode/ 下的文件apps/server/src/cloud-plugins.ts需要离线或固定版本时
插件包解析解析插件目录,遇到不支持的部分产生告警apps/server/src/claude-plugin-bundle.ts导入的插件被跳过了某些内容
桌面策略与提示卡给不同团队、不同成员推不同的建议提示packages/docs/cloud/share-with-your-team/team-prompt-cards.mdx改了配置但成员看不到

整个仓库是个 monorepo:apps/ 下 4 个应用、packages/ 下 12 个包,控制面这一侧另有 ee/apps/ 10 个与 ee/packages/ 3 个;架构文档集中在 docs/(20 份 md),流程验证在 evals/(26 份流程 md),面向使用者的文档在 packages/docs/(57 份 mdx,其中 model-context-protocol/ 有 10 份客户端接入指南)。分发方式在 packaging/ 下给了三种。全仓受版本控制的文件是 3490 个,本地服务端 apps/server/src/ 顶层就有 138 个 .ts——这不是个小玩具,读之前先有心理准备。

三、类型决定执行方式,而不是执行方式决定类型

配置对象的类型是一个封闭枚举,就在 schema 文件的开头:

export const configObjectTypeValues = ["skill", "agent", "command", "tool", "mcp", "hook", "context", "custom"] as const

八种类型不是八条执行路径,而是收敛成三种模式。这段 switch 是整套抽象里最值得抄走的部分:

export function marketplaceConfigObjectExecutionMode(objectType: ConfigObjectType): MarketplaceConfigObjectExecutionMode {
  switch (objectType) {
    case "mcp":
      return "mcp"
    case "agent":
    case "command":
    case "context":
    case "custom":
    case "skill":
      return "instructional"
    case "hook":
    case "tool":
      return "desktop_only"
  }
}

instructional 这一档占了五种类型,执行的含义就是把最新版本的原始文本连同来源信息回给调用方。文档把这件事说得很直白:这是把本地技能的渐进披露推广到组织范围,零安装。返回的载荷里带一句来源框定,句式是「Content from marketplace plugin ⟨插件名⟩ in your organization’s library.」,让 agent 在用这段文字之前先知道它是从哪来的。

command 属于 instructional 但多一步:它接受 body 里的 arguments,把模板里的 $ARGUMENTS 替换掉再返回渲染结果。服务端不执行任何命令,只是替换文本。这个区分很关键——命令在这套体系里是「带参数的指令性内容」,不是「远程执行入口」。

mcp 类型返回声明的 server 规格加上状态与提示。如果组织里已经存在指向同一个 URL 的 External MCP Connection,提示会引导你去搜那个连接的工具;否则提示管理员去添加,或者你自己本地装。第一阶段不做自动开通。

toolhook 落在 desktop_only。tool 会返回源码并带 needs_install,提示里点名是哪个插件、哪个市场;hook 更彻底——apps/server/src/claude-plugin-bundle.ts 里那句告警写得很清楚,插件声明的 hooks 不被支持,会被跳过。搜得到、执行不了,这是设计里承认的现状,不是 bug。

命名规则同样简洁。市场能力的名字长这样:

export function buildMarketplaceCapabilityName(pluginId: string, configObjectId: string): string {
  return `${MARKETPLACE_CAPABILITY_PREFIX}${pluginId}:${configObjectId}`
}

前缀是 plugin:,整体形如 plugin:<pluginId>:<configObjectId>,对照的是外部连接那边的 mcp:<connectionId>:<toolName>。文档特意点出这些名字是搜索结果里的数据、执行参数里的数据,不是注册出去的 MCP 工具名,所以工具名长度限制管不到它。搜索结果里其余几个字段也是固定的:method 恒为 "PLUGIN"path 形如 plugin://<插件 slug>/<相对路径>,路径参数和查询参数都是空数组,hasBody 只有 command 为真。摘要的前缀是 [市场名 / 插件名] 标题,插件没有归进市场时退化成 [插件名] 标题,后面有描述才追加冒号和描述——这就是你在同名内容里分辨来源的唯一线索。

四、可见性:授权怎么落到某个具体的人

一个成员能看到的集合,是三条路径的并集:直接授在配置对象上的、授在插件上向下级联的、授在市场上向下级联两级的。授权目标只有三种——整个组织、某个成员、某个团队;角色是 viewer | editor | manager 三档,搜索和执行有 viewer 就够。

实现上有个细节挺能说明工程判断力。架构文档明确要求用纯函数 resolvePluginArchGrantRole({grants, memberId, teamIds}),而不能用同文件里那个和 HTTP 上下文耦合的 resolvePluginArchResourceRole,理由是 /mcp/agent 这条路上手里只有一个 McpMemberIdentity(成员 id 加团队 id 列表),根本没有路由会话上下文。代码里也确实是这么调的。把授权判定切成「取数据」和「算角色」两截,是这类多入口系统里少数几个能长期省事的决定。

状态过滤是严格的:市场、插件、配置对象三者都得是 active,两张关联表的记录都不能是已移除。组织管理员按成员表里的角色和 owner 标记解析,能看到全部有效对象。

组织级还有一个总开关,键在组织元数据里:

{ "capabilities": { "mcpConnections": false } }

关掉之后的行为写得很克制:搜索侧市场结果并入的是空集,执行侧返回 unknown_capability。文档管这叫「与不存在字节一致」——退出该功能的组织和从来没有这份内容的组织,从调用方视角看不出任何差别。这是个好习惯,因为任何可探测的差异都是一条信息泄露侧信道。

还有一层不是能力本身、但直接影响「成员到底看到什么」的东西:桌面策略里的组织提示卡。这块的合并规则在 packages/docs/cloud/share-with-your-team/team-prompt-cards.mdx 里写得比代码还清楚——布尔类开关跨所有匹配的策略合并,任一为 true 就赢;提示卡不合并,桌面只取优先级最高的那条匹配策略的提示集,并列时取创建最早的,再并列取策略 id 最小的。成员通过 GET /v1/me/desktop-config 拿更新,桌面先加载缓存、在云端会话或设置变更时刷新、此外每小时轮询一次。管理侧还有几条硬限制:默认策略只能是组织范围,要指派给成员或团队就得另建一条非默认策略;提示正文和描述各有字符数上限,具体数值以文档为准;管理员填的描述会变成卡片标题、提示正文会变成卡片内容并被插入输入框,点卡片只填充输入框,不会自动发出任务。

五、边界与代价

这套设计放弃了不少东西,而且放弃得相当自觉。

它不跑任何东西。 instructional 那五种类型的执行结果就是一段带来源标注的文本;tool 停在需要安装;hook 连执行路径都没有。文档里把沙箱执行明确列为保留选项,两个候选方案都还开着,没有选定。所以如果你指望「发布一个工具,全组织立刻能远程调用」,这条路现在走不通。

去重是做不到的。 Den 侧不知道某台桌面往 .opencode/ 里拷过什么,第一阶段直接把去重推迟了,替代方案是靠来源信息让重复项可分辨。结果就是本地装过一份、云端又能搜到一份时,你会在结果里看到两条,得自己看摘要前缀分辨。

搜索是纯数据库的,代价是新鲜度。 这条路径不发任何网络请求,靠的是预先派生的搜索文本(标题、描述、原始内容拼起来)。对比之下,外部 MCP 连接那侧的搜索可能真的去调远端的 tools/list。省了网络就省不了同步——content_not_synced 在种子目录、内容随后才通过 GitHub 连接器到位的场景里是常态而不是异常,执行时返回的是这个状态加一句去连接或同步来源的提示。

指令性内容天然是提示注入面。 文档第 9 节自己承认了这一点:市场内容是组织策展的,但仍然是第三方文本。给出的缓解手段是三条——每份载荷都带来源框定、桌面端本来就不自动打开工具输出里的 URL、授权严格且由管理员控制发布。这是缓解,不是消除。你把组织内任意成员能发布的文本接进 agent 的执行回路,就得按不可信输入对待,MCP 授权加固那套思路在这里同样适用。

凭据集中带来的暴露面要自己算。 设计上要求配置对象里不放任何密钥,连接机制是唯一的凭据存储;加密内容只在服务端内部解密。但反过来说,这类产品的整体形态就是在你机器上装一个桌面应用,代管模型凭据,并持有到办公套件与各类第三方服务的授权。组织控制面这一侧能看到哪些连接存在、谁完成了逐成员授权、谁被授予了哪些能力。这些不是缺陷,是这类架构的固有代价,接入前该问清楚的是:授权范围有多大、数据流向哪里、离职或撤权时清理链路是否完整。

许可证是分层的,别笼统说成 MIT。 仓库根 LICENSE 写得明白:/ee 目录下的全部内容按 ee/LICENSE 定义的 Fair Source 许可证(文件里是 FSL-1.1-MIT,Copyright 2026 Different AI Inc),此外的部分才是 MIT(Copyright 2026 Different AI)。本文拆的这套市场能力实现、数据库 schema、授权解析,全都在 /ee 之下。能不能商用、能不能改、改了能不能对外提供服务,一律以许可证原文为准,本文不提供法律意见。

文档里的后续阶段只是文档里这么写。 那份架构文档给了第二、第三阶段的设想——把 mcp 声明真正桥接成连接、编译出版本化的能力记录、加上沙箱执行与本地通道对齐。这些是维护者写在文档里的规划,不是已经存在的行为,也不该被当成承诺来引用。

六、上手与避坑清单

一、以为「发布了」就等于「能用了」。 会踩是因为配置对象和它的版本是分开的两张表,对象建好而最新版本还没到位是完全合法的中间态。避法:发布后立刻用搜索跑一次、执行跑一次,看返回里有没有 content_not_synced,而不是看后台列表里对象是否存在。

二、把 tool 或 hook 当成云端能跑的能力。 会踩是因为它们在管理界面上和 skill 长得一样,都是一条可以发布、可以授权的记录,只有到执行那一刻才暴露出是 desktop_only。避法:分发之前先按类型枚举归类,凡是 tool 和 hook,规划的就是桌面安装路径,别写进「组织内即开即用」的说明里。

三、授权粒度选错,人还是搜不到。 会踩是因为三张授权表级联方向是市场向下到插件、插件向下到配置对象,反过来不成立——只授了某个配置对象,成员看不到同插件里的其它内容。避法:先想清楚你要按市场、插件还是单个对象来分发,再选对应的表;只要能搜能执行,viewer 就够,别顺手给 manager。

四、改了提示卡,成员那边没变。 会踩有两个原因叠在一起:桌面先用缓存、每小时才轮询一次;而且提示卡不合并,赢家只有优先级最高的那条匹配策略。避法:先重载或切换一次云端组织排除缓存因素,再回头检查是不是另一条更高优先级的策略把你的覆盖掉了;并列的情况按创建时间和策略 id 定胜负。

五、把组织退出该功能当成故障排查。 会踩是因为关掉之后的返回值刻意做成了和「不存在」完全一致,你在日志里只会看到 unknown_capability,看不出是权限、是拼写错误还是开关。避法:排查这条链路的第一步固定为确认组织元数据里的那个开关,再往下查授权和状态过滤。

六、把能力名当成 MCP 工具名去处理。 会踩是因为 plugin: 开头的那串看起来太像工具名了,有人会去截断它、规范化它,或者按工具名长度限制去改。避法:把搜索返回的 name 当成不透明字符串,原样回传给执行调用,不做任何加工。

七、同一份内容在结果里出现两次就以为是数据脏了。 会踩是因为本地装过的副本和云端可见的原件确实会同时出现,而服务端这一侧现在不做去重。避法:看摘要前缀里的市场名和插件名来分辨,别急着去删数据。

收个尾

这套抽象真正值钱的地方,是它把「分发一份能力」拆成了几个互不越界的问题:内容和版本归存储管,归属关系归关联表管,谁能看见归授权管,怎么执行归类型枚举管,工具面稳定归命名协议管。任何一层出问题,你都能定位到具体的一张表或一个函数,而不用在一坨「插件系统」里大海捞针。你自己做内部能力分发时,哪怕不用这个项目,这个切法也值得照抄。

给你一份自检清单,四个问题答得上来才算真的想清楚了:一份内容从写完到某个人能用,中间有几次可以被拒绝?被拒绝时调用方看到的信息,会不会泄露它本不该知道的存在性?内容还没同步到位时,系统给的是错误还是一个诚实的中间状态?以及——这份内容进入 agent 上下文之前,来源标注在哪里?

接着往下读的话,顺序建议是:先看 docs/marketplace-capabilities-architecture.md 把意图吃透,再读 ee/apps/den-api/src/mcp/marketplace-capabilities.ts 对照实现,然后翻 ee/packages/den-db/src/schema/sharables/plugin-arch.ts 确认数据形状,最后用 apps/server/src/cloud-plugins.ts 补上本地那半截。想搞明白技能在不同体系里的组织方式差异,可以横向对照技能机制的三体对比一起看。

本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用的远程 MCP 授权:文档、核验报告与测试OpenWork 开源桌面应用的扩展清单:字段构成、服务端加载与四份内置示范

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