前言

deepseek-harness(下称 dsh)官方版已经走到 0.1.0-rc.5 并在 npm 公开发布,但它始终是一个「通用产品」:测试套件、质量门、双语文档、示例 demo 一应俱全,产品形态是绑在 127.0.0.1 上的回环 Web GUI。对我而言,官方形态并不合身——我需要的是一个「自己说了算」的个人特化版:仓库删掉不需要的工程负担,把产品面拆成「PC 执行 + 服务器控制 + 手机/管理台展示」三层,同时安全底线一条都不能松。

这一轮 P1 改造的成果,就是把这套想法全部落地:从官方 fork 出个人 Gitee 特化版,完成分账号控制面的 P0/P1(控制面后端、管理台、手机工作台)、桌面壳、局域网直连工作通道、PC→手机文件、主题皮肤、harness_control 工具等一系列工作。这篇文章记录整个 P1 的改造思路与落地细节。

下载体验:P1 改造后的打包版本已发布到 Gitee Releases —— deepseek-harness v0.1.0-rc.7,桌面安装包及相关产物可到该页面直接下载体验。

一、为什么要改造:回环 GUI 的天花板

dsh 的产品 GUI 是一个回环 Web 应用:dsh web 只绑 127.0.0.1,页面和 /api 都在本机。这带来两个问题:

  1. 手机打不开127.0.0.1 只在 PC 上有效,出门在外根本没法驱动 PC 上的 Harness 干活。
  2. 不能直接发布/api 可以驱动 session.prompt,而 prompt 能触发工具,包括 bash——把这样一个 Host 挂上公网、前面再加个登录,本质上是 RCE 级控制面。之前已经做过「本地控制面加固」和「API 浏览器信任边界」来收口这个问题。

所以改造的目标不是「把 dsh web 发布出去」,而是拆成三个不同的产品:

  • 手机:驱动 桌面 Harness 干活(工具、工作区、沙箱都在 PC 上跑),完成后把文件拉到手机。
  • 服务器:一个管理台(Cursot 类),看余额、用量、worker 健康——它 承载 agent 会话。
  • PC:原样的回环强力 GUI,加一个桌面壳。

另外还有一条硬约束:DEEPSEEK_API_KEY 永远留在 PC 上,控制面不能变成密钥柜,也不能变成第二套 Harness 运行时。

二、整体架构:三层平面

1
2
3
4
5
6
7
8
9
10
11
12
手机工作台                   管理台 (control-web)
session / approval / files usage / balance / workers
| HTTPS + WSS(audience=mobile) | HTTPS(audience=dashboard)
+---------------+----------------+
v
控制面 (profile `control`)
^
| 出站 WSS (设备凭据)
|
本地 worker (`dsh web` + control-worker 插件)
|
回环 / 桌面 GUI(完整 Harness)
平面 进程 绑定 audience Agent UI 跑工具
执行 dsh --profile web 127.0.0.1 loopback
控制 dsh --profile control 公网 HTTPS(反代 TLS) server
管理台 apps/control-web 控制面源 dashboard
手机 apps/mobile 控制面源 mobile 是(远程) 否(worker 执行)

关键点:worker 永远是出站拨号。PC 上不开任何入站端口、不绑 0.0.0.0,它主动向控制面建立 /ws/worker 长连接。这样仓库依然可以只绑回环,控制面成为唯一的「信号 + 转发」中枢。同时 token 带 audience:dashboard 令牌拿到的所有转发工作方法(session.*approval.*artifact.*)都是 audience-refused,管理台 XSS 也变不成 session.prompt

三、P0:控制面后端与管理台

3.1 profile control

dsh control--profile control 的启动别名。它只叠 @deepseek-ai/dsh-control-server bundle,不叠 dsh-base——所以控制面进程里没有工具、没有 AgentLoop、没有 Host GUI、没有 composer,GET / 只是一页无 composer 的健康页。默认监听 127.0.0.1:4090--host 允许非回环,因为这个源已经认证了(TLS 由运维的反向代理终结)。这个特例写进了提案,不会放松 profile webWebServer.Config.host

3.2 Token 身份:没有用户名密码

控制面为每台 PC 存储三个秘密的 SHA-256:

  • workerSecret:worker 出站拨号用;
  • dashboardToken:管理台兑换后冻结为 dashboard audience;
  • mobileToken:手机兑换后冻结为 mobile audience。

token.redeem 会按哈希匹配把 audience 冻结在 token 上,dashboard 令牌永远只能看用量/余额/worker,mobile 令牌也只能看自己账户的用量摘要。租户 id 就是 workerId,天然隔离开不同 PC。

权威落地流程是什么?

  1. PC 上 Settings → 插件 → Control plane,填控制面源 URL 和显示名;
  2. 保存时若无已存秘密,POST worker.claim 拿到三件套,写入 $DSH_HOME/control/worker.json(目录 0700、文件 0600,在工作区外);
  3. 已存的秘密除非勾选 Reissue tokens 永不替换;
  4. 手机/管理台粘贴各自 token 兑换;
  5. worker 用 workerSecret 建出站 WSS,每 30 秒心跳并推送用量摘要。

3.3 用量仓库(SQLite)

控制面用 SQLite 当事实表:SCHEMA_VERSION 2、application id 0x44534843。增量 usage 行复用 TokenUsageProjection 字段名,billedInputTokens = uncached input + cache read + cache write;另有 workers 表和最新的 balance 快照。仓库永不接收 API Key——dashboard 看的是 worker 推上来的最后快照而不是自己去查余额。

每个 turn/end 后,worker 插件读 sessionProjectionstokenUsage,减去上一次推送的总数,POST usage.ingest 推送非零增量;每 5 分钟用 ctx.credentials(或进程环境)解析出 DEEPSEEK_API_KEY,调 https://api.deepseek.com/user/balanceredirect: 'error')推 usage.balance.push。失败静默跳过。

3.4 管理台 control-web

apps/control-web 是 Vite SPA(@deepseek-ai/dsh-control-web),只说管理台方法:token.redeem/logout/whoamiworker.list/revoke/describeusage.query/usage.balance。它的 client 里没有 session.*approval.*artifact.*worker.claimusage.ingest

有意思的是多 PC 方案:本地 fleet(localStorage)可以挂多个 dashboard token,每个仓库方法都按「谁认领了那台 PC」的作用域隔离,SPA 把所有挂载 token 的 worker/usage/balance 并集起来展示。页面三块:

  • Overview:本月(UTC)已计费+输出 token、在线 worker 数、每台机器的最后一次余额快照;
  • Usage:图表 + 表格,粒度 day/week/month,可按机器、provider、model、workspace、session 过滤;
  • Machines:在线/离线卡片、挂载另一个 token、从本浏览器摘除、吊销凭据。

离线机器显示最后快照和它的时间戳,明确标注「数据可能陈旧」。

四、P1:手机工作台(mobile work console)

4.1 形态与路由

apps/mobile 是 Vite SPA(@deepseek-ai/dsh-mobile),外面包一层 Capacitorcapacitor.config.jsonwebDir: dist)。当初提案里否决了 React Native——用 Capacitor 包 SPA,以后可以在不改协议的前提下用原生 widget 替换壳。

页面全是 hash 路由:#/login#/devices#/add#/sessions#/browse#/browse/<path>#/session/:id。Android 硬件返回键和手势返回都走这套栈,不是在 session 列表就退出。每个认领的 PC 一个 mobile token,本地存 profile book(dsh-mobile.profiles);主页是 PC 选择器,用 token.whoami + worker.list 判断在线/离线/无效:控制面不可达是 offline,unauthenticated 是 invalid。

4.2 能干的事

手机端从「会话」到「文件」一条龙:

  • workspace.list / browse / roots:浏览 PC 文件夹(手机没有 OS 文件夹选择器,选的就是 PC 上的完整路径;不存在则 session.create 递归 mkdir 再建 workspace);
  • session.list / create / rename / archive / prompt / cancel / steer / models / selectModel / history / stats
  • approval.pending / resolvequestion.pending / resolve(回答 ask_user_question);
  • artifact.list / get:拉取 PC 产物到手机。

发图走 PUT /api/prompt.spool,再在 session.prompt / steer 里引用 { spoolId },图片类型和大小沿用 Host 附件准入(png/jpeg/webp/gif,5 MiB)。任意写入会话 cwd 的能力不上手机

反向:它不能artifact.offer(offer 是桌面点击)、不能 worker.claim、不能碰 Host 特权方法、没有 HMR、不能写 themeAssetsdsh-web-frontend 的 conversation 包也不导入——手机端自己写了一个 transcript.ts,把中继过来的 session/event 帧折叠成本地行。

4.3 会话页:三层壳

打开的会话页是三层结构:pinned 头部(返回、标题、worker 在线状态、语言/归档 overflow 菜单)、唯一滚动的 transcript、pinned composer。

折叠规则:source.kind === 'user'(或缺 kind)→ 用户气泡;其他 user/message → 上下文行;reasoning-delta → 思考块;tool/call + tool/resultcallId 合并成一行;未知事件类型忽略。历史先加载,mux 帧序号 ≤ 当前页最后 seq 的跳过,防止直播 text-delta 覆盖。渲染是本地 markdown 子集(标题、列表、围栏、GFM 表格、内联代码、链接),streaming 时行尾有光标;未闭合的 fence 按代码块渲染到文末;表格在气泡内横向滚动。

细节都很「手机」:所有折叠行 44px 命中区;模型选择/推理强度是底部 sheet;composer 上方常驻 session.stats 占用条(上下文窗口已知时是 meter,外加 cache-hit 与 billed in/out);session.stats 失败保留最后一次好值;visualViewport 高度变化让 composer 顶住软键盘。

4.4 事件流

控制面对 mobile token 开放 GET /api/events.work(query token 或 cookie/Bearer)。握手先发 work/subscribed,没有 live worker socket 则发 work/offline;worker 在线时,session/eventapproval/*question/*broadcastWorkMux 扇出。掉线自动重连 mux。

历史有个坑:session.history 页要 compact 到 1 MiB 的 worker WebSocket 上限里,所以 PC 端去掉工具视图、settled 块和 projections 再翻页,手机端先读 16 条再补 session.modelssession.statsartifact.list。空占位符会等这次读取成功才消失。

五、PC → 手机文件

手机干活干完,文件怎么到设备?方案是 spool + 拉取,不是全量镜像:

  • artifact.list / get:mobile 工作方法;artifact.offer 只属于 worker(桌面点击);
  • 字节不走 RPC 信封,走 PUT /api/artifact.spool(worker Bearer)+ GET /api/artifact.bytes(mobile);下载成功 200 后控制面删掉 blob,转瞬即逝,不是文件柜;每账号并发 spool 上限 8;
  • 路径策略在 worker 侧按会话 cwd 解析:拒绝 .env / .env.* / credentials / $DSH_HOME / cwd 逃逸,拒绝超过 50 MiB;
  • 允许的来源:成功 mutation 工具的 locations(diff 卡片或泛化 edit),加桌面显式「Send to phone」(session.offerToPhone 是回环特权 RPC;host.describe.canOfferToPhone 只在控制面配对时 true)。bash 直接产生的文件不在 chips 里,但 Host RPC 仍可发送 cwd 内任意文件。

六、桌面壳:轻薄原生窗口

apps/desktop 是一个 Tauri 2 项目,Windows 用 WebView2、macOS 用 WKWebView——打包 Chromium(Electron、Wails+Chromium 都被否决)。Host 依然是 Node/Cordis 的 dsh --profile web,窗口只是 OS WebView 包住它,页面 origin 仍是 http://127.0.0.1:<port>,所以 /api、两个下行 WebSocket(events.mux/events.host)、主题皮肤、Client 插件全部和 Chrome 行为一致。

壳以 sidecar 方式拉起 Host(--desktop-parent),从 stdout 读一行 JSON 就绪记录(端口、origin、listen secret——只在父进程内存里),然后导航过去。设计上刻意做成「普通应用」:没有地址栏,窗口标题是 Deepseek(默认名,ui-app-shell 设置里可改),但 F12 能打开的是平台 WebView 检查器,地址、Network 一目了然——发布构建特意启用 Tauri 的 devtools feature,行为和浏览器一致而不是被阉割的 WebView。

关闭按钮 = 隐藏窗口 + 系统托盘(菜单:显示窗口 / 退出;托盘图标创建失败则关闭直接退出)。Windows 安装包是 NSIS(installMode: currentUserwebviewInstallMode: embedBootstrapper,没装 WebView2 的机器也能装),pnpm pack:desktop 会集结 portable Node + pnpm deploy @deepseek-ai/dsh(含前端 dist)进 runtime 再打包。

更新走 Gitee ReleasesappUpdate.check / download 用 Gitee API v5(releases/latest → attach_files → 下载),匹配固定文件名 Deepseek-{version}-windows-x64.exe,同时 attach latest.json 防截断下载;匿名 API 限流时回退 /releases/download/{tag}/latest.json。产品不内置任何个人访问令牌。appUpdate.apply 只在打包环境(DSH_DESKTOP_PACKAGED=1)里启动安装器并退出 Host。

七、LAN 直连工作通道

手机上且和 PC 同一个 Wi-Fi 时,走公网中继其实很亏:每个 prompt、每个流帧、每页 history 都要经过代理绕一圈,还有 1 MiB WebSocket 上限压历史。但发布 dsh web 到 LAN 又绝不允许。折中方案是 work-only 直连 socket

  • worker 在它所真正拥有的每个 RFC1918 接口地址(10/8172.16/12192.168/16;拒绝回环、link-local、CGNAT 100.64/10、公网)上绑一个 HTTP 工作 socket,端口 0 随机;没有 all-interfaces 监听,也不是 Host webserver(不是 3080);
  • 心跳 worker.heartbeat 附带 { lan: [{address, port}], lanKey }——lanKey 是 32 随机字节 base64url,进程本地,SQLite 不存;仓库只在 WorkerHub 内存里持有,socket 断、吊销或心跳省略 lan 就清除;
  • worker.lan.candidates(仅 account/mobile audience)返回该 token 的 accountId 端点和一张 30 秒 HMAC 票据exp.nonce.hmac,HMAC-SHA256(exp.nonce, lanKey));dashboard 令牌 audience-refused;worker A 的 mobile token 看不到 worker B 的地址;
  • 手机在自己的逻辑里:whoami/worker.list 确认在线后,调 worker.lan.candidates,对候选端点 GET /health 做约 2 秒竞速;成功就把工作 unary 和 mux 切到 http://address:port,网络错误或 unauthenticated 再退回仓库源,不登出token.*worker.list/describeworker.lan.candidates 永远走仓库;请求无子网扫描。

一个坦诚的边界:LAN socket 是 HTTP。Capacitor 和浏览器没法在 fetch/WebSocket 上 pin 一个临时自签证书,所以 fingerprint 为空字符串,TLS pinning 留给后面的切片。

八、其他 P1 内容

  • 主题皮肤ui-theme 扩展出 Skin 段(settings.section id skin)。四个二进制槽位(背景 + 按钮三态)存 $DSH_HOME/theme-assets,magic-byte 白名单 JPEG/PNG/WebP/GIF/MP4/WebM(SVG 拒绝;按钮槽拒绝视频),GET/HEAD 支持 byte range 让视频背景能 seek。按钮预设 default/sharp/pill/glass/neon/custom,custom 用 CSS border-image 9-slice(slice+fill+stretch),角落不压扁、边缘可缩放;蓝 whale 图标与设置对话框带 data-dsh-brand 免于被皮肤规则重绘。推理强度控件 effortControl 支持 slider / menu
  • harness_control 工具@deepseek-ai/dsh-tool-harness-control,allowlist 动作集(调会话设置、切模型、开 workspace、建会话、compact、UI focus 等),但永远读不到凭据——credentials、.env、provider key 命名空间不可达。活动模型选择从私有 ApiProxy WeakMap 挪进 @deepseek-ai/dsh-agent-model-selectionctx.agentModelSelections),Host RPC 和工具共用同一引用;UI 聚焦走 Cordis harness/session-focus 事件,ApiProxy 转发为 host/session-focus,客户端 sessions.open
  • Web 搜索流式与 fetch 加固、多模态(手机/桌面)、插件目录清单、上下文占用常显、会话打开与设置 RPC 延迟优化、移动端历史空白/延迟修复、安卓返回栈与桌面静默启动 等一票体验与稳定性项。
  • 打包与部署pnpm pack:mobile-apk 出本地 debug APK(assembleDebug,覆盖黑鲸启动图标、versionName/versionCode 由 package.json 派生;apps/mobile/android/ 保持 gitignored);SSE Deploy 用 pnpm pack:sdpy-control 填出 deploy/sdpy-dsh-control(compose 两个服务:web 放 SPA、api 放仓库,SQLite 与 spool 文件落 /data 绑定挂载)。

九、安全边界回顾

整轮改造最绷紧的就是这条线:

边界 做法
dsh web 只绑回环 profile web 的 host 不松;LAN 是独立 work-only socket
控制面无工具无 composer control 不叠 dsh-baseGET / 只是健康页
dashboard 不能驱动工作 audience 门控 + client 源边界测试
手机不能碰 Host 特权 协议 allowlist + apps/mobile source-boundary 扫描
API key 不出 PC 控制面只存 balance 快照;worker 用 ctx.credentials 解析后即时使用
worker secret 不进模型 不是 session event、不是 prompt 段、不是 DSH_WEB_URLfetch 对控制面 host 名 blockFetchHostname$DSH_HOME/control 加入 sandbox hidePaths
手机不读 .env artifact 路径策略在 worker 侧按 cwd 解析,拒绝秘密文件名与逃逸

十、下一步

P2 已经写在提案里:control-web 插件市场、软工集市 OAuth(桌面 + 手机 + dashboard)、为后续 coding-plan 售卖准备的 entitlement 钩子、持续 UI/UX。P3 是运维向:refresh token 轮换、revoke-all、审批/文件就绪的 OS 推送、用量告警、商店版移动端构建。LAN 的 TLS pinning 也留在后面。

P1 至此收工:官方开源树被我改成了「PC 干活、手机遥控、服务器记账」的三层产品,全程没有把任何密钥挪出桌面。


本文为个人项目改造记录,事实细节以仓库 .agents/notes 中的实施记录为准。