开源桌面应用 OpenWork 的本地服务端:路由怎么分、桌面壳管什么
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
OpenWork 这个开源桌面应用真正的主体不是那个 Electron 窗口,而是一个跑在你本机的 HTTP 服务端;桌面壳只负责把它拉起来、拿住令牌、递给界面用。 想明白这一点,很多事情立刻就顺了:为什么它敢说不装桌面也能用,为什么远程访问、令牌分级、静态前端托管这些能力全落在服务端这一侧,为什么它把托管的推理引擎当成一个可被代理的下游服务,而不是自己的一部分。
这里说的 OpenWork 是 different-ai 维护的那个把技能、MCP 连接与外部服务打包成可共享能力的开源桌面应用,不是同名的职场点评网站,也不是泛指的开放式协作。下文所有路径都是仓库里的真实路径,你可以边读边对照。
站内已经有几篇相邻的文章:Pi 的 server 进程模型 讲的是另一个项目的常驻进程与会话状态,Agent SDK 与框架怎么选 讲的是抽象层的取舍,MCP 的生产部署 讲的是协议侧上线后的运维面;本篇只钻一件事——OpenWork 这一个仓库里,本机服务端的路由分层与它跟桌面壳的职责边界。
一、这个本地服务端是什么,又不是什么
仓库根目录下 apps/ 有 4 个应用,packages/ 有 12 个包,另有 ee/apps/ 10 个与 ee/packages/ 3 个;全仓受版本控制的文件 3490 个。本篇的主角是 apps/server,包名就叫 openwork-server,它自己在 package.json 里的描述是「Filesystem-backed API for OpenWork remote clients」——面向远程客户端的、以文件系统为底的 API。apps/server/src/ 顶层就有 138 个 .ts 文件,是仓库里最厚的一块。
它的对外契约收在 apps/server/src/index.ts,导出面就三个函数——startEmbeddedServer、startServer、resolveServerConfig——外加三个纯类型:EmbeddedServerHandle、EmbeddedServerOptions、ServeResult。文件头的注释直接给了用法示例——传 host、port、workspaces、token、hostToken、manageOpencode、opencodeBin,拿回一个带 url 和 stop() 的句柄。这个导出面很窄,窄本身就是设计:可执行的入口只有那三个函数,外部绕不到内部模块上。
它不是推理引擎。真正跑对话的是 OpenCode,服务端把它当作一个下游进程来托管和代理。它也不是前端,静态资源托管是一项可选能力,默认关着。
二、一次请求进来,它按什么顺序分流
apps/server/src/server.ts 里的 startServer 把一个巨大的 fetch 函数交给底层监听器,所有请求都从这一个入口进。顺序是硬编码的,先命中先返回:
OPTIONS直接回 204,交给统一的 CORS 包装。parseWorkspaceOpencodeMount匹配/workspace/<id>/opencode...,命中就走 OpenCode 代理。parseWorkspaceMount匹配/w/<id>/...这种挂载前缀;如果剩下的路径是/opencode或以/opencode/开头,同样走代理。- 挂载前缀下如果剩下的路径以
/workspace/开头,它会把嵌套的那个 workspace id 抠出来跟挂载的 id 比对,不一致直接 404;一致才剥掉前缀,用剩下的路径走常规路由。源码注释讲得很清楚:这是为了保住「一个挂载地址对应一个工作区」的心智模型。 - 裸的
/opencode或/opencode/*,用config.workspaces[0]这个工作区去代理。 - 都没命中,才交给
matchRoute查注册的路由表。 - 路由表也没有,最后尝试
serveStaticUi;还不行就回 JSON 的{ code: "not_found" }。
路由表本身在 apps/server/src/routes/registry.ts,实现朴素得让人放心:addRoute 把 /w/:id/status 这类模板里的 :name 替换成 ([^/]+) 编成正则,matchRoute 线性遍历、方法相同再匹配正则,命中就把捕获组按顺序 decodeURIComponent 塞进 params。没有前缀树,没有优先级排序——注册顺序就是优先级。
代理那一段值得单独看。proxyOpencodeRequest 转发前会删掉 authorization、x-openwork-host-token、x-openwork-client-id、host、origin 这几个头,再按工作区补上下游需要的 Authorization 和 x-opencode-directory(目录名含非 ASCII 字符时做百分号编码)。请求体先 arrayBuffer() 整个缓冲再转发,注释说明原因是 Node 的流边界不总能被全局 fetch 直接接受。响应回来时 sanitizeProxyResponse 会摘掉 content-encoding、transfer-encoding、content-length——上游已经解过压,头留着的话浏览器会按 gzip 去解一段明文,然后报解码失败。
还有一条特例:POST /session/<id>/command 走的是发射后不管,请求扔出去就立刻返回 { ok: true, accepted: true },失败通过 OpenCode 的事件流暴露。你拿到 200 不代表命令执行成功,只代表被收下了。
三、四档鉴权,分别认哪个头
registry.ts 里 AuthMode 只有四个值:none、client、host、host-token。每条路由注册时第四个参数就是它的档位,读源码时看这一个参数就知道要什么凭据。
client:要Authorization: Bearer <token>,服务端拿去查作用域,查不到就 401;可选的x-openwork-client-id头会带进 actor 里。host-token:只认x-openwork-host-token头,且必须等于配置里的 hostToken。host:host token 可以,owner 级的 bearer 也可以,两条路都通。none:不校验。/health、/dev/log、/ui及其静态资源走的就是这档。
令牌分三级:owner、collaborator、viewer,这也是能力清单里 tokens.scopes 报出来的三个值。assertOpencodeProxyAllowed 对代理路径额外加了一层:viewer 只能 GET/HEAD,写请求一律 403。源码里有一段很实在的注释,说明这里曾经把权限回复接口收得太紧——单页应用手里只有 collaborator 级的客户端令牌,如果只放行 owner,每个交互式权限对话框都会 403,工具调用就永远卡在 running。这类「权限设计过紧反而把主流程锁死」的坑,可以对照 最小权限怎么落地 一起看。
apps/server/src/routes/core.ts 是最基础的那批路由。健康检查、状态、能力清单、whoami、工作区列表、运行时版本查询走 client;发令牌与吊销令牌(GET/POST /tokens、DELETE /tokens/:id)走 host;而整组 /env 路由——列举、按键读、批量写、删除——统一走 host-token。文件里的注释挑明了:这一组要的是桌面主机令牌,owner 级的 bearer 也不行。理由不难猜,环境变量里躺的是模型服务商的密钥。写入之后它会立刻调用同步逻辑把凭据推给引擎,注释的意思是:存下来的凭据在引擎拿到之前是没用的,所以现在就送过去,而不是等下次引擎启动。
四、组成部分速查表
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 库导出面 | 只对外暴露三个函数与三个类型 | apps/server/src/index.ts | 想把服务端当依赖用时,先看这里的边界 |
| 进程内启动入口 | 一次调用完成配置解析、托管引擎拉起、监听绑定,返回带 stop() 的句柄 | apps/server/src/embedded.ts | 桌面壳或你自己的程序要把它嵌进去 |
| 独立进程入口 | 命令行参数、启动日志、SIGINT/SIGTERM 收尾 | apps/server/src/cli.ts | 想让它在一台机器上常驻跑 |
| 请求分流与代理 | 那个唯一的 fetch 函数、挂载前缀解析、OpenCode 代理与响应清洗 | apps/server/src/server.ts | 请求走错分支、代理响应异常时 |
| 路由表与鉴权档位 | addRoute/matchRoute,四档 AuthMode | apps/server/src/routes/registry.ts | 判断某个接口要哪种令牌 |
| 基础路由 | 健康、状态、能力、令牌、环境变量、语音会话 | apps/server/src/routes/core.ts | 客户端做能力探测、对接令牌体系 |
| 静态前端托管 | 可选开关,单页应用兜底,index.html 注入 | apps/server/src/static-ui.ts | 不装桌面、直接用浏览器访问 |
五、桌面壳到底管什么
桌面壳在 apps/desktop,是个 Electron 工程,主入口 electron/main.mjs,启动逻辑集中在 electron/runtime.mjs。看它怎么起服务端,分工线就画出来了。
它做的事情很具体:为每个工作区生成并持久化一组令牌(clientToken、hostToken 各是一个 randomUUID,ownerToken 先留空);挑端口并记住上次用的那个;根据「是否允许远程访问」决定 host 绑 0.0.0.0 还是 127.0.0.1;找到打包出来的 dist/embedded.js 动态 import 进来,调 startEmbeddedServer;拿到句柄后,用 host token 去 POST /tokens 换一个 owner 级令牌存起来,之后再用这个 owner 令牌读 /workspaces。
有两个细节值得注意。第一,它是在同一个进程里跑服务端的,不是 spawn 一个子进程——句柄存在变量里,重启时先 stop() 再重来。第二,注释明确说了端口以返回值为准:startEmbeddedServer 在 EADDRINUSE 时会退回到系统分配端口(serve-node.ts 里那段重试只做一次,用 listen(0)),所以你传进去的 port 不一定是最终绑上的 port。
反过来,桌面壳不管的是:路由、鉴权、工作区文件、MCP 与技能的读写、与引擎的通信。这些全在服务端。桌面壳更像一个带界面的进程管理器,外加一个凭据保险箱。
apps/server/src/embedded.ts 那一侧的动作同样清晰:解析配置;非只读时准备工作区文件;写一份由服务端托管的引擎配置文件并保持它随运行时写入而刷新(注释解释了原因——引擎每次重建实例都会从磁盘重读这个文件);清扫遗留配置;拉起托管的 OpenCode 子进程,通过环境变量把服务端地址、服务端令牌、配置文件路径、模型服务地址递过去;把子进程登记为受信任进程;启动 HTTP 服务;最后尽力把所有工作区的 MCP 注册推给引擎。stop() 则是反向来一遍。
六、为什么不装桌面也能用
仓库 README 这样定位自己:它是一个免费开源的桌面应用,是 Claude Cowork 与 Codex 的开源替代;并且明确写着桌面应用只是「你想要一个专属工作区时才有」,不是必需的,你可以从已有的 agent 里用它。这是项目自己的说法,出处标在这里,判断留给你。
代码这一侧确实撑得住这句话。apps/server/src/cli.ts 和 embedded.ts 走的是同一套流程——解析配置、按需拉起托管引擎、startServer——区别只在于前者自己拥有进程生命周期、注册信号处理、把生成的令牌打到日志里,后者返回句柄让调用方管。命令行支持 --config、--host、--port、--token、--host-token、--approval、--workspace(可重复)、--cors、--read-only、--log-format、--log-requests 这些开关,日志还能切成 JSON 格式,带上服务名与实例 id,方便丢进日志系统。packaging/ 下给了三种分发方式的目录,其中 Docker 那一份的 README 描述的就是容器化部署的场景。
浏览器访问靠 apps/server/src/static-ui.ts。逻辑很短:读环境变量拿静态资源根目录,没配就直接返回 null(等于这块能力不存在);只处理 GET/HEAD;/w/ 开头的路径不接管;找不到文件时,除 /assets/ 外一律回退到 index.html——标准的单页应用兜底。/assets/ 下的资源发一年期不可变缓存,index.html 发 no-cache。
把这三块拼起来,「不装桌面也能用」就不是宣传口径,而是分工的必然结果:服务端自带进程入口、自带令牌体系、自带静态托管,桌面壳能做的它自己都能做,桌面壳只是其中一种拉起方式。
七、边界与代价
这个设计放弃了不少东西,值得逐条摆明。
没有独立的用户体系。 这一层的身份就是令牌本身,三档作用域加上 host token,没有账号、没有密码、没有单点登录。谁拿到令牌谁就是那个身份。仓库 docs/ 下的 20 份架构文档里确实有身份与组织相关的题目,但那些能力落在 ee/ 目录,许可证不同(见下一条)。
许可证是分层的,不能笼统说成 MIT。 根目录 LICENSE 写得很明白:/ee 目录下的所有内容按 ee/LICENSE 里定义的许可证(原文括注为 Fair Source License);第三方组件按各自原始许可证;其余部分才是 MIT,署名 Copyright 2026 Different AI。翻开 ee/LICENSE 本身,标题是 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT,署名 Copyright 2026 Different AI Inc——跟 MIT 是两套条款。团队控制面、企业侧能力这类东西大多在 ee/ 下,别按 MIT 的预期去规划商用与二次分发。本文不提供法律意见,能不能商用、能不能改,一律以两份许可证原文为准。
状态在内存里,不适合在这一层水平扩容。 诊断接口的冷却计时和在途集合都挂在以配置对象为键的弱引用表上,重启即清空,也不跨实例共享。工作区文件、配置、令牌都落在本机磁盘。它是「一台机器一个实例」的形态。
代理是薄的。 它只删几个头、补几个头、清几个响应头,不做协议翻译;下游地址缺失就抛 400。想在这一层做协议适配、多引擎路由,得自己加。
它明确不管的事: 模型推理本身、前端构建产物(要你自己把目录喂给它)、跨机器的状态同步。只读模式一开,能力清单里技能、插件、MCP、命令、配置的写权限会整体变成 false。
风险这一侧也要说透。这类工具会在你机器上装桌面应用、代管模型凭据,还可能接上办公套件与团队控制面:环境变量接口默认连值一起返回(列举接口有个开关可以只要元数据,但缺省是带值的);语音会话接口会把请求发到外部服务商换取临时凭据,也可能改走另一个中转地址,走哪条取决于你存了哪些环境变量;办公套件的连接、断开、切换账号、冒烟测试都是服务端上的接口,授权范围由你在授权页面上勾的东西决定。凭据集中保管的好处是一处配置处处生效,代价是一处泄漏全部暴露。密钥管理的通用做法可以参考 API Key 的安全管理。
八、上手与避坑清单
读源码的顺序:index.ts 看边界 → embedded.ts 看启动全流程 → cli.ts 看独立部署形态 → server.ts 里那个 fetch 函数看分流顺序 → routes/registry.ts 看鉴权档位 → routes/core.ts 看具体路由长什么样。半天能过一遍主干。
为什么会踩 + 怎么避:
- 拿 owner 令牌去调环境变量接口,401。因为这组路由挂的是
host-token档,只认那个专用请求头,跟 bearer 令牌不是一条链路。避的办法是:看一眼addRoute的第四个参数,那就是答案,别靠接口路径猜。 - 把 viewer 令牌当成「给同事用的默认档」。viewer 在代理层只能 GET/HEAD,连权限对话框都回不了,同事会卡在工具永远 running 上。要么给 collaborator,要么提前说清这是只读旁观。
- 看到命令接口返回 200 就以为跑完了。那条路径是发射后不管,返回的字段写着 accepted。要判断结果,去订阅引擎的事件流。
- 配了 port 却连不上。端口探测和绑定之间是竞态,冲突时会退到系统分配端口。以启动日志里打出来的 URL 或句柄返回的 port 为准,别拿配置文件当真相。
- 把浏览器前端开出来直接暴露到公网。托管 index.html 时,服务端会往
</head>前注入一段脚本,把客户端令牌写进页面的全局变量——谁能打开这个页面谁就拿到了令牌。有一个环境变量可以把注入关掉(设成 0 或 false),不想要就干脆别配静态资源根目录。 - 照抄桌面壳的启动参数去做常驻部署。桌面壳传的是 CORS 全放开、审批模式自动,那是本机单人场景的取舍;开了远程访问它还会绑
0.0.0.0。放到共享环境上,CORS 和审批模式都得自己重新拍板。 - 默认整仓 MIT。
/ee下另有一套许可证(FSL-1.1-MIT),根 LICENSE 已经把这条分层写在最前面。涉及团队控制面、企业能力的部分,先读ee/LICENSE原文再做决定。 - 用挂载前缀时嵌套路径写岔。
/w/<a>/workspace/<b>/...里 a 和 b 不一致会直接 404,不会告诉你为什么。保持两处 id 一致。
收束
判断一个本地服务端设计得好不好,我一般看三件事:入口是不是唯一、鉴权档位是不是一眼可查、进程边界是不是干净。OpenWork 这一块三条都还行——一个 fetch 函数管所有分流,四档 AuthMode 写在路由注册处,桌面壳只负责拉起和保管凭据。至于分流顺序硬编码、路由线性匹配、状态全在内存,是它选择的形态带来的代价,不是漏掉了什么。
接下来该读哪个文件:关心「这东西能不能常驻在服务器上」,读 apps/server/src/cli.ts 和 apps/server/src/config.ts;关心「桌面壳到底往里塞了什么」,读 apps/desktop/electron/runtime.mjs;关心「工作区、会话、文件这些接口怎么定义」,apps/server/src/routes/ 下还有工作区、会话、文件、操作、云端 MCP 五组路由等着你。文档那一侧,仓库 docs/ 有 20 份架构文档,packages/docs/ 有 57 份 mdx,其中 model-context-protocol/ 下 10 份是各家客户端的接入指南,evals/ 下另有 26 份流程文档——从哪进都行,但先把服务端这一层的分工搞明白,后面的东西才挂得上。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 读 OpenWork 开源桌面应用源码:主进程、预加载、运行时的职责边界 和 OpenWork 开源桌面应用:把别人的 Agent 当引擎要补的工程课。