OpenWork 开源桌面应用策略:能锁住什么、锁不住什么、链路在哪断

2026-08-04

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

OpenWork 的桌面策略是一层产品护栏,不是安全边界。 它的判定发生在客户端渲染层,配置缓存在 localStorage,网络失败或用户没登录 Cloud 的时候,代码路径会退回一个空配置对象——空配置意味着一条限制都不生效。你如果打算拿它来”管住员工机器上那个应用”,先把这句话记下来,后面每一节都在解释这句话的具体形态。

这里说的 OpenWork 是 different-ai 的开源桌面应用项目(仓库 https://github.com/different-ai/openwork ),跟同名的职场点评网站、跟”开放工作”这类泛指没有关系,下文出现”OpenWork”一律指这个仓库里的东西。

本站已经写过几篇相邻的话题,分工先说清楚:AI 工具的团队治理 讲的是组织层面该定哪些规矩,Agent 权限该给多大 讲的是授权范围的取舍原则,AI 变革管理 讲的是推行节奏;这篇不重复那些,只做一件事——把某个具体开源项目的策略实现拆开,看它的机制到底能承载多少治理意图。

一、这套策略要解决的问题,以及它明确不解决的

先看这个项目自己怎么定位。仓库 README 这样写:OpenWork 是一个免费开源的桌面应用,用来共享 AI 工作流,是 Claude Cowork 和 Codex 在 macOS、Windows、Linux 上的开源替代;同时它强调桌面应用不是必需的,你可以从已有的 agent 里通过一个 MCP 接入,README 里给出的两个工具是 search_capabilities(找出你能用什么)和 execute_capability(执行)。这是项目方的自我定位,不是本文的结论,但它决定了策略机制要面对的局面:同一套能力有多个入口,桌面应用只是其中一个。

于是策略要解决的问题变得很具体:员工在自己机器上装了这个桌面应用,登录了组织的 Cloud 账号,管理员希望在应用界面里关掉一部分口子——比如不让他自己加模型供应商、不让他开第二个工作区、不让他跳到实验版本。

它不解决的问题同样具体:它管不到你不通过桌面应用做的事,管不到没登录 Cloud 的那台机器,也不承担”防止有动机的用户绕过”的职责。这个取舍不是缺陷,是位置决定的——判定代码跑在用户自己的进程里,就不可能变成硬边界。

二、策略目录:八个键与它们的落点

策略的权威清单在 packages/types/src/den/desktop-policies.ts,导出的常量叫 desktopPolicyDefinitions。文件顶部的注释写得很直白:新增策略项要先改这里,然后选一个对老组织安全的 defaultValue,再把桌面端行为接到配置 hook 上;并且明确要求不要手改由 ID 列表生成的 desktopPolicyValueSchema

这份目录当前有八项,全部是布尔值,命名统一走 allow 风格:allowCustomProvidersallowZenModelallowMultipleWorkspacesallowControlSettingsallowManageExtensionsallowBuiltInExtensionsallowAlphaUpdatesshowWelcomePage。语义在架构文档 docs/desktop-app-policies.md 里定死了:false 表示功能受限或禁用,true 或者缺省表示应用不应该在本地拦这个功能。

判定函数只有一行有效逻辑,在 apps/app/src/app/cloud/desktop-app-restrictions.ts

export function checkDesktopAppRestriction(input: {
  config: DenDesktopConfig | null | undefined;
  restriction: DesktopAppRestrictionKey;
}) {
  return input.config?.[input.restriction] === false;
}

严格等于 false 才算受限。配置对象是 null、是空对象、键不存在、值是字符串 "false",通通不受限。这个设计是刻意的——它保证任何配置读取失败都倒向”不拦”,代价是拦截能力天然脆弱。

把这几块摆在一起看:

组成部分它负责什么对应仓库位置你什么时候会碰到它
策略目录与合并算法定义八个策略键、默认值、文案,并提供有效策略计算与提示卡挑选函数packages/types/src/den/desktop-policies.ts想知道到底有哪些开关、组合起来是什么结果时
Cloud 侧下发端点按调用者的活跃组织算出有效策略并返回,同时带上版本白名单与品牌字段ee/apps/den-api/src/routes/me/index.ts/v1/me/desktop-config排查”管理台改了但客户端没变”时
服务端策略求解从组织的策略表、成员/团队/角色分配关系里查出候选策略再合并ee/apps/den-api/src/desktop-policies.ts想搞清楚分配给团队和分配给个人怎么叠加时
桌面端配置提供者读缓存、发请求、比对差异、按事件与定时刷新,并把结果交给 hookapps/app/src/react-app/domains/cloud/desktop-config-provider.tsx排查生效延迟、缓存残留、断网行为时
判定与模型级辅助把配置翻译成”这个功能是否受限""这个模型是否被挡”apps/app/src/app/cloud/desktop-app-restrictions.ts读某个界面为什么灰掉时
版本闸门比较版本号、按组织允许的版本清单筛选可升级目标、处理 alpha 通道apps/app/src/app/lib/version-gate.ts做灰度和版本冻结时

表里的路径都是这次实际打开读过的文件,你可以照着在仓库里核对。

三、下发链路:从 Cloud 到你机器上那一次渲染

链路本身不复杂,但每个环节都有一个”失败时怎么办”的分支,值得逐段过。

服务端这一段在 ee/apps/den-api/src/desktop-policies.tscalculateDesktopPolicyForOrgMember()。它先查组织下未删除的全部策略,如果一条都没有,直接返回 allDesktopPolicies(true)——全放开。有策略的话,取出启用中的默认策略,再按三个维度找分配给这个人的策略:直接分配到 orgMemberId、分配到他所在团队的 teamId、分配到他的角色 role。查出来的结果按 id 去重,然后交给合并函数。

客户端这一段在 DesktopConfigProvider。它的顺序是:先算缓存键,形如 openwork.den.desktopConfig: 加上 baseUrl 与活跃组织 id,也就是说不同的 Cloud 地址、不同的组织各存一份;先把缓存里的配置应用上去,再发 HTTP 请求拿最新的。文件里的注释解释了为什么要先读缓存——避免被门控的界面在等待网络的这段时间里”闪一下未受限状态”。

刷新时机有三类:登录状态或 Den 设置发生变化时(监听 denSessionUpdatedEventdenSettingsChangedEvent),每小时一次的定时器(常量 DESKTOP_CONFIG_REFRESH_MS60 * 60 * 1000),以及调用方主动调 refresh()refreshFresh()。产品文档 packages/docs/cloud/share-with-your-team/desktop-policies.mdx 给管理员的说法与此一致:保存策略后,让成员重新加载、刷新 Cloud 账号、切换活跃组织,或者等每小时的刷新。

现在看失败分支,这是本节的重点。请求抛异常时,代码回落到 cached ?? DEFAULT_DESKTOP_CONFIG,而 DEFAULT_DESKTOP_CONFIG 就是 {}。同样,当发现没有登录、没有 token、或者没有活跃组织 id 时,代码直接应用 DEFAULT_DESKTOP_CONFIG 并结束。结合上一节那个严格等于 false 的判定,结论是清楚的:没缓存过策略的那台机器,只要断网或者退出登录,八个开关全部失效。

链路里还有一个恢复分支值得知道:如果服务端回 404 且错误码是 organization_not_found,客户端会调 ensureDenActiveOrganization({ forceServerSync: true }) 去重新同步组织,让下一次刷新打到有效组织上。

应用侧读策略的入口,架构文档给了明确的优先级:大多数功能门控用 useCheckDesktopRestriction(),只关心一个键的组件用 useDesktopRestriction(),需要加载态或手动刷新时用 useDesktopConfig(),只有纯粹读原始配置才用 useOrgRestrictions()。文档里的单键示例是这样的:

import { useDesktopRestriction } from "../domains/cloud/desktop-config-provider";

function AddWorkspaceButton() {
  const multipleWorkspacesRestricted = useDesktopRestriction(
    "allowMultipleWorkspaces",
  );

  return (
    <button disabled={multipleWorkspacesRestricted}>
      Add workspace
    </button>
  );
}

一个禁用属性——这就是”锁住”在这套体系里的物理形态。文档同时叮嘱:不要直接读 provider 内部的 ref,那些只用来安全地比较和应用新配置。

四、合并语义、提示卡与版本闸门

合并语义是最容易理解错的一块。calculateEffectiveDesktopPolicy() 的核心是这样:

const calculated = allDesktopPolicies(false);
const policies = [
  normalizeDefaultDesktopPolicyValue(input.defaultPolicy ?? {}),
  ...input.assignedPolicies.map((policy) => normalizeDesktopPolicyValue(policy)),
];

for (const policy of policies) {
  for (const key of desktopPolicyKeys) {
    if (policy[key] === true) {
      calculated[key] = true;
    }
  }
}

从全 false 起步,任何一条参与计算的策略把某个键置为 true,结果就是 true。架构文档把这称为 OR 并集。这意味着多分配一条策略只会更宽松,不会更严格。你想给某个团队”额外收紧”,靠再挂一条策略是做不到的;收紧只能通过让默认策略与所有会命中这个人的策略都不给 true 来实现。这是我认为整套设计里最需要写进内部运维手册的一条。

提示卡走的是另一套语义。策略文档里可以带 onboardingPromptsonboardingPromptDescriptions,共享 schema 要求 2 到 3 条,每条 prompt 上限 500 字符、每条描述上限 120 字符。选择函数 selectEffectiveOnboardingPromptConfig() 不做并集,而是按优先级 priority 从高到低、createdAt 从早到晚、最后按策略 id 排序,取第一条有效的定向配置;没有定向配置时才回落到默认策略。在桌面应用里,描述会变成提示卡的标题,prompt 变成卡片上可见的描述文字,点击时把这段文字填进输入框——文档特意说明,点击不会自动发送。

版本闸门是第三套语义。allowedDesktopVersions 是下发响应的一部分,但按架构文档的说法,它不是 desktopPolicyDefinitions 里的布尔策略项;在服务端它来自组织的 metadata。客户端的处理在 version-gate.tsisUpdateAllowedByDesktopConfig() 在这个数组没配置时一律放行,配置了就要求候选版本与清单里某一项按版本号比较相等。selectStableDesktopUpdate() 返回的三种结果——updateblockedcurrent——把”有新版但组织不批”这种状态显式表达了出来。alpha 通道另有规则:allowAlphaUpdatesfalse 时,resolveDesktopUpdateChannel() 会把 alpha 降回 stable;允许 alpha 时,代码注释说明 alpha 构建可以比当前上限高一个 patch,更大的跨度仍然会被挡住,防止用 alpha 通道整体绕过分批放量的版本上限。

注意版本闸门管的是”能升到哪个版本”,不是”现在跑的是哪个版本”。已经装在机器上的旧版本,这套机制不会把它降下去。

五、边界与代价:它明确不管的事

第一,目录里有键不等于应用里有闸。 我把八个键在仓库里逐个搜过:allowCustomProvidersallowZenModelallowMultipleWorkspacesallowBuiltInExtensionsallowAlphaUpdatesapps/ 下都能找到实际消费点,比如模型选择、工作区新增、输入框区域、更新通道;而 allowControlSettingsallowManageExtensionsshowWelcomePage 这三个键,在这次梳理的提交里除了目录文件本身之外搜不到任何引用。管理台会照着目录把开关渲染出来,管理员可以点、可以保存、可以在下发响应里看到它,但桌面端没有对应的拦截点。如果你的管控清单依赖”禁止改设置”或”禁止管理扩展”,就不能只看后台有没有这个开关。

第二,判定在客户端,绕过成本很低。 配置缓存在 localStorage,判定在渲染层,桌面应用本身是开源的。这不是攻击手法的讨论,是定位的必然结果:任何跑在用户进程里的检查都只能拦住”按正常路径操作的人”。真正的硬边界只能放在服务端——凭据发放、网关准入、审计。想清楚这条界线在哪,可以参考 API 密钥的安全管理 里那套”控制权在谁手里”的思路。

第三,它管不到桌面应用之外的入口。 README 讲得很清楚,这套能力可以通过一个 MCP 接到 Codex、Claude Code、Cursor 等客户端里用。桌面策略这一层,按名字和实现都只作用于桌面应用。你在桌面端关掉自定义供应商,不等于这个人在别处不能用自己的模型密钥。

第四,下发通道顺带能改的东西比你想的多。 同一个响应里除了策略键,还有 brandAppNamebrandLogoUrlbrandIconUrlbrandAccentColor 这些品牌字段,客户端拿到之后会改文档标题、调用本地接口去换应用图标和应用名,并把结果同步进本地的引导配置文件,防止清空后又从旧快照恢复;还有一个 connectEnabled 会被推送给本地的 OpenWork 服务端。这些都是登录组织后自动发生的。做决策时要如实告诉员工:登录组织账号意味着组织能改这台机器上这个应用的外观与一部分开关状态。

第五,凭据与授权的暴露面要单独评估。 这类工具会在你机器上装桌面应用、代管模型凭据、连上第三方服务授权与团队控制面。策略机制本身不解决”凭据集中保管之后谁能看到什么”的问题,那是另一套需要单独审的东西。

第六,许可证是分层的,讲团队控制面时不能笼统说成 MIT 开源。 仓库根目录的 LICENSE 写明:/ee 目录下的全部内容按 ee/LICENSE 定义的许可证(根 LICENSE 称之为 Fair Source License,ee/LICENSE 文件头写的是 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT,Copyright 2026 Different AI Inc),其余部分才是 MIT(Copyright 2026 Different AI),第三方组件按各自原始许可证。而这篇文章里的 Den 控制面——ee/apps/den-apiee/apps/den-web——正好都在 /ee 下面。也就是说桌面端与共享类型包是 MIT 那一侧,组织策略的服务端是另一侧。本文不提供法律意见,能不能商用、能不能改,一律以许可证原文为准。

顺带一提,仓库里的 docs/enterprise-plan-gating.md 记录了一套按套餐门控的设计,状态写的是”实现在 DEN_PLAN_GATING_ENABLED 后面,默认关闭”,原则是只对写操作设门、读取与删除永不设门,GET /v1/me/desktop-config 被明确列入永不门控的清单。这是文档里这么写的,具体规则会调整,以官方最新说明为准。

六、上手与避坑清单

别把”多加一条策略”当成收紧手段。 会踩是因为大多数策略系统是”取交集”或”后者覆盖”,而这里是 OR 并集,直觉正好反过来。避法:改策略前先在 packages/types/src/den/desktop-policies.ts 里把 calculateEffectiveDesktopPolicy() 读一遍,把”这个人会命中哪几条策略”列全,任何一条给了 true 就等于放开;要收紧就去改会命中他的每一条。

别忽略”组织一条策略都没有”这个状态。 会踩是因为它不返回默认值,而是直接返回全 true。避法:新组织上线时先确认默认策略已经创建(服务端有专门的确保默认策略存在的逻辑,默认策略名是 Default desktop policy),别指望”没配置就是最严”。

别在改完策略后立刻找员工验收。 会踩是因为客户端先用缓存渲染,网络刷新是异步的,且定时刷新周期是一小时。避法:让对方按产品文档说的做——重新加载、刷新 Cloud 账号或切换活跃组织;排查时优先怀疑缓存,缓存是按 baseUrl 加活跃组织 id 分桶存的,切组织不会串。

别把”后台能开这个开关”当成”这个行为被管住了”。 会踩是因为策略目录和实际消费点是两处,中间没有编译期约束逼你补齐。避法:把你依赖的每个键在 apps/ 下搜一遍,搜不到消费点就当它不生效;上线前用一台真机逐条走查界面,而不是看后台勾选状态。

别用它做版本回退。 会踩是因为 allowedDesktopVersions 名字听起来像”允许运行的版本”,实际参与的是升级目标筛选。避法:把它当作灰度与冻结工具,冻结期间新装机器仍需靠分发渠道控制;packaging/ 目录下当前提供三种分发方式(aurdockerhelm),真正的版本准入要在分发这一层想办法。

别忘了断网与登出这两个状态。 会踩是因为测试通常在联网且已登录的环境做。避法:验收清单里必须包含”拔网线后行为如何""退出 Cloud 登录后行为如何”,如果这两种状态下的放开是你不能接受的,那这套机制就不适合承担你的管控目标,需要往服务端挪。

别跳过端到端验证。 仓库里有 evals/ 这一层,其中 evals/flows/desktop-policies-cloud-mcp.flow.ts 就是围绕桌面策略与 Cloud 侧改动的流程;apps/app/tests/ 下也有 den-desktop-config.test.tsversion-gate.test.ts 这类针对配置归一化与版本闸门的测试。避法:自己做二次开发时照着这些跑一遍,比读代码更快确认语义。

收束

判断这套机制适不适合你,问三个问题就够:你要拦的行为,在 apps/ 下能不能找到对应的消费点;你的合规要求能不能接受”断网即放开”;你要收紧的那个人,会命中的所有策略里有没有哪一条给了 true。三个都过得去,它是一层成本很低、体验也算克制的护栏——限制不是静默失效,而是有专门的提示界面告诉用户这是组织控制的;三个里有一个过不去,就别在这层耗时间,直接去服务端找边界。

想继续往下读,按这个顺序:docs/desktop-app-policies.md 建立整体印象,packages/types/src/den/desktop-policies.ts 看清语义,apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx 看清链路与降级,ee/apps/den-api/src/desktop-policies.ts 看清服务端怎么求解。四个文件读完,你对这套东西的判断会比任何介绍文章都准。至于工具本身该怎么在团队里推、权限边界怎么划,回到 AI 工具的团队治理Agent 权限该给多大 那两篇。

本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 开源桌面应用 OpenWork 接入企业身份:SSO 与 SCIM 分工自建 OpenWork 这个开源桌面应用:容器与 Helm 两条路,先算清四笔账

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