deepseek-harness P1 改造:从官方开源到个人特化版
前言
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 都在本机。这带来两个问题:
- 手机打不开:
127.0.0.1只在 PC 上有效,出门在外根本没法驱动 PC 上的 Harness 干活。 - 不能直接发布:
/api可以驱动session.prompt,而 prompt 能触发工具,包括 bash——把这样一个 Host 挂上公网、前面再加个登录,本质上是 RCE 级控制面。之前已经做过「本地控制面加固」和「API 浏览器信任边界」来收口这个问题。
所以改造的目标不是「把 dsh web 发布出去」,而是拆成三个不同的产品:
- 手机:驱动 桌面 Harness 干活(工具、工作区、沙箱都在 PC 上跑),完成后把文件拉到手机。
- 服务器:一个管理台(Cursot 类),看余额、用量、worker 健康——它 不 承载 agent 会话。
- PC:原样的回环强力 GUI,加一个桌面壳。
另外还有一条硬约束:DEEPSEEK_API_KEY 永远留在 PC 上,控制面不能变成密钥柜,也不能变成第二套 Harness 运行时。
二、整体架构:三层平面
1 | 手机工作台 管理台 (control-web) |
| 平面 | 进程 | 绑定 | 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 web 的 WebServer.Config.host。
3.2 Token 身份:没有用户名密码
控制面为每台 PC 存储三个秘密的 SHA-256:
workerSecret:worker 出站拨号用;dashboardToken:管理台兑换后冻结为dashboardaudience;mobileToken:手机兑换后冻结为mobileaudience。
token.redeem 会按哈希匹配把 audience 冻结在 token 上,dashboard 令牌永远只能看用量/余额/worker,mobile 令牌也只能看自己账户的用量摘要。租户 id 就是 workerId,天然隔离开不同 PC。
权威落地流程是什么?
- PC 上 Settings → 插件 → Control plane,填控制面源 URL 和显示名;
- 保存时若无已存秘密,
POST worker.claim拿到三件套,写入$DSH_HOME/control/worker.json(目录0700、文件0600,在工作区外); - 已存的秘密除非勾选 Reissue tokens 永不替换;
- 手机/管理台粘贴各自 token 兑换;
- 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 插件读 sessionProjections 的 tokenUsage,减去上一次推送的总数,POST usage.ingest 推送非零增量;每 5 分钟用 ctx.credentials(或进程环境)解析出 DEEPSEEK_API_KEY,调 https://api.deepseek.com/user/balance(redirect: 'error')推 usage.balance.push。失败静默跳过。
3.4 管理台 control-web
apps/control-web 是 Vite SPA(@deepseek-ai/dsh-control-web),只说管理台方法:token.redeem/logout/whoami、worker.list/revoke/describe、usage.query/usage.balance。它的 client 里没有 session.*、approval.*、artifact.*、worker.claim 或 usage.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),外面包一层 Capacitor(capacitor.config.json,webDir: 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/resolve,question.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、不能写 themeAssets。dsh-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/result 用 callId 合并成一行;未知事件类型忽略。历史先加载,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/event、approval/*、question/* 经 broadcastWorkMux 扇出。掉线自动重连 mux。
历史有个坑:session.history 页要 compact 到 1 MiB 的 worker WebSocket 上限里,所以 PC 端去掉工具视图、settled 块和 projections 再翻页,手机端先读 16 条再补 session.models、session.stats、artifact.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: currentUser,webviewInstallMode: embedBootstrapper,没装 WebView2 的机器也能装),pnpm pack:desktop 会集结 portable Node + pnpm deploy @deepseek-ai/dsh(含前端 dist)进 runtime 再打包。
更新走 Gitee Releases:appUpdate.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/8、172.16/12、192.168/16;拒绝回环、link-local、CGNAT100.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/describe、worker.lan.candidates永远走仓库;请求无子网扫描。
一个坦诚的边界:LAN socket 是 HTTP。Capacitor 和浏览器没法在 fetch/WebSocket 上 pin 一个临时自签证书,所以 fingerprint 为空字符串,TLS pinning 留给后面的切片。
八、其他 P1 内容
- 主题皮肤:
ui-theme扩展出 Skin 段(settings.sectionidskin)。四个二进制槽位(背景 + 按钮三态)存$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 用 CSSborder-image9-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-selection(ctx.agentModelSelections),Host RPC 和工具共用同一引用;UI 聚焦走 Cordisharness/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-base;GET / 只是健康页 |
| dashboard 不能驱动工作 | audience 门控 + client 源边界测试 |
| 手机不能碰 Host 特权 | 协议 allowlist + apps/mobile source-boundary 扫描 |
| API key 不出 PC | 控制面只存 balance 快照;worker 用 ctx.credentials 解析后即时使用 |
| worker secret 不进模型 | 不是 session event、不是 prompt 段、不是 DSH_WEB_URL;fetch 对控制面 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 中的实施记录为准。




