OpenWork 开源桌面应用:把别人的 Agent 当引擎要补的工程课
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
OpenWork 把「谁来真正读写文件、跑命令、调模型」这件事整个外包了出去,它自己的服务端只干三件事:把外部 Agent 进程拉起来、为每个工作区解析出一套连得上的地址与凭据、再把配置裁剪成一份能带走的东西。 这三件事在 apps/server/src/ 那 138 个顶层 .ts 文件里各自对应一个很短的文件,加起来不到 250 行,却刚好把「拿别人的 Agent 当底座」要补的工程课划了个圈:进程生命周期、信任边界、可移植性。名字先说清楚:这里讲的 OpenWork 指 different-ai 开源的这个跨平台桌面应用项目(仓库 different-ai/openwork),既不是中文里泛指的「开放工作」,也跟同名的职场点评网站没有关系。
站内此前几篇的分工不同——开源终端 Agent 选型 解决的是选哪个,Agent 框架横向对比 和 Agent SDK 与框架之争 讨论的是抽象层怎么挑;这一篇不比选型,只看一个已经做完选择的项目,在「引擎不是我写的」这个前提下补了哪些活。
一、先看清楚它把什么交了出去
仓库 README 这样定位自己:OpenWork 是一个免费开源的桌面应用,用于分享 AI 工作流,是 Claude Cowork 与 Codex 在 macOS、Windows、Linux 上的开源替代。这是项目自己的说法,不是本文替它下的判断。它把重心押在「能力」的复用上:技能、插件、MCP 连接,以及 Google Workspace、Microsoft 365 这类外部服务,接一个 MCP 就能在 Codex、Claude Code、Cursor 里复用同一套东西。
那编码内核在哪?apps/server/src/managed-opencode.ts 里那行 spawn 讲得很直白:命令名默认取 opencode,参数是 serve --hostname ... --port ... --cors *。宿主没有自己实现会话循环和工具调用,它拉起的是外部引擎的 serve 模式,然后通过 HTTP 跟它对话。仓库里其它地方也就直接管这个子进程叫 engine。
许可证要一并看清:根目录 LICENSE 写明这是分层的——/ee 目录下的内容按 ee/LICENSE 定义的 Fair Source 许可证(该文件抬头是 Functional Source License, Version 1.1, MIT Future License),其余部分才是 MIT(Copyright 2026 Different AI)。ee/ 下有 10 个 apps 和 3 个 packages,README 里描述的团队控制面 OpenWork Den 那类企业能力落在这一侧。所以笼统说一句「MIT 开源」对整个仓库并不成立。能不能商用、能不能改,以许可证原文为准,本文不提供法律意见。
二、托管:把一个外部二进制变成可依赖的子进程
managed-opencode.ts 做的是最容易被低估的那部分。它没有 sleep,没有轮询端口,每一步都可解释。
端口不是硬编码的。findFreePortOnce 先 listen(0) 让内核分配一个再关掉,findFreePort 在这之上加了排除集合,最多重试 20 次,拿不到就抛错。调用方 embedded.ts 传进来的排除项是 [config.port],也就是宿主自己 HTTP 服务的端口——这一条防的是自己抢自己。
凭据是每次现生成的。username 和 password 各由两个 randomUUID() 去掉横杠拼成,通过 OPENCODE_SERVER_USERNAME 与 OPENCODE_SERVER_PASSWORD 两个环境变量注入子进程。走 env 而不是命令行参数,是因为参数会出现在进程列表里,同机上任何一个用户都读得到。
就绪判定靠解析 stdout。它累积 stdout 的输出,逐行找以 opencode server listening 开头的那一行,再用正则从里面抽出 http/https 地址;抽不出来立刻 fail,而不是接受一个半成品。超时默认 15000 毫秒。子进程如果中途 exit,会把此前累积的 stdout 与 stderr 一起塞进错误信息再 reject。超时、解析失败、提前退出,三条失败路径都被显式处理了——这正是很多人自己写 spawn 时漏掉的部分,一旦漏掉,表现就是启动阶段挂死没有任何线索。
它还准备了一份给人看的执行快照 execution:把注入的环境变量列成 name / value / redacted 三元组,凡名字命中 /(TOKEN|PASSWORD|USERNAME|AUTH|SECRET|KEY|CREDENTIAL)/i 的一律替成 <redacted>,再按名字排序。诊断面板默认脱敏,而不是先展示再想起来该遮。
关闭是两段式。close() 用一个 closePromise 做幂等,先 SIGTERM,最多等 1 秒;还没退就 SIGKILL,再等 500 毫秒。isAlive() 同时看 exitCode、signalCode 和 killed 三个信号,任何一个说明它死了就是死了。桌面应用退出时留下孤儿引擎进程,端口和凭据都还挂着——这段代码就是为了不让那件事发生。
三、连接:地址与凭据怎么落到每个工作区
opencode-connection.ts 全文只有两个导出函数,但它定义了整套连接语义。
resolveWorkspaceOpencodeConnection 的规则是工作区级覆盖优先:先看工作区上的 baseUrl、opencodeUsername、opencodePassword,为空再回落到全局配置里的对应项,每一项都先 trim。用户名和密码必须同时非空才会拼出 authHeader,格式是 Basic 加上 user:pass 的 base64。缺一个就干脆不带 Authorization 头,而不是发一截残缺凭据过去让服务端去猜。
inheritWorkspaceOpencodeConnection 是反过来的:新建工作区时从全局配置继承这三项,routes/workspaces.ts 里把它展开进新工作区记录。
托管起来之后,embedded.ts 会把托管引擎的 url、用户名、密码回写进配置和每一个工作区,但对两类工作区区别对待:workspaceType === "remote" 的用 ??= 只在空缺时填,本地工作区则直接覆盖。这个区别值得记住——远程工作区本来可能指向另一台机器上的引擎,无差别覆盖会把它打断。
真正体现工程成熟度的是信任边界。server.ts 里的 registerTrustedOpencodeProcess 把 pid:username:password 拼成一个 identity,进来就 hash 掉(原值从不外报),同时存下把 baseUrl 规范化到 /global/health 之后的 endpoint 以及 isAlive 回调。之后要用缓存的 MCP 注册结果去发一次带凭据的诊断探针时,会先确认这份受信身份还活着、endpoint 还对得上;进程一换代,引擎侧 MCP 的注册证据整个作废。注释把取舍写得很清楚:外部引擎(不是它自己拉起来的那个)照样能热同步,但拿不到 per-boot 身份,也就无权授权那次带凭据的探测。
同一份谨慎还体现在网络出口上。apps/server/src/server-fetch.ts 把 externalFetch(外部出网)和 loopbackFetch(127.0.0.1、localhost 与托管引擎流量)分成两条路,并且有一个 no-bare-fetch.test.ts 直接把 apps/server/src 下的裸 fetch 判为违规。宿主与引擎之间是本机回环,与外部服务之间是真出网,这两者的证书信任和超时策略本来就不该混用。这类边界思路和 MCP 授权加固 里讨论的问题是同一族。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 托管引擎进程 | 选端口、生成凭据、spawn、解析就绪 banner、两段式关闭 | apps/server/src/managed-opencode.ts | 引擎起不来、启动超时、退出后残留进程 |
| 启动装配 | 决定要不要托管、注入运行时配置与回指地址 | apps/server/src/cli.ts、apps/server/src/embedded.ts | 想嵌入自建程序,或调试托管开关 |
| 连接解析 | 工作区优先级、Basic 认证头拼装、新建工作区继承 | apps/server/src/opencode-connection.ts | 多工作区连错引擎、认证 401 |
| 运行时配置文件 | 把 agent 定义、插件、运行态 MCP 写成一份引擎会重读的文件 | apps/server/src/openwork-runtime-config.ts | 改了 MCP 却在引擎侧不生效 |
| 便携裁剪 | 白名单保留 9 个顶层键,其余一律丢弃 | apps/server/src/portable-opencode.ts | 导出/导入工作区,做模板分享 |
| 导出安全闸 | 密钥信号检测与三态取舍 | apps/server/src/workspace-export-safety.ts | 导出时被 409 拦下来要你做决定 |
| 客户端接入说明 | 远程 MCP 地址与 OAuth 流程 | packages/docs/model-context-protocol/opencode.mdx | 想在自己已有的 Agent 里接它 |
四、便携:能带走的只有白名单里那九个键
portable-opencode.ts 全文 27 行,值得逐字读。PORTABLE_OPENCODE_TOP_LEVEL_KEYS 列了九个键:agent、command、instructions、mcp、permission、plugin、share、tools、watcher。sanitizePortableOpencodeConfig 只按这个顺序抄这九个,其余一律不进结果,每个值还都走一遍 JSON.parse(JSON.stringify(...)) 深拷贝,避免调用方改到源对象。
这是白名单不是黑名单,差别很实在:引擎将来新增了什么顶层键,默认不会顺着分享包漏出去。同目录的 portable-opencode.test.ts 把边界钉死了——输入里的 model、provider(里面还嵌着 apiKey)、autoupdate 三项在输出里都消失了。模型选择和 provider 凭据属于「这台机器、这个人」的东西,不该跟着模板走。
再往上一层,server.ts 里的 exportWorkspace 才是完整链路:先读工作区的 opencode 配置,白名单裁剪一遍,再取模板配置、技能、命令、可移植文件;然后拿未裁剪的原始配置去跑 collectWorkspaceExportWarnings。这一步在 workspace-export-safety.ts 里,认三类信号:像密钥的键名(apiKey、token、secret、password、credentials、privateKey 这些)、像密钥的值(Bearer 串、JWT 三段式、常见令牌前缀),以及 .opencode/plugins/ 和 .opencode/tools/ 这两个前缀下可移植文件里的裸文本。
取舍是三态的:auto、include、exclude。默认 auto,一旦检出警告就直接抛 409 workspace_export_requires_decision,把决定权交回给人;选 exclude 走 stripSensitiveWorkspaceExportData 抹掉,选 include 就是你自己认了这份风险。默认不替用户做决定,这在导出功能里是少见的克制。这套思路和 API 密钥安全管理 讲的原则一致:不把「大概不会有密钥」当成默认假设。
五、边界与代价:这套设计放弃了什么
拿别人的 Agent 当底座,省下的是内核,付出的是下面这些。
能力上限就是引擎的上限。 工具集、权限模型、会话语义全部由外部引擎定义,宿主能加的只有配置注入和 MCP。引擎行为一变,宿主要跟着改,且改的是适配层不是产品层——这类返工不产生用户可见价值。
二进制必须在。 bin 参数为空时会 fallback 到裸命令名 opencode,靠 PATH 去找。这条依赖不在 npm 依赖图里,装包装不出来,只能由安装流程负责。仓库 packaging/ 下备了 aur、docker、helm 三种分发方式,本质上都在解同一个问题:怎么保证运行环境里那个二进制真的存在。
便携包刻意不完整。 模型与 provider 被裁掉了,换台机器导入之后要重配。这是对的取舍,但代价是「一键复现」并不成立,别把导出包当完整环境快照。
托管只挑一个工作区。 findManagedEngineWorkspace 取的是第一个非 remote 且 path 非空的工作区,运行时配置文件也只覆盖它。其余工作区的运行态 MCP 得靠 syncAllWorkspacesRuntimeMcpToEngine 事后补推,cli.ts 与 embedded.ts 的注释都写着这是 best-effort。多工作区场景下,MCP 一时看不见不等于配错了。
它明确不管的事。 不管模型服务商那侧的账单与配额(这类规则会调整,以官方最新说明为准);不管引擎自身的缺陷;不管远程工作区那台机器上的引擎归谁维护——resolveWorkspaceOpencodeConnection 只负责把地址和凭据拼对,拼对之后连过去是什么东西,它不判断。
装机与授权面要自己掂量。 这是一个跑在你机器上的桌面应用:它代管模型服务商的登录状态(managed-provider-auth.ts 走的正是同一套连接解析去跟引擎打交道),并引导你把 Gmail、Calendar、Drive 这类授权接到托管 MCP 上。packages/docs/model-context-protocol/opencode.mdx 里写明,OAuth 登录用的是 OpenWork 账号,且你选中的组织会被钉进 token;服务端只暴露 search_capabilities 与 execute_capability 两个工具,让客户端按名字搜索并执行,而不是把上百个工具定义灌进上下文。这个设计对上下文预算友好,但也意味着能力清单由服务端决定。README 还写着控制面可以设置桌面策略、限制本地模型访问、控制组织内可用的应用版本——这类能力天然意味着管理员那侧看得见、管得着。装之前先问清楚三件事:授权范围有多大,数据流经哪台服务器,企业侧能看到什么。真要隔离,参考 Agent 工作区隔离 的做法先把边界画出来。
六、上手与避坑清单
别把配置塞进环境变量当长期方案。 openwork-runtime-config.ts 顶部的注释记着一次真实返工:早先用 OPENCODE_CONFIG_CONTENT 把配置内容冻结在 spawn 那一刻,引擎每次实例重建都会把 MCP 状态回滚回旧值。改成写一份文件、用 OPENCODE_CONFIG 指过去之后,引擎在每次重建时重新读盘,配置才跟得上。避法:给子进程传路径不传内容,并在每次运行态写入后同步那份文件。
端口分配别忘了排除自己。 只调 findFreePort 而不传排除集合,宿主和引擎抢同一个端口是概率事件,复现率低、排查成本高。避法:把宿主自己的端口放进 excludedPorts,像 embedded.ts 那样。
就绪判定别用 sleep。 固定等 2 秒在你机器上够、在冷启动的 CI 上不够,于是变成偶发失败。避法:解析确定性的启动 banner,同时把超时和子进程提前 exit 都接成 reject。
诊断面板会顺手泄密。 把执行参数原样吐给用户看,凭据也就一起吐了。避法:像 execution 快照那样按名字模式判定并替换成占位符,默认遮蔽而不是默认展示。
覆盖工作区连接信息时分清 local 与 remote。 无差别覆盖会把用户手配的远程引擎地址抹掉,而且用户往往在报错之后才发现。避法:本地覆盖、远程用 ??= 只填空缺。
别以为白名单裁剪等于安全。 白名单保证的是结构可移植,不是内容无密钥——mcp 和 plugin 都在白名单里,而密钥恰恰最爱藏在这两处,workspace-export-safety.ts 里给 mcp、plugin、provider 三节单独写了警告文案就是这个原因。避法:拿原始配置再跑一遍密钥信号检测,有警告就停下来让人做选择。
关子进程一定要有兜底。 只发 SIGTERM 就当完事,遇到引擎卡在清理逻辑里就会留下孤儿进程,端口占着、凭据还在内存里。避法:SIGTERM 之后带超时,超时后 SIGKILL,再等一小段确认。
收尾
判断一个「宿主 + 外部 Agent 引擎」的项目做得实不实,不用看它的功能列表,看四个地方就够:进程起不来时错误信息里有没有子进程的输出;凭据是走 env 还是走命令行;导出功能默认是替你决定还是拦下来问你;关闭路径有没有 SIGKILL 兜底。OpenWork 这四处都写了,所以它的这三份代码可以当模板读。
想接着往下看,顺序建议是:apps/server/src/managed-opencode.ts 看进程怎么起来,apps/server/src/cli.ts 与 apps/server/src/embedded.ts 看它被谁装配、注入了哪几个环境变量,apps/server/src/opencode-connection.ts 看连接语义,最后 apps/server/src/portable-opencode.ts 配 apps/server/src/workspace-export-safety.ts 看什么东西被允许离开这台机器。仓库里另有 docs/ 20 份架构文档、packages/docs/ 57 份 mdx(其中 model-context-protocol/ 10 份是各家客户端的接入指南)、evals/ 26 份流程文档,需要更细的上下文时按目录去翻,比读二手描述准。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 开源桌面应用 OpenWork 的本地服务端:路由怎么分、桌面壳管什么 和 OpenWork 开源桌面应用的工作区模型:初始化建了什么、状态存在哪、导出如何拦住敏感文件。