系统架构
PicoAide Harness 是一个三层系统:桌面客户端 / 本地 Harness 服务 / 企业服务端。一句话概括职责划分——体验与执行留在员工机器上,控制面与密钥留在服务端。本页写给两类读者:判断「能不能部署、数据在哪、出事怎么定位」的管理员与运维,以及要知道契约边界的二次开发者。逐条端点见 API 参考,部署形态见私有化部署。
三条目标决定了这个形态,也解释了它为什么不是另外几种更简单的做法:
| 目标 | 因此必须 | 因此不能 |
|---|---|---|
| 上游模型密钥不进员工机器 | 模型调用统一经服务端网关转发,密钥在服务端加密存储 | 客户端直连模型供应商(密钥会分发到每台机器) |
| 员工能对真实的项目目录干活 | 本地进程持有工作区、文件系统、子进程与内核沙箱 | 纯网页/SaaS 形态(浏览器拿不到真进程与真文件系统) |
| 企业要能统一管权限、计费与审计 | 认证、授权、计量、审批集中在服务端一处 | 纯本地形态(每人一份配置,无法集中治理) |
这三条同时成立时,唯一可行的形状就是「本地执行 + 服务端控制」。仓库里已登记的相关取舍见本文末的明确的设计取舍。
三层各自的边界
Section titled “三层各自的边界”| 层 | 位置 | 进程模型 | 职责 | 明确不负责 |
|---|---|---|---|---|
| 桌面客户端 | 员工机器 | Electron 多进程(main / renderer,另有独立的浏览器窗口与应用窗口) | 原生窗口与托盘;对话、能力中心、连接器、自动化任务、内置浏览器、语音输入、记忆、应用中心;本地更新检查与安装;权限审批界面 | 不持有上游模型密钥;不决定模型可用性与额度;不决定谁能看到哪份内容 |
| 本地 Harness 服务 | 员工机器(127.0.0.1,默认随机端口,可固定) | 运行在 Electron main 进程内的上游 DSH 宿主(Cordis 插件树),对外只提供 loopback HTTP/WebSocket | 跑固定版本的上游 DSH;承载会话与上下文、工作区与文件、子进程与内核沙箱、工具执行与审批 | 不直连模型供应商(走服务端网关);不上报会话正文 |
| 企业服务端 | 客户内网一台机器 | 单个 Go 二进制(gin)+ PostgreSQL,容器化交付(caddy + server + postgres 三容器) | 认证与 RBAC、模型网关与计量计费、能力中心与审批、连接器目录、应用中心、审计、客户端分发、门户页与管理后台 | 不接触员工本地文件;不执行员工机器上的命令;不代理会话正文 |
┌─ 员工机器 ─────────────────────────────────────────────────────┐│ 桌面客户端(Electron) ││ ├─ 主窗口 ──► 本地 Harness 服务(127.0.0.1,随机或固定端口)││ ├─ 应用窗口(自定义协议 scheme://<app_id>/) ││ ├─ 内置浏览器窗口(独立分区) ││ └─ 托盘 / 通知 / 更新 ││ 本地 Harness 服务 ││ ├─ 固定版本的上游 DSH(Cordis 插件树) ││ ├─ 会话 / 上下文 / 工作区 / 文件 / 子进程 ││ └─ 内核沙箱(Linux Landlock、Windows ACL、macOS Seatbelt) │└────────────────────────────────────────────────────────────────┘
│ HTTPS + Bearer(员工 API 令牌:90 天,服务端只存哈希) │ 模型调用走 /v1/*;业务面走 /api/client/v2/* ▼
┌─ 企业服务端(客户内网一台机器,一个容器镜像) ────────────────┐│ Go 服务端(gin) ││ ├─ 认证:local / LDAP / OIDC + api_tokens + 管理端会话 ││ ├─ 模型网关:/v1/* → 上游 provider(密钥 AES-GCM 加密存储)││ ├─ 计量计费:usage 写入与余额扣减同事务 ││ ├─ 能力中心 / 连接器目录 / 应用中心 / 审批 ││ ├─ 客户端分发:更新清单 + 安装包下载 ││ ├─ 管理后台(内嵌 SPA,/admin/)与门户页(/、/portal) ││ └─ 审计(哈希链) ││ PostgreSQL(容器内置,也接受外部实例) │└───────────────────────────────────────────────────────────────┘- 登录:客户端
POST /api/client/v2/auth/login→ 员工 API 令牌(有效期 90 天,服务端只存哈希)。令牌落在本地数据目录的session.json。 - 启动配置:
GET /api/client/v2/config/bootstrap一次取回默认模型、可用模型清单、共享技能、连接器目录、错误上报配置与服务端版本标识。客户端的模型目录以这一次响应为准。 - 建会话:会话在本地创建,绑定一个工作区目录(绝对路径写进会话头
cwd,随会话持久化)。会话权限在同一时刻按当前默认预设钉进会话日志,之后改默认预设不影响既有会话。 - 工具执行:模型提议工具调用 → 本地按会话的沙箱档与审批策略判定 → 需要确认的弹出审批界面 → 执行发生在员工机器的沙箱里。这一步不经过服务端。
- 模型调用:客户端
POST /v1/chat/completions(或/v1/messages等)→ 服务端鉴权 → 单用户并发闸门 → 限流 → 余额闸门 → 按模型名匹配已启用的上游 provider → 转发。 - 计量与扣费:上游返回后,用量行写入
usage与余额扣减在同一个事务里完成;账本balance_ledger可逐笔对账。 - 员工自查:
GET /api/client/v2/auth/usage返回账户余额与今日/昨日/本月/累计 tokens 与费用。 - 升级检查:客户端启动 60 秒后首次检查、之后每 6 小时一次,问它登录的那台服务端要
GET /api/client/v2/updates/manifest。
谁持有密钥,谁做决策
Section titled “谁持有密钥,谁做决策”| 东西 | 在哪 | 说明 |
|---|---|---|
| 上游模型 API Key | 服务端 | AES-GCM 加密存储(密文前缀 enc:v1:),主密钥为独立文件;永不落明文、永不下发客户端 |
| 员工 API 令牌 | 客户端持有明文,服务端只存哈希 | 90 天有效期;改密 / 降权 / 禁用时同事务吊销 |
| 管理端会话 | 浏览器 cookie,服务端只存哈希 | 12 小时硬上限 + 60 分钟空闲滑动过期;写操作还要 CSRF |
| 连接器凭据 | 客户端本地 | 按用户 scope 存储(0600/0700、原子写、防符号链接);服务端只下发连接器定义,不下发也不回收凭据 |
| 模型可用性与定价 | 服务端 | 管理员在管理后台配置 provider、模型与价格;客户端只消费 bootstrap 下发的清单 |
| 能否调用(额度) | 服务端 | 余额是唯一计费闸门(见管理后台) |
| 谁能看到哪份内容 | 服务端 | 市场/组织内容「审核通过 + 授权」双门制;管理员恒全量 |
| 工具调用是否执行 | 客户端 | 本机沙箱档 + 审批策略;服务端不参与单次工具决策 |
| 应用能否发布 | 两端 | 客户端发起并上传,服务端校验、编译,并按管理端开关决定是否需要审批 |
固定上游 + 插件组合
Section titled “固定上游 + 插件组合”上游 DeepSeek Harness 以固定版本运行(当前 pin dsh-v0.2.1-rc.2,真源为仓库根 upstream.json),仓库对上游只保留少量带判据的补丁(patches/,每条都有对应的回归用例),不做整体魔改。桌面壳本身就是一个合法的 DSH 插件:它不另造一套 renderer IPC 插件系统,也不把 Electron API 暴露给页面,而是把自己作为一行插入上游的 Cordis 插件树。
因此「我们加的东西」与「上游的东西」在同一个装配面上,用同一种机制表达:
| 类别 | 例子 | 说明 |
|---|---|---|
| 上游行(原样) | agent、session、tools、settings、webserver、sandbox、llm 适配器 | 固定版本、按上游语义运行 |
| 我们新增的行 | desktop-shell、desktop-diagnostics、desktop-updates、desktop-loop-notify、desktop-asar-fs、desktop-asar-guidance | 由桌面层的组装补丁插入 |
| 我们新增的 bundle 层 | 九个自有包:@picoaide/dsh-enterprise、-capability-center、-account-card、-wasm-apps、-foot-menu、-wasm-apps-host、-connectors、-browser 与 dsh-memory-evolve;另有上游的实验性语音输入等 bundle | 自有包各带一层组装补丁,加上桌面包自身的 cordis.patch.yml(由 @picoaide/dsh-enterprise 的 bundle 列表首项携带)合计十层,作为 profile 的 bundle 层进入组合,顺序固定 |
| 显式禁用的上游行 | 见下一节 | 关闭都有具体理由,不是「用不到就关」 |
被显式禁用的上游行
Section titled “被显式禁用的上游行”| 行 | 关闭原因(摘要) |
|---|---|
hmr | 该行要求存在只有 CLI 会提供的 appReady 服务;桌面宿主不提供它,激活会让整棵插件树在启动时失败 |
session-log-deepseek | 会把会话正文、工具参数与结果、工作区路径随每个请求发给模型供应商——企业交付不可接受 |
session-telemetry-otel | 与上一条同类(默认模式为「用户反馈时导出截至该事件的完整会话前缀」),只是触发条件不同 |
desktop-product-telemetry、product-analytics | 上报端点的 schema 缺省指向厂商自建收集器,事件含设备与使用行为;且缺一个必需环境变量时该行直接构造失败 |
deepseek-account、account-controller、llm-deepseek-account | 走的是「DeepSeek 账号鉴权」路线;本产品的模型走企业网关,账号与余额面是自有面板 |
ui-sidebar-browser | 用 iframe 承载外部页面,而窗口 CSP 没有 frame-src,回退到 default-src 后必然白屏;桌面提供自己的浏览器入口 |
ui-plugin-manager | 占用 main 与侧栏面板槽位,与桌面自有的四个整页面板接管方式不互通 |
office-to-pdf | 会拉入仓库外的 npm 包与五个平台引擎包,而打包清单对它们零覆盖,其中 macOS 侧引擎是无扩展名可执行文件,会留在 app.asar 内无法启动 |
fs-sandbox | 由桌面自己的文件系统后端替换(保留沙箱围栏并增加 asar 链接展开) |
ui-layout | 桌面高级模式自己持有根框架与 layout 服务;上游禁止同一子槽被二次声明 |
ui-settings-plugins | 桌面不提供上游插件设置选项卡(插件写面是桌面自有的 bundle 启停服务) |
ui-settings-models | 上游模型设置页允许用户自建 provider;本产品的模型目录只在登录后由网关下发 |
llm-deepseek | 替换而非弃用:由自有的网关适配器行接管同一条 provider 路由。两行必须成对——只关不插则没有任何插件注册该路由,模型面全灭;只插不关则两个适配器抢同一条路由 |
此外 Windows 上还有三处实现替换(不是简单关闭):目录选择器改为桌面自有的浏览式实现;pwsh-sandbox 与 agent-preset-registry 换成桌面自有的 Windows 实现(前者因为平台不提供所需 PTY)。
连接器、内置浏览器、应用宿主与记忆这四个自有 bundle 层没有禁用任何上游行——它们只新增行与槽位(自动化任务已改用官方 Schedule,不再有自有 cron 层)。
profile 固定为 desktop 的含义
Section titled “profile 固定为 desktop 的含义”- 产品只管理一个 profile:没有 profile 选择器,也没有 Web 默认项;
- 上游保留了这个名字:CLI 会拒绝
dsh --profile desktop与dsh plugin --profile desktop(提示该 profile 由 Electron 应用独占管理); - 第三方插件因此不通过 CLI 安装,而是写进用户补丁层
~/.picoaide-harness/cordis.patch.yml(数据根随渠道而变,见渠道与白标)。启动器每次启动都会合并这一层; - 插件变更需要重启客户端才进入组合:桌面的插件写面是自有的 bundle 启停服务(状态落在用户数据目录的
plugin-management/下),不依赖上游的 profile 热更新; - 本地服务只监听
127.0.0.1;端口默认由系统随机分配(避免冲突),需要稳定 origin 时可在设置里固定。
会话与上下文
Section titled “会话与上下文”会话绑定工作区
Section titled “会话绑定工作区”每条会话绑定一个工作区目录:绝对路径写进会话头(cwd)并随会话持久化。工作区之外的文件默认不可写——这是沙箱的事实来源,不是提示。会话、文件与工作区都在本机,服务端不参与,也不持有它们的副本。
权限在会话里钉住
Section titled “权限在会话里钉住”会话的权限是会话级事实,存在会话自己的事件日志里,而不是设置里的一项全局开关:
- 权限由两个独立旋钮组成——沙箱档(
read-only/workspace-write/danger-full-access)与审批策略;对用户呈现为「只读 / 工作区内修改 / 完全权限」等预设; - 新会话创建时按当前默认预设写入这两个事实;此后改设置里的默认预设只影响之后的新会话;
- 会话内切换用
/permission命令或输入框的权限控件,且只写事件——切换本身就是一条日志事实,没有旁路修改状态的路径; - 因此「我改了默认权限,新建会话却又跳回去」的观感,根因通常是上游会复用同一工作区里已有的空白会话,而那条会话带着创建时钉死的旧权限。绕法是在会话内切换,不要靠新建会话。
上下文与工具审批
Section titled “上下文与工具审批”审批不是「模型申请权限」,而是每一次有风险的工具调用都要经过的一道门:
- 模型可以提议调用任意工具,但真正执行前先过权限判定;
- 需要确认的调用会向用户提问,界面给出该调用的参数与可选的授权范围;
- 审批策略为「自动拒绝」时,需要审批的动作会被拒绝而不是放行——方向是 fail-closed;
- 审批记录与会话日志一起留在本机,服务端看不到。
「AI 只提议,人批准」这条设计体现在三处,缺一不可:工具调用要人批准;自动化任务由人查看与管理(模型可以创建作业,但作业的启停与删除都在界面上可见可控);应用发布要人确认,并按管理端开关决定是否再经管理员审批。它不是一句产品口号,而是由权限预设、审批策略与审批界面共同实现的机制约束。
桌面客户端 企业服务端 上游供应商 │ │ │ │ POST /v1/chat/completions│ │ │ Authorization: Bearer ──►│ 鉴权(服务端只存令牌哈希) │ │ │ 单用户并发闸门 │ │ │ 限流 │ │ │ 余额闸门(唯一计费闸门) │ │ │ 按模型匹配已启用 provider ──────►│ │ │ ◄──────────── 流式/非流式响应 ──│ │ ◄──────── 转发 ──────────│ usage 写入 + 余额扣减(同事务) │- 模型清单从哪来:
GET /api/client/v2/config/bootstrap。它只列出已启用 provider 下、且未被标记为目录缺失的模型,并带上每个模型的输入模态(input_modalities)——客户端据此决定是否允许粘贴图片。管理后台是唯一配置面。 - 密钥在哪:上游 API Key 以 AES-GCM 加密存在服务端(密文前缀
enc:v1:,主密钥为独立文件,也可用环境变量注入)。客户端只持员工令牌。 - 限流与闸门在哪一层:全部在服务端网关这一层,顺序是「鉴权 → 单用户并发闸门 → 限流 → 余额闸门 → 上游」。客户端不做额度判断(判断了也不算数)。
- 余额闸门(唯一一条):余额账户已开通、总闸门开启、且分位口径余额不足时返回
429 BALANCE_EXHAUSTED;管理员豁免;余额查询失败 fail-closed。未开通余额账户(从未入账)的账号既不扣余额也不被拦——避免出现「显示 0 却能用」的矛盾界面。 - 未定价模型:默认
429 MODEL_NOT_PRICED,由管理员用策略开关显式放行,而不是默默按 0 计费。 - 计费口径:
usage.cost记录时定价(改价只影响之后的请求),高峰窗口外按模型的峰谷折扣折算;余额与用量在同一个事务里落账。
客户端分发链路
Section titled “客户端分发链路”客户端安装包随服务端镜像发布(镜像内 /opt/picoaide/client/),由服务端自己对外提供。于是客户端从它登录的那台服务端取包:员工机器不需要访问外网,客户端版本天然跟随服务端版本——「客户端升了服务端没升」在结构上不可能发生。
清单来自 GET /api/client/v2/updates/manifest,形状是 {schema, channel_id, server:{version}, client:{version, assets}};资产按平台键索引(mac-universal / win-x64 / linux-x64),每项含 url、sha256、size。给不出绝对 https 下载地址时,清单明确返回「客户端不可用」的原因,而不是降级下发一个 http 链接。
客户端侧逐项校验,任一不过都不安装:
| 判据 | 失败行为 |
|---|---|
schema 必须等于 1 | 整份清单视为不可用 |
channel_id 必填,且与预期渠道精确相等 | 拒绝(品牌客户端装到官方清单会被「洗」成官方客户端,反之亦然) |
| 下载地址必须是绝对 https | 拒绝 |
sha256 必须是 64 位十六进制,下载后流式校验 | 拒绝并删除已下载字节 |
| 平台容器魔数(如 DMG 尾部标识、AppImage 的 ELF 头) | 拒绝 |
失败一律保持当前版本:不安装、不降级、不改用其他来源。检查与传输各自有有界重试(清单 3 次、安装包 5 次,带确定性抖动),传输中断按 Range 续传而不是从零开始;安装动作始终由用户显式触发,产品不做静默重启安装。详细的交付与升级流程见客户端分发。
数据与持久化
Section titled “数据与持久化”| 项目 | 事实 |
|---|---|
| 数据库 | 只支持 PostgreSQL。容器化交付内置 PG 18;服务端二进制也接受外部实例(-pg-dsn 为必填参数) |
| 迁移 | server/internal/serverstore/migrations-pg/,文件名形状 NNNN_<描述>.sql(四位编号 + 下划线),服务端启动时自动执行;编号区间 0001–0085(历史上删减过部分编号,因此有连续空档)。迁移是单向的:回滚镜像不能把库降回旧结构,升级前必须先备份 |
| usage 明细 | 按 created_at 做原生月分区(usage_YYYYMM),保留 N 个月可配(默认 6),过期用 DROP PARTITION 秒删;删除明细不影响账本 |
| usage 账本 | usage_daily(按年分区)/ usage_monthly 永久保留,由账本任务从明细幂等重算,因此历史统计不会因为明细过期而丢 |
| 审计 | audit_logs 带哈希链:每行的 hash = sha256(prev_hash | username | action | detail | created_at),prev_hash 指向前一行;写入用 advisory lock 串行化,避免并发插入读到同一个 prev_hash 形成分叉。保留策略可配,裁剪时保留被删批次的最新一条作为锚,链仍可自证 |
master.key | 主密钥优先取环境变量 PICOAI_MASTER_KEY;未设置时在数据目录生成 32 字节随机密钥写入 master.key(权限 0600)。它不入库——这也是「备份必须包含数据目录」的原因:master.key 丢了,库里加密的上游密钥永久无法解密 |
四条扩展路径解决的是四类不同的问题,选错会付出不必要的代价:
| 路径 | 是什么 | 运行在哪 | 权限面 | 适合 |
|---|---|---|---|---|
| 插件(Cordis) | 宿主进程内的代码行 | 客户端本地(Electron main 的 Cordis 树) | 与宿主同等(完全受信) | 需要介入会话、工具、界面槽位或本地资源的能力 |
| 连接器(MCP) | 外部系统的 MCP 服务 | 远端(streamable-HTTP)或本地子进程(stdio) | 只有它自己声明的工具 | 把已有系统的能力接进 Agent;凭据留在本地 |
技能(SKILL.md) | 指令、流程与随附文件 | 会话上下文里被读取 | 不获得任何额外权限 | 教模型怎么做事——不需要写代码的场景 |
| 应用(WASM) | 员工自建的沙箱程序 | 服务端 wazero 执行,客户端独立窗口展示 | 平台注入的封闭能力表 | 需要状态与界面的小工具(数据落在应用自己的库) |
选择建议:先问「要不要可执行代码」。不要 ⇒ 技能;要,且是接外部系统 ⇒ 连接器;要,且是自建的小应用 ⇒ WASM 应用;要,且必须触碰宿主能力(界面槽位、本地文件、子进程、浏览器) ⇒ 插件。四条路径的边界是叠加的而不是互斥的:一个插件可以注册连接器定义,一个技能可以指导模型去调用某个连接器或应用。
明确的设计取舍
Section titled “明确的设计取舍”以下都是仓库里已登记的取舍,不是遗漏:
| 取舍 | 依据 | 代价(认账) |
|---|---|---|
应用没有对外主机名:WASM 应用只在桌面客户端的独立窗口里打开,服务端唯一入口是带员工令牌的 POST /api/client/v2/apps/wasm/:app_id/request | docs/decisions/2026-09-19-wasm-client-internal-origin.md | 员工不能在浏览器里打开应用;升级必须服务端与客户端同版本,旧客户端无法再打开应用。换来的是不需要为应用准备任何域名解析、证书或额外反代站点块 |
客户端默认不使用任何代理:系统代理、代理环境变量、PAC/WPAD 与 --proxy-server 一律忽略 | docs/decisions/2026-09-22-client-system-proxy-ban.md | 「只有经认证代理才能出公网」的部署必须显式开逃生门(渠道字段或真实进程环境变量),而这个开关必须随包给——它生效的时刻早于任何服务端下发 |
| 不做静默安装更新:检查与下载静默,安装一律用户显式触发 | docs/decisions/2026-09-12-client-update-resilience.md | 员工不点就不会升级;换来的是「应用在用户没同意时重启」这类事故不存在。企业侧如需强制,用 MDM/GPO/SCCM 推安装包 |
不整体改用上游的更新框架:只采纳其设计(状态机、sidecar + Range 续传、可重试分类),不引入其依赖 | 同上(四条理由:更新源是登录的那台服务端;macOS 侧该框架只吃 ZIP + blockmap,与已公证 DMG 冲突;Range 续传服务端本来就有;避免第二处跟随上游钉版本的运行时依赖) | 没有差分下载,每次升级下载完整安装包 |
| 不整体魔改上游:只保留少量带判据的补丁 | patches/、docs/decisions/ | 每次升级上游都要重切补丁并逐条复验;换来的是能跟随上游演进 |
| 渠道内容不在管理端编辑:名称、标语、欢迎语、标识与主题色来自构建期注入的渠道内容 | 见渠道与白标 | 改品牌 = 重新构建对应渠道镜像,不能在线改 |
| 数据库只支持 PostgreSQL;迁移单向 | server/AGENTS.md、迁移目录形状判据 | 老库形状转换由迁移自身承担(含就地转换与 fail-loud 自检);回滚只能回滚镜像,不能回滚结构 |
| 余额是唯一计费闸门:员工 token 配额、金额配额与部门预算已下线(列与设置键保留在库中但不再读写) | docs/planning/2026-09-11-balance-quota-consolidation.md | 管理端不再提供「按人设上限」的入口,只能调余额 |
诊断字段不承诺产品行为:bootstrap.server_version 是溯源信息,客户端没有任何消费方;版本检查走更新清单 | server/internal/bootstrap/bootstrap.go 的字段注释与同源用例 | 「服务端升级了、客户端该提示升级」这条链路目前不存在——不能让注释读起来像它存在 |