读 OpenWork 开源桌面应用源码:主进程、预加载、运行时的职责边界
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
如果你以为桌面壳只是给网页套一个窗口,那你会低估它至少三件事:它要给子进程凑出一份能用的 PATH 和一份能用的根证书列表,它要在渲染进程和操作系统之间架一道只进不出的窄门,它还要负责把自己换成新版本而不把用户的工作弄丢。 OpenWork 是 different-ai 开源的一个桌面应用,它把技能、MCP 连接与外部服务打包成可共享的「能力」;本文只看它的桌面外壳,也就是 apps/desktop/electron/ 这个目录。这里说的 OpenWork 是这个开源项目的专有名称,跟职场点评网站或者「开放工作」这类泛指没有关系。
先说清楚本篇在站内的位置。Hermes 的两套前端怎么分工 讲的是同一个后端配两种界面的取舍,Agent 的日常运维 讲的是跑起来之后怎么盯,AI 数字人工具盘点 是产品视角的横向扫描;这篇不碰界面美学也不碰运维流程,只回答一个问题——一个要在别人电脑上长期驻留、还要代管凭据的桌面 Agent 应用,它的进程边界应该怎么切。
一、三块是怎么切开的
打开 apps/desktop/electron/ 你会看到四十五个 .mjs,其中十八个是 .test.mjs。真正撑起结构的是三个入口:main.mjs(两千六百多行)、preload.mjs(两百三十行)、runtime.mjs(一千九百多行)。行数比例本身就是信息量:主进程是杂事的集散地,预加载刻意做得极薄,运行时管理器则是唯一一块被认真做了状态机的代码。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 主进程 | 建窗口、注册 IPC 命令表、深链接、终端、品牌图标、退出前清场 | apps/desktop/electron/main.mjs | 加一个渲染进程调不到的系统能力时 |
| 预加载脚本 | 把一组固定方法挂到 window.__OPENWORK_ELECTRON__ | apps/desktop/electron/preload.mjs | 前端想调新命令,发现桥上没有这个方法 |
| 运行时管理器 | 起停内嵌服务、分端口、发令牌、拼子进程环境与 CA 包 | apps/desktop/electron/runtime.mjs | 服务起不来、端口被占、企业网内 TLS 报错 |
| 更新模块 | 频道选择、下载、Squirrel.Mac 安装兜底 | apps/desktop/electron/updater.mjs | 用户点了检查更新却一直停在旧版本 |
| 系统 CA 装配 | 把操作系统信任库读出来交给运行时 | apps/desktop/electron/system-ca.mjs | 公司装了中间人证书,子进程连不上 |
这张表里最容易被忽略的是最后两行。前三行是 Electron 应用的标准动作,后两行是这类「本地跑 Agent」的应用被现实逼出来的。
二、主进程:一张命令表加一道闸门
主进程没有把 IPC 处理散落在各处,而是收敛成一个对象 desktopCommandHandlers,渲染进程所有请求都从 ipcMain.handle("openwork:desktop", handleDesktopInvoke) 这一个通道进来,再按命令名分发。表里有 workspaceCreate、engineStart、engineDoctor、listLocalSkills、opencodeCommandWrite、connectLinkVerify 这些,也有一批下划线开头的低层能力,比如 __openPath、__fetch、__getFileIcon、__setNativeTheme。源码里那段注释说明了为什么要收敛:这张表被断言到共享的 DesktopCommandMap 契约上,少一个、多一个或者改了名,类型检查就会失败。也就是说,桥两端的方法清单是靠类型约束对齐的,而不是靠人记。
分发之前还有一道闸门。handleDesktopInvoke 会先问 enterprisePreactivationCommandAllowed(command),不在允许清单里的命令要走 assertDesktopActivation(),未激活就直接抛错。同样的判断在应用启动流程里也出现了三次:未激活就不做 prepareFreshRuntime()、不启 UI 控制服务、不引导运行时。源码注释写得直白——UI 控制桥能在渲染进程里执行任意 JavaScript,所以它必须在激活之前保持关闭,否则就是对这条限制的本地绕过。
这里必须交代一件事:这类「管理员先激活、能力才解锁」的机制属于团队控制面范畴,而这个仓库的许可证是分层的。仓库根目录 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)。也就是说这是一份分层的许可证,你在源码里看到的 ee/apps/、ee/packages/ 那些跟组织管理相关的东西,不能笼统当成「MIT 开源」来理解。能不能商用、能不能改、改了能不能分发,一律以两份许可证原文为准,本文只是指出分层这个事实,不提供任何法律意见。
主进程还管着几件很具体的事:app.requestSingleInstanceLock() 保证单实例,抢不到锁的第二个进程在开发模式下会打印当前 profile 目录并退出,把 CDP 端口让出来;app.setAsDefaultProtocolClient(DESKTOP_PROTOCOL_SCHEME) 注册协议,深链接先进 pendingDeepLinks 队列,等窗口 ready-to-show 再 flushPendingDeepLinks() 一次性送进渲染进程;退出时 before-quit 会先 preventDefault(),切到一个内嵌的 data URL 停机页,等 disposeRuntimeBeforeQuit() 和 uiControlServer.stop() 都完成才真正 app.quit()。最后这一段值得抄——它承认了一个事实:这类应用退出时手上握着子进程,不给清场时间就是留孤儿进程和被占的端口。
三、预加载:窄门只有这么宽
preload.mjs 通篇只做一件事:
contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", {
invokeDesktop(command, ...args) {
return ipcRenderer.invoke("openwork:desktop", command, ...args);
},
...
});
挂上去的分组是 shell、system、migration、brandIcon、dev、nuke、updater、browser、terminal、meta。每一项都是明确写死的函数,没有一个通用转发器能让渲染进程随便点名 ipcRenderer 的任意通道。窗口那侧的配置与之对应:
webPreferences: {
backgroundThrottling: false,
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
plugins: true,
},
contextIsolation: true 加 nodeIntegration: false 是这道边界成立的前提。sandbox: false 是个取舍——预加载脚本需要用到 Node 能力,代价是这一层不再被 Chromium 沙箱兜底,所以桥上暴露多少东西就得更克制。backgroundThrottling: false 那行有注释解释:渲染进程自己在跑会话调度和事件流,窗口最小化时不能被降频。
事件方向是反过来的。主进程 webContents.send 发出来的 openwork:deep-link-native、openwork:updater:download-progress、openwork:browser:state、openwork:terminal:data 这些,预加载要么转成 window.dispatchEvent 的 CustomEvent,要么在订阅函数里注册 ipcRenderer.on 并返回一个取消订阅的闭包。前端只拿到一个回调,拿不到通道名。另外有两个同步调用 ipcRenderer.sendSync("openwork:desktop-bootstrap-sync") 和 openwork:desktop-distribution-sync,在页面脚本跑起来之前就把引导配置和分发版本塞进 meta,让前端第一帧就知道自己跑在哪种壳里。
四、运行时:端口、令牌,和它替你凑齐的那些环境
createRuntimeManager 返回的 engineStart / engineStop / engineRestart / prepareFreshRuntime / dispose 全部包在 withRuntimeLifecycle 里串行执行。注释解释了为什么:启动过程中主进程在引导选中工作区,渲染进程的路由又会独立地去确保连接,两条路径并发时,后一次调用的清场动作会杀掉前一次刚起好的服务,前一次再去探活就超时。这是本地起服务的应用最常踩的一类竞态,值得单独记住。
服务本身不是外挂进程,而是 await import() 进来的内嵌模块:它按几个候选路径找 embedded.js:一个是仓库内的 apps/server/dist/embedded.js,另外两个是打包时暂存到 apps/desktop/server/dist/ 和 process.resourcesPath 下的副本。开发模式把仓库内那个排在最前,否则先试两个打包副本、最后才回落到仓库路径,找到哪个就 await import() 哪个,再调 startEmbeddedServer。这个顺序不是随手写的:开发时你改了服务端代码,希望立刻生效;装机版则不该被残留的源码目录污染。端口是黏性的——openwork-server-state.json 里按工作区记住上次用的端口,下次优先复用,实在不行才 findFreePort。令牌存在 openwork-server-tokens.json,每个工作区一组 clientToken / hostToken / ownerToken,前两个用 randomUUID() 生成,ownerToken 是拿 hostToken 通过 X-OpenWork-Host-Token 头去服务端 /tokens 换来的。
这里是本文必须说清代价的地方。这两个 JSON 文件都躺在应用的 userData 目录里,能读到该目录的进程就能拿到访问这台机器上 OpenWork 服务的令牌。开启远程访问时 host 从 127.0.0.1 变成 0.0.0.0,并且会用 buildConnectUrls 拼出局域网 IP 和 .local 的 mDNS 地址——这意味着同网段的其它设备可以直接摸到这个端口。还有一条更容易被忽视的:主进程在 ready 之前就 app.commandLine.appendSwitch("remote-debugging-port", ...),端口从 9223 起往后探到 9227,地址锁在 127.0.0.1,为的是让浏览器面板能被 CDP 驱动。本机上的任何进程都能连这个调试端口。凭据集中保管带来的便利有多大,暴露面就有多大,这一点在 API 密钥的安全管理 里讨论过一般性做法,桌面场景只是把保管位置从服务器换成了用户的磁盘。
子进程环境是 buildChildEnv 拼的,两个细节值得抄。一是用户自定义环境变量的加载顺序:loadUserEnvFile() 结果放最底层,process.env 覆盖在上面,并且解析时会直接丢掉所有以 OPENWORK_ 和 OPENCODE_ 开头的键——注释写明这是防止被篡改的文件影子掉自己的保留变量。二是 PATH 修复:enrichedPath 把 sidecar 目录、extraPathEntries() 猜出来的一堆常见位置(Homebrew、nvm 各版本 bin、fnm、volta、pnpm、bun、cargo、pyenv shims、~/.local/bin,macOS 上还会先跑 /usr/libexec/path_helper -s 取系统 PATH)拼到前面。桌面应用从 Finder 或开始菜单启动时拿不到登录 shell 的 PATH,这段代码就是在补这个洞。
五、桌面壳还管着证书链和更新
这是标题里那句「比你想的多」的两个落点。
先说证书。resolveSystemCaEnv 会读取操作系统信任库(tls.getCACertificates("system") 加平台加载器),去重后写成 system-ca-bundle.pem 放进 userData,再通过 NODE_EXTRA_CA_CERTS 传给子进程;如果父进程本来就设了这个变量,它什么也不做,直接让位。注释还提了一句:Bun 认 Node 的这个变量,所以打包的 Bun sidecar 能沿用同一份信任库。
更有意思的是 repairIncompleteChains。它处理的是一类具体故障:服务端只发了叶子证书、没带中间证书,于是握手报 UNABLE_TO_VERIFY_LEAF_SIGNATURE。修复流程是先严格探一次,只有错误码正好是这一个才继续;再不校验地连一次,确认对方发的链确实是「只有叶子」;然后从叶子证书的 AIA 扩展里读 CA Issuers 的 URL 把中间证书抓下来。抓下来也不直接用,要过 refusalReason 四道关:
function refusalReason(leaf, intermediate, rootsProvider) {
if (intermediate.ca !== true) return "fetched certificate is not a CA";
if (leaf.checkIssued(intermediate) !== true) return "fetched certificate did not issue leaf";
if (leaf.verify(intermediate.publicKey) !== true) return "leaf signature verification failed";
if (!intermediateChainsToTrustedRoot(intermediate, rootsProvider)) return "fetched certificate does not chain to a trusted public root";
return null;
}
作用域也被死死框住:候选来源默认只有一个,就是引导配置里 enterpriseActivation.denBaseUrl 那个管理员配的控制面地址,而且 exactEnterpriseOrigin 要求它必须是 https、不带用户名密码、不带 query 和 hash,只取 origin;最多三个 origin,整体超时二十秒,环境变量 OPENWORK_DISABLE_CHAIN_REPAIR 可以一键关掉。这是一段「明知有风险仍要做,于是把每一步都收窄」的代码,比大多数教程里的 TLS 示例更值得读。
再说更新。updater.mjs 单独成文件的理由,在于它踩过的坑都写在注释里了。稳定版和 alpha 两个 feed 都指向 GitHub Releases,alpha 只在 macOS 上生效;频道选择持久化在 electron-updater-channel.v1.json;autoDownload 关掉,由界面显式触发下载。macOS 那部分尤其具体:差分下载被 disableDifferentialDownload = true 关掉,因为重建出来的 bundle 会喂给 Squirrel 那套靠移动整个 app 目录来安装的逻辑,容易失败;同时用 /usr/bin/defaults 往 ShipIt 的 defaults 域写 SquirrelMacEnableDirectContentsWrite,让它就地写文件内容而不是搬整个 bundle。注释里那句现象描述值得记:安装失败后应用可能悄悄重启回旧版本,界面上的版本号看着更新了,磁盘上的渲染层还是旧的。下载前还会清掉上次卡住的 ShipIt 缓存目录。整个更新模块只在 app.isPackaged 时才真正加载 electron-updater,开发模式直接跳过。
六、边界与代价
这个设计放弃了什么,得说明白。
放弃了渲染进程的灵活性。 桥上没有通用转发器,前端想用一个新的系统能力,就必须在命令表、预加载、类型契约三处都补一遍。对安全是好事,对迭代速度是税。
放弃了沙箱。 sandbox: false 换来预加载脚本可以用 Node,代价是这一层出问题时没有第二道防线。
它明确不管的事: 不管你的模型服务商怎么计费、怎么限流,这些在桌面壳里根本没有对应代码;不管工作区里的代码安全,它只负责把工作区路径传给内嵌服务;也不承诺跨平台一致——engineInstall 在 Windows 上直接返回一句「引导式安装暂不支持」,让你手动装;alpha 更新通道只对 macOS 开放;品牌图标和快捷方式那一大段 PowerShell 只在 Windows 上跑。
不适用的场景也很清楚。 如果你要的是无人值守的服务端常驻,这套东西的重心(窗口、托盘图标、原生菜单、深链接、通知)有一大半用不上,内嵌服务本身反而是你要的部分。如果你的合规要求禁止应用自动联网抓取任何证书或图标,那么链修复和品牌图标下载这两条路径都需要评估——品牌图标是主进程用 electronNet.fetch 直接去拉的远程 URL,虽然限了大小、超时和尺寸比例,但它确实会联网。
还有一类风险跟架构无关,跟这类工具的性质有关。 它会在你机器上开真实的终端:openwork:terminal:create 用 pty.spawn 起一个登录 shell,cwd 由渲染进程传入,环境变量里带上 OPENWORK_TERMINAL=1。它会代管模型凭据和第三方服务授权。装在企业管理的机器上时,引导配置可以要求激活、可以换掉应用名和图标、可以指定控制面地址。这些能力用在哪一侧、企业侧能看到什么,属于你在部署前要问清楚的事,不是读源码能替你决定的。权限该怎么收,Agent 的最小权限设计 那篇的思路在这里同样适用。
顺便一提,仓库 README 把自己定位成「Claude Cowork 和 Codex 的开源替代」——这是项目自己的说法,本文只是转述,不替它背书,也不在此排任何座次。
七、上手与避坑清单
别在同一个 profile 目录下开两个开发实例。 会踩是因为第二个进程抢不到单实例锁会直接退出,而你可能以为是启动失败。怎么避:看它打印的那段提示,用 OPENWORK_DEV_PROFILE=auto 起隔离 profile。
别指望改了 preload.mjs 就够。 会踩是因为桥、命令表、共享类型契约三处必须同时改,只改一处时报错是「桥方法不存在」或者类型检查失败,两种表现差很远。怎么避:加命令时按「类型契约 → 命令表 → 预加载」的顺序走一遍。
别在渲染进程里并发调起停。 会踩是因为你以为 engineStart 是幂等的,实际上并发时后一次的清场会杀掉前一次的服务。怎么避:读懂 withRuntimeLifecycle 的串行队列和那个「已健康就直接复用」的短路条件,需要强制重启时显式传 forceRestart。
别在没关远程访问的情况下连公共网络。 会踩是因为开启后监听地址变成 0.0.0.0,并且会主动算出局域网地址给你分享。怎么避:把远程访问当成一个开关而不是默认态,用完关掉。
别忽略那个 9223 起步的调试端口。 会踩是因为它是启动时自动开的,不需要你打开任何开发者工具。怎么避:知道它存在、知道它只绑 127.0.0.1,在多用户共享的机器上把这一条纳入评估。
遇到子进程连不上外网先别怀疑代码。 会踩是因为企业网的中间人证书和不完整证书链的表现都是「握手失败」,但两者的修法完全不同。怎么避:先看主进程日志里那行 CA 来源汇总和链修复的原因串(它把跳过原因写得很细),再决定是设 NODE_EXTRA_CA_CERTS 还是关掉链修复。
更新卡住时先看是不是打包版本。 会踩是因为开发模式压根不加载 electron-updater,检查更新的返回是 unavailable。怎么避:在打包产物上验更新,macOS 上顺带看看 ShipIt 缓存目录有没有残留。
接下来读哪个文件
如果你要抄的是进程边界,从 apps/desktop/electron/preload.mjs 开始读最快,两百多行能看完全貌,再回头看 main.mjs 里那张命令表就有坐标了。如果你要抄的是本地服务的生命周期管理,直接跳到 runtime.mjs 的 createRuntimeManager,把 withRuntimeLifecycle、startOpenworkServer、prepareFreshRuntime 三段连起来看。如果你在解决网络问题,runtime.mjs 里 repairIncompleteChains 那一段和同目录的 system-ca.mjs 是一组。
给自己留一份三问自检:我的桥上有没有通用转发器;我的子进程环境变量和 PATH 是自己拼的还是指望继承;我的应用退出时给子进程留了几毫秒。这三个问题在 OpenWork 的源码里都有明确答案,而多数自研桌面壳的答案是「没想过」。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 开源桌面应用 OpenWork 的许可证分层:MIT 与 /ee 目录边界如何影响自建与商用 和 开源桌面应用 OpenWork 的本地服务端:路由怎么分、桌面壳管什么。