产品哲学
ZeoKeel Harness 不是「一堆功能的集合」,而是一组有明确边界的设计取舍。这一页把七条原则写清楚,并且每条都给出本仓库里可核对的证据(配置项、代码路径、可观察行为),最后列出这些取舍各自的代价——没有代价的取舍通常意味着还没想清楚。
判断标准很简单:如果一条原则在代码里找不到落点,它就不该出现在这一页。
一、一切皆插件:桌面本身就是插件
Section titled “一、一切皆插件:桌面本身就是插件”原则:整个产品与上游走同一条 Cordis 插件组合路径,不另起一套插件系统、不做整体分叉。
证据
- 上游以固定版本放在
deepseek-harness/(git submodule),真源是仓库根的upstream.json(repository/commit/sourceVersion)。 - 桌面壳是一个普通插件行:
packages/host/desktop/cordis.patch.yml用- insert:声明desktop-shell(dsh-plugin-desktop)以及desktop-diagnostics、desktop-updates、desktop-loop-notify等行;企业能力同样是行:packages/host/enterprise/cordis.patch.yml声明zeokeel-enterprise、zeokeel-session、zeokeel-auth-gate等。 - 界面能力通过上游的 slot 机制注入,服务能力通过 service contract 提供;桌面对第三方公开的只有
desktopRuntime.registerTrayItem/desktopActions这类明确契约(packages/host/desktop/docs/plugin-services.md),窗口、托盘、打包器内部不是第三方 API。 - 因为桌面壳是插件,「替换上游某一行」这件事是配置级的:
packages/host/desktop/cordis.patch.yml里既有disabled: true(关掉不需要的上游行),也有整行config覆盖。
代价
- 公开契约(slot、service、子路径导出)成为兼容面:改它们等于改 API,需要走决策记录与判据。
- 上游默认值的变化会直接进入产品组合。升级时必须逐条复核「哪些行默认启用了、默认值是否翻转」,而不是假定「我们没改就不受影响」(
docs/decisions/下的升级决策记录就是为这件事存在的)。
二、控制面必须放在服务端
Section titled “二、控制面必须放在服务端”原则:凡是「可被滥用」的能力——密钥、模型目录、计量计费、内容授权、审计——一律收口到服务端;客户端只负责把它们画出来。
证据
- 命名空间把两侧职责写死:
server/internal/router/router.go集中声明/api/server/*(管理面,AdminAuth+ RBAC 逐路由申报)与/api/client/v2/*(员工面,Bearer),/v1/*是模型网关。 - 上游密钥只在服务端:
server/internal/util/crypto.go用 AES-GCM 加密(密文前缀enc:v1:),主密钥是独立文件master.key(0600,永不入库);客户端拿到的只是它自己的会话令牌,模型端点是<服务端>/v1。 - 计量与余额是硬闸门:
server/internal/llmgateway/balance_gate.go在余额不足时返回429 BALANCE_EXHAUSTED(管理员豁免;读设置失败 fail-closed),未定价模型默认429 MODEL_NOT_PRICED,可用策略开关显式放行;usage 与余额扣减同事务。 - 审计是防篡改的:
server/internal/serverstore/audit.go写入带prev_hash的哈希链,篡改任意一行会打断后续所有链节。 - 授权在服务端判定,且不泄露存在性:市场/组织内容未授权一律 404(
server/internal/marketplace/的分类用例把「依赖故障」与「不存在」分开,后者必须保持 404 同形)。 - 客户端没有本地模型配置入口:企业组合里
ui-settings-models被disabled: true,上游llm-deepseek行被禁用并替换为自研的网关 provider 行(packages/host/enterprise/cordis.patch.yml),请求头由网关令牌决定。
代价
- 服务端不可达就没有模型:未登录时客户端没有可用的模型路由。这是「密钥只在服务端」的直接结果,不是故障。
- 服务端成为必须运维的一层:账号、余额、模型目录都靠它,客户需要承担它的部署、备份与升级(见私有化部署)。
- 客户端侧的一切「聪明」都要压制:任何本地豁免、本地定价、本地授权判断都会与这条原则冲突。
三、客户端只信任它登录的那台服务端
Section titled “三、客户端只信任它登录的那台服务端”原则:客户端的外部信息来源只有一个——它当前登录的那台服务端;渠道身份由服务端在结构上决定,客户端不携带渠道身份。
证据
- 更新源是会话派生的:
packages/host/desktop/src/updates.ts监听zeokeel/session-changed,从会话里取serverURL;serverURL === null(未登录)时没有更新源,不做任何外发检查。换服务端会让在飞的检查与下载整份作废(代际计数),「已下载待安装」的安装包被撤回。 - 清单与安装包来自同一台服务端:
GET /api/client/v2/updates/manifest与/updates/client/*都在服务端(server/internal/clientrelease/),客户端不访问更新服务器、不访问 GitHub。 - 渠道对账是硬判据:清单里的
channel_id必须存在,且必须与服务端/api/client/v2/channel自报的渠道 id 精确相等;不一致直接判清单非法、不重试。 - 模型链路同样绑定会话:
packages/host/enterprise/src/gateway-model.ts把baseURL设为${session.serverURL}/v1;登录页、渠道内容、模型目录、能力中心与连接器目录都来自这台服务端。 - 客户端与它登录的服务端绑定还体现在数据面:连接器凭据按(账号 + 服务端地址)作用域存储,换服务端不会复用上一台的凭据。
代价
- 没有「官网直接下载客户端」这条路:安装包由服务端下发(客户端分发与升级),这也是公开渠道镜像里必须带
client/的原因。 - 客户端无法被指向别处的更新源:想换版本来源只能换服务端,这是有意的(防止「客户端被洗成另一个渠道」这类品牌与数据根错配事故)。
- 换服务端或换账号是「换一整套上下文」:在飞状态整份丢弃,用户要重新登录并重新授权连接器。
四、默认拒绝
Section titled “四、默认拒绝”原则:任何「顺手就放行」的默认值都被反过来——文件效果、进程、出站、本地写面、渲染进程权限,默认都是拒绝,要放行必须有人或显式配置点头。
证据
- 权限是两个旋钮(沙箱 + 审批)捆成的预设,上游
packages/interaction/permission-presets的默认表只有workspace-write(+ 审批ask)与danger-full-access(+ 审批never),新会话默认就是前者;审批是逐次的,没有「总是允许」。 - 沙箱按平台落地:Linux 走 Landlock、Windows 走目录 ACL、macOS 走 Seatbelt;Windows 上
workspace-write需要先给工作区根写 DACL,拿不到就 fail-closed(该工作区每条命令都起不来),报错明说这是目录 DACL 而不是命令语法问题。 - 本地写面要求持有性证明:
packages/host/desktop/src/write-proof.ts对非 GET 请求校验上游connection的dsh-auth-*cookie,证明不足返回 403,证明机制缺席返回 503——绝不放行。 - 连接器出站是 fail-closed 的:
packages/host/connectors/src/outbound.ts只允许 https(或回环 http),拒绝私网/链路本地/元数据地址,redirect: 'manual'不跟随 3xx;域名解析不可验证(解析失败、超时、空答案)与解析到非公网同等拒绝,并把已校验的地址钉给连接使用,杜绝「判完了再解析一次」的 TOCTOU。 - 渲染进程默认没有任何权限:主窗口的权限处理器对非本应用顶层文档一律拒绝,只对本安装回环源的顶层文档放行剪贴板(内嵌浏览器与应用窗口各有自己的权限面)。
- 出站代理默认禁止:
packages/host/desktop/src/network-policy.ts忽略系统代理、代理环境变量、PAC 与--proxy-server;退路只有渠道字段desktop.allow_system_proxy或真实进程环境变量ZEOKEEL_ALLOW_SYSTEM_PROXY=1。 - 服务端侧同样是默认拒绝:项目内容未授权不可见(404 不泄露存在性),管理面每条路由都要申报权限点,登录限流只对失败计数(成功即清空)。
代价
- 交互上更啰嗦:写文件、跑命令、操作浏览器都要确认;审批没有「记住这次选择」。
- 部分环境需要显式配置才能用:Windows 上要给工作区目录正确的 DACL;「只有认证代理能出公网」的网络要打开代理逃生门;某些边界情况会故意失败(例如域名解析无法验证时拒绝连接器出站),而不是降级放行。
- 排障时需要区分「拒绝」与「故障」:拒绝都有明确错误码与文案(如
403持有性证明、429 BALANCE_EXHAUSTED、连接器拒绝原因),而故障是另一类信号。
五、数据留在客户自己的机器上
Section titled “五、数据留在客户自己的机器上”原则:敏感数据的默认位置是员工机器与客户自己的服务器,厂商不在数据路径上。
证据
- 本机数据只有一个权威目录:
packages/host/desktop/src/desktop-home.ts定义 Harness home(~/.zeokeel-harness,DSH_HOME优先,渠道不同则目录不同),会话、设置、工作区、技能都落在它下面;平台会检查它是否落在系统关键目录上,拒绝把会话令牌写进攻击者可读的位置。 - 连接器凭据落在按(账号 + 服务端)分隔的作用域目录(
packages/host/connectors/src/user-scope.ts),目录0700、文件0600、原子写入,防符号链接与路径逃逸。 - 本地 Harness 服务只监听
127.0.0.1;会话正文、工作区文件、工具执行、沙箱状态、语音音频都在员工机器上。 - 服务端只保存它必须知道的:账号、模型用量与费用、余额账本、内容与授权、审计事件。它不接触员工本机文件与会话日志。
- 模型请求走客户自己的服务端到客户自己配置的上游 provider:上游 API Key 加密存在客户服务器上,请求内容由客户选择发给哪家 provider——厂商既不托管也不中转。
代价
- 没有跨设备同步:换一台机器不会自动带着会话与工作区走(这是数据主权的直接代价)。
- 员工机器上的数据要自己负责:本机磁盘损坏不会由服务端兜底;服务端侧的备份口径见升级、备份与回滚。
- 想用云端模型就必须由客户自己配置出网与密钥;离线部署时要自己准备模型出口。
六、AI 只提议,人批准
Section titled “六、AI 只提议,人批准”原则:凡是会真实改变 AI 行为或对外可见的写入,先进入待确认 / 待审核队列,由人或管理员拍板后才生效。
证据
- 工具调用逐次审批:写文件、跑命令、操作浏览器、上传都要确认,批准只对这一次有效;沙箱拒绝与审批拒绝都会作为工具错误回到模型,模型看得见「被谁拒了」。
- 能力分发是审核 + 授权双门制:员工上传技能/智能体后进入
pending,管理员 approve 之后还要对被授权人可见可装;未授权一律不可见;归属 = 首个成功占名的发布者。 - 记忆是确认制:vendored 插件
packages/vendor/memory-evolve的默认reviewMode: 'suggest'——AI 通过memory_suggest提建议进待确认队列,用户采纳才写入;待办同理。 - 自动化任务由人创建(时间规则 + 提示词,绑定原会话);模型只能通过显式工具(
schedule_create/schedule_list/schedule_update/schedule_delete)在当前账号可见的任务集合里操作,看不到的任务按「不存在」处理;目录与投递都按账号隔离。 - 应用侧的授权记录是显式的:允许 AI 读取应用数据的同意记录按(用户 + 服务端 + 应用)作用域存储,拿不到作用域就 fail-closed 拒绝写入。
- 浏览器控制权只能由同一个按钮双向交接:「我来操作」取得、「交给 AI」交回;蒙版空白处、活动面板、
Esc都不会改变控制权。
代价
- 多一次点击:AI 不能「自己变得更聪明」,记忆与技能演进要用户确认。
- 有意的能力上限:模型无法绕过审批去「先做了再说」,也无法自行扩张自己的行为输入。
- 需要区分「AI 提议」与「用户意志」:审批卡、待确认队列、审核状态就是这条界线,界面上必须始终可见。
七、上游固定版本,不魔改
Section titled “七、上游固定版本,不魔改”原则:上游 DeepSeek Harness 以固定版本原样运行(当前 pin dsh-v0.2.1-rc.2,真源为仓库根 upstream.json);产品能力通过插件组合与少量带判据的补丁实现,不做整体分叉。
证据
- 上游是 git submodule(
deepseek-harness/),不在此分支上编辑;升级入口是scripts/upgrade-upstream.mjs。 - 补丁数量是少数且逐个登记:
patches/下每个文件都是<包名>@<上游版本>.patch,并由守卫脚本盯着——scripts/verify-patches.mjs(补丁是否真的生效)、scripts/check-patch-pin.mjs(补丁与 pin 是否对得上)、scripts/verify-patch-resolutions.mjs(resolutions 是否登记齐全)、scripts/patch-targets.mjs(补丁目标是否仍然存在)。 - 每次上游升级都留一份决策记录(
docs/decisions/下的升级决策),写明「哪些补丁要重切、哪些上游默认值必须显式关掉、哪些行为变化需要用户可见的说明」。 - 上游行为默认值会被显式改写而不是听任:企业组合里禁用上游的模型设置行与本地 provider 行、桌面组合里关闭产品分析/遥测行与不需要的上游行,都有判据钉住(例如
verify-profile-boot.mjs的mustStayDisabled名单与cordis.patch.yml双向对拍)。
代价
- 升级是有工作量的事件:补丁要重切(目标是哈希命名的产物文件)、默认值要逐条复核、打包与运行时门禁要重跑。
- 不能「顺手拿上游新功能」:新能力要么等升级、要么以插件实现。
- 补丁本身是长期负债:每条补丁都带一条「为什么必须存在」的判据;上游合并了同样的修复时,补丁要被删掉而不是留着。
八、有意为之的取舍与代价
Section titled “八、有意为之的取舍与代价”下表把上面七条原则落成具体取舍。「代价」一列是这个选择必须付的成本,不是待办事项。
| 取舍 | 我们选择 | 代价 |
|---|---|---|
| 交付形态 | 客户端只信任登录的那台服务端,安装包随镜像分发 | 没有公开下载站;换版本来源只能换服务端 |
| 密钥与计费 | 上游密钥只在服务端、AES-GCM 加密;余额是唯一计费闸门 | 服务端不可达时没有可用模型;客户必须自建并运维服务端 |
| 数据位置 | 会话、文件、沙箱、音频留在员工机器 | 没有跨设备同步;本机数据损坏不由服务端兜底 |
| 客户端体积 | 随包 Electron + 固定版本 DSH + Node/pnpm/Python 运行时 | 安装包以百 MB 计,换取「装上即用、零外网依赖」 |
| 升级方式 | 后台静默检查与下载,安装一律用户显式触发 | 升级比「静默重启安装」慢一步,换来不打断本机未保存工作 |
| 出站策略 | 默认忽略系统代理与代理环境变量 | 「只有认证代理能出公网」的网络必须显式打开退路 |
| 插件管理 | 固定 desktop profile,第三方插件走用户补丁层 | 没有 profile 切换与终端入口;改动必须重启才进 Loader 组合 |
| 内容与品牌 | 品牌来自构建期注入的渠道内容,不在管理端编辑 | 改一个文案 = 重新构建该渠道镜像 |
| 白标隔离 | 每个渠道的应用标识、数据根、单实例锁互相独立 | 跨渠道安装是并存而不是覆盖,员工机器上可能出现两个客户端 |
| 应用形态 | 应用只在客户端窗口内打开,服务端不为应用准备公网入口或证书 | 应用不能被浏览器直链分享,只能通过深链在客户端打开 |