OpenWork 开源桌面应用的工作区模型:初始化建了什么、状态存在哪、导出如何拦住敏感文件

2026-08-04

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

**OpenWork 的工作区不是一个目录,而是四份状态的交集:磁盘上的一个文件夹、桌面端用户数据目录里的一份 JSON 清单、服务端配置目录里的 server.json,以及一个 SQLite 库里的一行记录。**你如果只盯着那个文件夹,会发现删了它工作区还在列表里;你如果只改 JSON 清单,会发现服务端启动时又从另一处把它捞了回来。搞清楚这四者的分工,是读懂它导出机制的前提——因为导出恰恰只覆盖其中一部分。

OpenWork 在这里指 different-ai 的这个开源桌面应用(把技能、MCP 连接与外部服务打包成可共享的能力),与任何叫同名的职场点评网站或者泛指的“开放工作”没有关系。它的仓库 README 把自己定位成 Claude Cowork 与 Codex 的开源替代,这是项目自己的说法,本文不替它做这个横向判断,只讲代码里能读到的机制。

许可证要先说清楚:这个仓库是分层授权的。/ee 目录下的内容按 ee/LICENSE 定义的 Fair Source 许可证(该文件标题为 Functional Source License, Version 1.1, MIT Future License,Copyright 2026 Different AI Inc),其余部分才是 MIT(Copyright 2026 Different AI)。仓库里 ee/apps/ 有 10 个应用、ee/packages/ 有 3 个包,团队控制面那一侧的东西大量落在这个目录下。所以看到“开源”两个字别自动等价于“随便商用随便改”,能不能用、怎么用,一律以许可证原文为准,本文不提供法律意见。

站内已有三篇相邻的文章分工不同:Agent 工作区隔离 讲的是隔离策略本身的取舍,Pi 的会话存储 讲的是另一个项目怎么存会话,Agent 交接给人 讲交接时的产物约定;本篇只做一件事——把 OpenWork 这一个具体实现的工作区状态落点和导出过滤链路读到文件级。

一、先划清楚“工作区”这个词的边界

apps/server/src/workspaces.tsbuildWorkspaceInfos() 是理解的入口。它把配置里的工作区条目展开成运行时信息,第一件事就是分叉:workspaceType 要么是 local,要么是 remote。本地工作区有一个 path,用 resolve(cwd, rawPath) 归一化;远程工作区可能连本地路径都没有,只有 baseUrl,再按 remoteType 细分成 openworkopencode 两种。

ID 的算法直接写在同一个文件里:workspaceIdForKey() 对一个字符串做 sha256,取前 12 位十六进制,拼上 ws_ 前缀。三个入口分别喂不同的 key:

  • workspaceIdForPath() 直接用解析后的绝对路径;
  • workspaceIdForRemote()remote::<baseUrl>remote::<baseUrl>::<directory>
  • workspaceIdForOpenwork()openwork::<hostUrl>openwork::<hostUrl>::<workspaceId>

这意味着一件很具体的事:本地工作区的身份就是它的路径。你把文件夹改个名或者挪个位置,它在系统里就是另一个工作区了,原来那份以 ID 为主键的状态不会跟着走。这个设计换来的好处是不需要在目录里埋一个 ID 文件,代价是路径即身份,重命名等于换人。

桌面端那侧的 apps/desktop/electron/workspace-store.mjs 有一套平行实现:stableWorkspaceId() 同样是 sha256 前 12 位加 ws_localWorkspaceId()remoteWorkspaceId() 与服务端口径一致;但连到 OpenWork 主机的远程工作区走 openworkRemoteWorkspaceId(),返回的是 rem_<远程工作区 ID>。你在桌面端状态文件里同时看到 ws_rem_ 开头的 ID,不是数据脏了,是两条命名线。

还有个容易被忽略的函数:findManagedEngineWorkspace()。它的注释解释得很直白——引擎服务所有工作区,但需要一个本地目录来启动,而 config.workspaces[0] 靠不住,新加的远程工作区会被插到列表最前面。所以它跳过 remote 且跳过 path 为空的条目,找第一个有本地路径的。纯远程的部署会返回 undefined,那种情况下本来也不需要本地引擎。

二、创建一个工作区,到底落了哪几个文件

这里有个反直觉的点:服务端的初始化几乎不写文件,真正写文件的是桌面端。

apps/server/src/workspace-init.ts 里的 ensureWorkspaceFiles() 做的事只有:归一化 preset(空则回落到 starter),路径为空就抛 invalid_workspace_path(HTTP 400),然后 ensureDir(workspaceRoot)。它调用的 ensureOpencodeConfig() 更保守——如果配置文件存在就读一遍(用 readJsoncFileallowInvalid: true,等于容忍语法错误),然后无条件返回 false,一个字节都不写。defaultWorkspaceOpenworkConfig() 上方那段注释写明了原因:openwork 配置现在存在运行时数据库里,由调用方 seed,不再写 .opencode/openwork.json

defaultWorkspaceOpenworkConfig() 因此被保留成一个纯函数,只产出默认结构:version: 1workspace 里带 name(取目录 basename)、createdAtpresetauthorizedRoots 初始化为 [workspaceRoot]reloadnull。工作区创建路由拿这个结构去种数据库行。

外层的 ensureLocalWorkspaceFiles() 负责批量兜底,它显式跳过 workspaceType === "remote"path 为空的条目。注释里说明了动机:远程工作区可能带一个非空的 directory,如果不拦住就会进 ensureWorkspaceFiles() 然后抛异常,把服务端启动整个搞崩。

桌面端的 createWorkspace() 则是实打实地建目录:mkdir(folderPath, { recursive: true }),再 mkdir(path.join(folderPath, ".opencode")),然后 writeWorkspaceOpenworkConfig() 把默认配置写成 .opencode/openwork.json。也就是说,从桌面应用新建的工作区,磁盘上确实会有这个文件;而服务端读取时走 readOpenworkConfigForWorkspace(),它先查数据库,查不到再读这个文件,读到内容就顺手 seed 进数据库(代码注释称之为 migrate-on-read)。**同一份逻辑配置存在两个真相,文件是历史入口,数据库是当前落点。**你手改文件而工作区在数据库里已经有行,改动不会生效。

路径归一化也值得留意。桌面端 normalizeLocalWorkspacePath() 会展开 ~~/path.resolve 之后调 realpath,失败才退回未解析的路径——意味着符号链接会被解开,而 ID 是按解开后的真实路径算的。

三、状态到底存在哪

组成部分它负责什么对应仓库位置你什么时候会碰到它
工作区目录内的 .opencode/技能、命令、插件、工具、agent 等可移植内容的落盘位置apps/server/src/workspace-files.tsprojectSkillsDir/projectCommandsDir/projectPluginsDir你要手工加一个技能或命令、或想搞清楚导出包里的文件从哪来
工作区级 opencode 配置引擎侧配置,四个候选路径按存在性择一packages/paths/index.mjsworkspaceOpencodeConfigCandidates()你改了配置却不生效,通常是改错了那四个候选里的另一个
桌面端工作区清单工作区列表、选中项、远程凭据字段apps/desktop/electron/workspace-store.mjsworkspaceStatePath() → 用户数据目录下 openwork-workspaces.json列表里出现幽灵工作区、或想知道凭据以什么形态落盘
桌面端引导配置组织地址、登录策略、品牌、一次性交接凭据packages/paths/index.mjsdesktopBootstrapPath()desktop-bootstrap.json排查“为什么装完就指向了某个组织”
服务端配置文件工作区注册表与授权根packages/paths/index.mjsopenworkServerConfigPath() → 配置目录下 server.json桌面状态文件丢了却发现工作区又回来了
运行时数据库每个工作区的 openwork 配置 JSONapps/server/src/openwork-workspace-config-store.ts + workspace-kv-store.ts,落在 runtime.sqlite你改了 .opencode/openwork.json 但没有任何反应

几处路径规则说细一点。desktopBootstrapPath() 的解析顺序是:环境变量 OPENWORK_DESKTOP_BOOTSTRAP_PATH 优先,其次 OPENWORK_DEV_MODE=1 时落到用户数据目录下的 dev 沙箱,最后才是常规位置。常规位置由 desktopConfigDir() 决定,这里的顺序容易记反:它先看 XDG_CONFIG_HOME,且这一步不分平台——Windows 上只要设了这个变量,同样会走它;没设才按平台分叉,Windows 用 LOCALAPPDATA(取不到再退到用户目录下的 AppData/Local),其余平台退到 ~/.config。选定的目录下面接 openwork/desktop-bootstrap.jsonworkspace-store.mjs 里专门留了 LEGACY_DESKTOP_BOOTSTRAP_PATH 兼容早期版本无脑用 ~/.config 的行为,并且在显式指定环境变量时拒绝读取 legacy 文件,注释给的理由是:显式路径定义了一个隔离的安装边界,legacy 全局配置可能带着另一个部署的激活状态。

openworkServerConfigPath() 走的是另一套目录规则,分叉顺序恰好和上面相反:OPENWORK_SERVER_CONFIG 覆盖优先,否则先判平台——Windows 用 APPDATA 下的 openwork(取不到退到用户目录下的 AppData/Roaming),非 Windows 才看 XDG_CONFIG_HOME、再退到 ~/.config,同样接一层 openwork,文件名 server.json。所以在 Windows 上设了 XDG_CONFIG_HOME,桌面引导文件会跟着走、服务端配置文件不会,排查路径时别把两者当成一处。runtimeDbPath()runtime.sqlite 放在同一个配置目录里,可被 OPENWORK_RUNTIME_DB 覆盖。

数据库这一层是通用 KV:createWorkspaceKvStore()tableName/valueColumn 生成建表与 upsert 语句,表结构是 workspace_id 主键 + 值列 + updated_at,标识符要过 IDENTIFIER_RE 校验。openwork 配置用的表名是 openwork_workspace_configs,值列 config_json

最后是恢复逻辑,很多“删不干净”的现象来源于此。readWorkspaceState() 发现桌面状态文件不存在时,会调 recoverWorkspacesFromKnownState():先从 server.jsonworkspaces 数组恢复(本地条目还会检查路径是否真的存在),拿不到再从 openwork-server-tokens.json 里记录过的工作区路径反推。这个行为可以用环境变量 OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY=1 关掉。同一个函数还顺手做两件迁移:把带 worker 挂载路径的远程条目归一成 rem_<id>,以及按 ID 去重(注释说明是为了避免 React 收到重复 key)。

凭据在这里的暴露面必须说明白:normalizeWorkspaceEntry() 归一化的字段里包含 openworkTokenopenworkClientTokenopenworkHostToken,服务端 buildWorkspaceInfos() 输出的字段里还有 opencodeUsernameopencodePassword。这些都是随普通 JSON 一起写进磁盘的。desktop-bootstrap.json 里的 handoff.grant 也是明文,代码注释说明它是一次性、短时效的桌面登录令牌,应用启动时兑换一次就把这个文件重写成 handoff: null,并明确写着“不要在这个文件里放长期有效的密钥”;claimLinks 数组里同样可能带 token。换句话说,能读到你用户数据目录的进程,就能读到这些

四、导出时它怎么拦住不该带出去的东西

导出有两条路径,过滤思路完全不同,理解这一点很重要。

桌面端打 ZIP:按文件名拦

apps/desktop/electron/workspace-archive.mjs 里的 exportWorkspaceConfig() 只收两处内容:工作区根下的 opencode.json,以及整个 .opencode/ 目录递归下来的文件。过滤器是 isSecretName(),纯按文件名判断:文件名等于 .env 或以 .env. 开头、等于 credentials.json/credentials.yml/credentials.yaml、或者扩展名是 .key/.pem/.p12/.pfx——命中就跳过,并且记进 excluded 列表。打包结果里会额外写一个 manifest.json,含 versioncreatedAtMs、工作区标识、includedexcluded 两份清单。ZIP 是手写的(自带 crc32 实现和本地文件头/中央目录的字节布局),不依赖第三方库。

导入侧 importWorkspaceConfig() 的防御更密:目标目录若已存在必须为空,否则直接报错;每个条目先过 isSafeArchivePath(),拒绝以 / 开头、带盘符前缀、或路径段里出现 .. 和空段的名字;然后只接受 opencode.json 或以 .opencode/ 开头的条目;再过一次 isSecretName()。落地之后还会把 .opencode/openwork.json 里的 authorizedRoots 强制重写成新的目标目录——从别人那里导入的配置,不会把对方的授权目录一起带进你的机器。解不出 .opencode 目录会直接报错终止。

服务端 HTTP 导出:按内容拦,并且会先把你拦下来

路由是 GET /workspace/:id/export,带一个 sensitive 查询参数,由 parseWorkspaceExportSensitiveMode() 解析成 auto(默认)、includeexclude 三选一,非法值抛 400。

exportWorkspace() 的流程分三步。第一步先做结构裁剪:sanitizePortableOpencodeConfig() 用白名单只保留 9 个顶层键——agentcommandinstructionsmcppermissionpluginsharetoolswatcher。凡不在这个清单里的顶层段(比如 provider)根本不会进导出结果。可移植文件用 listPortableFiles() 收集,白名单前缀是 .opencode/agents/.opencode/plugins/.opencode/tools/,并且路径归一化会拒绝 ..、空段和含 NUL 的路径,.env 系列文件按段匹配剔除,.DS_Store/Thumbs.db/node_modules 作为保留段跳过。

第二步是检测,交给 apps/server/src/workspace-export-safety.ts。注意它检测的是未裁剪的原始配置collectWorkspaceExportWarnings({ opencode: rawOpencode, files })。所以哪怕 provider 段最终不会被导出,只要里面有 apiKey,你照样会被拦一次。检测规则分三类:

  • 键名:先把驼峰拆开再按分隔符切词,命中 apiKey/单独的 key/token(含 authtokenaccesstokenrefreshtoken)/Bearer(含 authorization)/secret(含 client secret)/password(含 passwd)/credentials/privateKey。带 public 的键会把 privateKey 这条摘掉,避免公钥误伤。
  • 值:识别 Bearer <串>、常见令牌前缀(ghpghogithub_patxox[baprs]skrkAKIAASIAAIza)、以及 eyJ 开头的 JWT 形态;另外含 http://https:// 且长度超过 32 的字符串会被标成 long URL。除此之外还有一组按自然语言词面匹配的规则,字符串里只要出现 api_keyaccess_tokenBearerclient_secretpasswordcredentialsprivate_key 这类词,同样会被记一笔——这解释了为什么插件源码里一个叫 apiKey 的局部变量也能触发告警。
  • 单独叫 key 的字段做了降噪:只有值是字符串、且长度不小于 16、不含空白、字符类不少于 3 种(或者是 32 位以上纯十六进制)才算数。仓库里的测试用例直接验证了这个边界——{ key: "primary" }{ key: "theme-dark" } 不告警,"AbCDef1234567890+/token" 告警。

第三步是决策。auto 模式下一旦有告警,就抛 409 workspace_export_requires_decision,错误消息是让你选择排除还是包含,并把 warnings 列表带回去。这是这套设计里我认为最值得抄的一点:它不替你猜,而是把选择权连同证据一起还给你exclude 模式走 stripSensitiveWorkspaceExportData(),对配置逐层递归裁剪(命中的值置为 undefined 并从对象里删掉,裁空的对象和数组也一并删掉),对可移植文件则是整份丢弃——只要 .opencode/plugins/.opencode/tools/ 下的文件内容命中信号,这个文件就不进导出。include 模式就是你自己签字确认,原样带出。

导入侧服务端还有一层 POST /workspace/:id/import/preview,先算出 create/update/replace/delete/unchanged 的变更计数和一个 fingerprint,正式导入时校验这个 fingerprint 是否与预览一致。

五、边界与代价:它明确不管什么

导出的是配置,不是工作成果。 exportWorkspace() 的返回体只有 workspaceIdexportedAtopencodeopenworkskillscommands 和可选的 files。会话、聊天记录、模型产出的中间文件都不在里面。桌面 ZIP 更窄,只有 opencode.json.opencode/ 目录。别把导出包当备份使。

按文件名过滤挡不住内容。 桌面 ZIP 那条路径完全不看文件内容,你把密钥写进 .opencode/plugins/foo.ts 的字面量里,它就跟着走了。服务端那套按内容扫描的逻辑,也只覆盖 .opencode/plugins/.opencode/tools/ 两个前缀(PORTABLE_FILE_PREFIXES 就这两条),而可移植文件的白名单是三个前缀——.opencode/agents/ 下的文件会被导出,但不会被扫内容。

内容扫描是启发式,两个方向都会错。 漏检方面,任何不符合那几个前缀正则、也没写在可疑键名下的自定义凭据格式,它认不出来。误报方面,一条超过 32 字符的普通接口地址就会被标成 long URL,然后在 exclude 模式下把这个值删掉——你导出的配置可能因此少了一个正常的 URL。所以 exclude 之后最好实际打开产物看一眼,别默认它只删了坏东西。

路径即身份带来的迁移成本。 前面说过,本地工作区 ID 由绝对路径的哈希决定。跨机器同步、目录改名、把项目从 ~/work 挪到 ~/projects,在这套模型里都是新建了一个工作区,数据库里那行以旧 ID 为主键的配置留在原地。

凭据集中保管的暴露面。 这类工具本身的价值就来自代管模型凭据与第三方服务授权,代价是这些凭据集中在一处明文落盘(远程工作区的多个 token 字段、opencodePassword、引导文件里的一次性 grant 与 claim 链接 token)。接入团队控制面之后,能力发布与访问管理在组织侧,组织管理员能看到什么、你的连接是共享还是按人配置,取决于那侧的配置——而那部分能力大量位于 /ee 目录,适用的是 Fair Source 而非 MIT。装之前值得把这两件事一起问清楚。

恢复机制会让“删除”不彻底。 forgetWorkspace() 只从桌面状态里摘掉条目并调 forgetWorkspaceToken() 清理 token store,但 server.json 里若仍有这条记录,下次状态文件缺失时又能被恢复出来。

六、上手与避坑清单

改了 .opencode/openwork.json 却毫无反应。 会踩是因为这个文件名字太像唯一真相,实际上服务端已经把它当成 legacy 入口——只有数据库里没有对应行时才读它,读完还会 seed 进去。避法:判断这个工作区是不是已经被 migrate 过(数据库里有行就以数据库为准),要改就走服务端接口改,别直接编辑文件。

改了 opencode.json 却毫无反应。 会踩是因为工作区级配置有四个候选路径,resolveWorkspaceOpencodeConfigPath()opencode.jsoncopencode.json.opencode/opencode.jsonc.opencode/opencode.json 的顺序取第一个存在的。你建了一个 .opencode/opencode.json,但根目录已经有 opencode.jsonc,改的那个根本没被读。避法:改之前先确认这四个位置实际存在哪几个。

导出时收到 409 就去改代码或换参数绕过。 会踩是因为报错文案看起来像 bug。它不是——auto 模式检测到疑似敏感配置就是要停下来让你选。避法:先看 warnings 里的 labeldetail(detail 会列出命中的信号名,最多列 4 个再加省略号),确认是真密钥就用 exclude,确认是误报再用 include

以为 exclude 模式等于安全导出。 会踩是因为“排除敏感项”这个说法容易被理解成完备保证。它的实际语义是“删掉这套正则认出来的东西”。避法:把 exclude 的产物当草稿看,导出后 grep 一遍你自己知道的密钥前缀,尤其是自建服务的自定义格式。

把工作区目录改名或移动。 会踩是因为文件夹看起来就是工作区本体。避法:改名前先想清楚 ID 会变,需要保留的就先导出配置,改完再导入到新位置。

导入到一个非空目录。 会踩是因为直觉上导入应该能合并。桌面导入路径明确要求目标目录为空,否则抛 Target folder must be empty。避法:导入到新建的空目录,再把你原有的文件挪进去。

误以为 .env 一定不会被带出去。 服务端可移植文件确实按路径段正则剔除 .env 系列,桌面 ZIP 也按文件名剔除,但两者都只在各自覆盖的范围内生效。避法:别把凭据放在 .opencode/ 里的任何位置,包括写在插件代码的字面量里。

在多台机器上共用一份引导配置。 会踩是因为 desktop-bootstrap.json 看起来只是普通配置。它里面可能带一次性登录 grant 和 claim 链接 token。避法:不要把这个文件塞进同步盘或者版本库。密钥与令牌的常规管理思路可以参考 API Key 的安全管理

收束

把这套模型压缩成一句话:工作区的身份来自路径的哈希,配置的真相在数据库,内容的载体在 .opencode/ 目录,而导出只搬走最后那一部分,并且在搬之前用两套互补的过滤器问你一次。

想自己往下读,建议按这个顺序:apps/server/src/workspaces.ts 看身份是怎么定的,apps/server/src/workspace-init.ts 看初始化的边界在哪,apps/desktop/electron/workspace-store.mjs 看状态实际落在哪几个文件,apps/server/src/workspace-export-safety.ts 配合同目录下的 workspace-export-safety.test.ts 看检测规则的确切边界——那份测试用例把“什么算密钥、什么不算”写得比任何文档都清楚。要看打包与解包的字节级实现,再翻 apps/desktop/electron/workspace-archive.mjs

最后留一份自检清单:你能说出自己那台机器上 openwork-workspaces.json 的绝对路径吗?能说出 runtime.sqlite 在哪吗?你的 .opencode/ 里有没有硬编码的密钥?如果现在把工作区导出发给同事,manifest.jsonexcluded 里会出现哪些文件?四个问题答不上来,就先别按导出。

本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用:把别人的 Agent 当引擎要补的工程课OpenWork 开源桌面应用的 MCP 服务端:两个工具收敛整套能力

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