Vai al contenuto principale
S

dsh

shuangji66/dsh

Bridge fnOS per file e cartelle in DeepSeekRuntime: seleziona file o directory nella conversazione, gestisci albero file e schede di consegna, controlla i permessi delle cartelle; include diagnostica e interfaccia cinese/inglese.

Installazione

dsh plugin --profile web add github:shuangji66/dsh

README

DeepSeek Harness 控制台

一个自包含的 DeepSeek Harness (dsh) 管理控制台。它在 fnOS(TRIM 平台)上负责 拉起并守护 dsh web 进程,并通过一个内嵌的 Vue 管理界面提供: 概览、设置、目录授权、插件管理、Web 终端、日志查看与更新管理等功能。

本仓库(shuangji66/dsh)即 DeepSeek Harness 控制台的后端 + 前端源码。

它不是 dsh 本体,而是管理 dsh 的“控制台”。dsh 服务本身是官方的 @deepseek-ai/dsh npm 包,由控制台按需从设置页选中的 npm 镜像源安装到 ${TRIM_PKGVAR}/server/<版本>/(不再随 fpk 预置、也不再走 GitHub Release 压缩包)。


功能特性

  • 守护 dsh 进程 — 启动、停止、重启、状态(CPU / 内存 / PID / 端口),并自动在 dsh 装插件自重启后通过 /proc 重新发现实时 PID。
  • 访问 Token / Cookie 交换 — 从 dsh 启动日志捕获一次性访问 token,换取 dsh 会话 Cookie,供反向代理转发时携带,实现免 token 访问。
  • 反向代理 — 把 dsh 的 Web 界面经配置的端口(默认 3079,设置页可改)对外暴露,并叠加登录鉴权。 控制台启动的第一刻就监听:dsh 还没起来时不再“无响应”,而是先给登录页、再给 带阶段的等待页,dsh 完成启动(含换取凭据、安装依赖)后等待页自动跳转。 (见下文「启动期间的等待页与放行门禁」一节)
  • 浏览器兼容模式 — 可开关的反代注入,用来武装下一条的 iOS 兼容守卫;默认关闭, 桌面与 Chromium 开启无副作用。(见下文「浏览器兼容模式」一节)
  • 移动端模型 / 推理等级菜单 — 反代注入:修复 iPhone(WKWebView)上「模型 / 推理等级 菜单能打开、点选项却毫无反应」;随「浏览器兼容模式」开关、只对触屏设备生效,桌面行为不变。 (见下文「移动端模型 / 推理等级菜单(iOS)」一节)
  • 登录鉴权 — 密码校验(≥8 位、大小写字母/数字/符号组合)、会话 Cookie、TTL 有效期,以及访客管理(在线访客列表 / 踢出,SSE 实时推送)。飞牛网关访问 (平台网关注入 X-Trim-* 身份头)跳过鉴权,端口访问照旧需要登录 (见下文「飞牛网关访问」一节)。
  • Web 终端 — 基于 creack/pty + xterm.js 的交互式 bash 会话,通过 WebSocket 传输。 一个会话同一时刻只有一个操作端:在新设备上打开会接管该会话,原设备收到提示后停在 那里(不自动重连,点「重连」即可夺回);浏览器断开只解挂载,会话继续运行并在重连后回放历史。 移动端底部带两页辅助键条(功能键/方向键/常用符号 + 常见标点;Shift 为上档锁定, 方向键同时变 Home/End/PageUp/PageDown),iPad 等大屏触屏同样显示(按触屏能力判定, 不看屏幕宽度),平板档两页并排。触屏上可直接上下拖动终端内容滚动回看(xterm 自身 不处理触摸,控制台自己把手指位移换算成行数),长按终端粘贴剪贴板;软键盘弹起时终端区 与辅助键条一起贴到键盘上沿,拖终端不会把整页带着上滚。
  • 目录授权 — 授权/查看已共享目录(主目录固定为应用自己的 shares 目录,不可切换)。 飞牛 App 内置 WebView 不走宿主 pickUserFile 桥接(iOS App 上会按共享授权处理并报 鉴权异常),而是把 WebView 当作独立 Web,同窗口打开平台的网页授权页 /app-auth/pick-user-file,完成后由平台跳回本页。
  • 插件管理 — 列出 / 启用 / 停用 / 卸载 / 重置 dsh web profile 的插件依赖(插件名点击即复制); 启停按官方 dsh 的 dsh.profile.bundles 选择(停用保留依赖,不写插件市场的 .dsh-market/state.json),只有声明了 dsh.bundle 的包才给开关;启用前过官方的兼容性 栅栏(engines.dsh / @deepseek-ai/dsh* peer 范围,compatibility.json 里的放行记录 优先),安装/更新正在进行时写清单会回 409,避免与 pnpm 抢同一份 package.json。
  • 弹窗交互 — 打开任一弹窗(更新 / dsh 版本 / 插件市场 / 备份进度 / 停止重启确认…)都会 锁定背景页面滚动,移动端不会把弹窗背后的控制台页面一起拖走;叠加的二次确认各加一层锁, 全部关闭才解锁。弹窗内的长信息按「组合」换行 —— 例如 dsh 版本行在窄屏下版本号独占一行, dist-tags 与「已安装 / 使用中」整组落到下一行(不会挤在版本号旁边,也不会从连字符处断开)。
  • 移动端滚动区 — 页面滚动区止于底部导航栏上沿(滚动条不会滑进/压在 fixed 底栏上), 底栏恒定贴底不动;软键盘弹起时底栏隐去,滚动区与终端页一起贴到键盘上沿。
  • 快捷指令 — 持久化的终端快捷命令(数据目录下的 quickcmds.json);列表弹窗为紧凑卡片 (新增/关闭为纯图标按钮,编辑/删除/上移/下移与命令名同一行)。命令内容加密落盘 (AES-256-GCM,密钥为 master.key 或 HARNESS_MASTER_KEY):快捷指令里常放 mysql -uroot -p<密码>、带 token 的 curl 这类东西,拿到磁盘快照/备份的人不该顺手读到它们; 旧版本留在盘上的明文会在启动时(以及第一次打开弹窗时)自动加密重写,名称仍是明文以便排查。 加载失败时弹窗给「重试」而不是显示「暂无快捷指令」—— 后端是整表保存,让用户在不可信的 空列表上新增会把服务端已有的指令整表覆盖掉。
  • 日志 — 查看 / 下载 / SSE 实时流式输出 dsh 与主进程日志(数据目录下的 harness.log)。 后端日志统一走 backend/logging.go 这一个出口,分三级:[INFO](白)/ [WARN](黄)/ [ERROR](红);终端(stdout 是 TTY)用 ANSI 着色,日志文件保持纯文本,由控制台日志页 按 [LEVEL] 标记着色。连续重复的同一行只记一次、序列结束时汇总(如 (previous message repeated 3566 times) …);基础的成功操作不记日志;dsh 子进程的输出原样透传(不改格式、 不加前缀),在终端与控制台里都固定显示为黄色。更新类日志按目标分开打标签 ([dsh] / [market] / [advanced]),升级 dsh 服务与两个受管插件的步骤不会混在一起。
  • dsh 服务多版本管理 — dsh 服务不再内置在 fpk 安装包里,而是按需从设置页选中的 npm 镜像源(默认阿里云,可选腾讯云 / 华为云 / 官方源)安装官方包到 ${TRIM_PKGVAR}/server/<版本>/, 可同时装多个版本并自由切换(切换 = 用该版本重启 dsh);下载时支持取消(会清除安装 目录与下载缓存),安装成功的版本可删除;未安装任何版本时控制台显示「未安装」。 (见下文「dsh 服务的多版本管理」一节)
  • 更新管理 — 自动检测 dsh 服务 / 插件市场 / 增强插件的新版本(每小时);三者的 安装与更新都以设置页选中的 npm 镜像源为准,不走 GitHub,默认不碰官方源 (只有用户主动选中时才用),且只用所选源、失败即报错并提示换源。 控制台自身不提供就地自我更新:升级只走 fpk 安装包(见下文「控制台版本与升级方式」)。 另有 dsh 数据备份 / 恢复(目录页):备份在服务端异步执行,弹窗显示进度、可取消, 关掉弹窗不会终止备份(见下文「dsh 数据备份」一节)。
  • node-pty 自动安装 — 主进程启动后自动补齐 node-pty 预构建文件与 patch。

技术栈

层技术
后端Go(backend/),标准库 + github.com/creack/pty
前端Vue 3(Composition API / <script setup>)+ TypeScript + Vite
前端构建Tailwind CSS、Pinia、Vue Router、xterm.js(@xterm/xterm)
通信Admin Unix Socket、HTTP JSON API、WebSocket(终端)、SSE(日志/访客)
部署目标fnOS / TRIM 平台,/var/apps/Harness,经 nginx 前置

目录结构

.
├── Makefile                 # make dev / release / clean(前端→embed→Go 单二进制)
├── build.sh                 # 等价构建脚本
├── backend/                 # Go 后端
│   ├── main.go              # 入口:runtime env、日志、启动反代、启动 dsh、优雅退出
│   ├── boot.go              # 启动阶段状态机(等待页文案 / 反代放行门禁的输入)
│   ├── config.go            # 应用配置 & 运行时环境(环境变量解析)
│   ├── logging.go           # 统一日志出口(等级 / 终端着色 / 重复抑制 / dsh 透传)
│   ├── admin.go             # Admin 管理 mux:SPA、API 路由、Unix socket 服务
│   ├── auth.go              # 登录鉴权(Cookie / HMAC / 密码校验)
│   ├── dsh.go               # DshManager:dsh 进程生命周期 / token 交换 / 状态 / 插件
│   ├── proxy.go             # 反向代理(携带 dsh 会话 Cookie)+ 等待页 / /_ready
│   ├── terminal.go          # WebSocket 交互式 PTY 终端(会话单挂载点)
│   ├── update.go            # 更新管理(harness 版本检测、下载通路/续传/校验、数据备份)
│   ├── server.go            # dsh 服务多版本:镜像源列表 / npm 安装 / 取消 / 删除 / 切换
│   ├── market.go            # 受管插件(dshmarket / dsh-fnos-advanced):检测 / 安装 / 更新 / 卸载
│   ├── install.go           # node-pty 自动安装
│   ├── quickcmds.go         # 终端快捷指令持久化 API(命令内容加密落盘 + 旧明文迁移)
│   ├── secret.go            # 命令内容加密:AES-256-GCM 信封 + 主密钥(master.key / HARNESS_MASTER_KEY)
│   ├── visitors.go          # 访客跟踪 / 踢出 / SSE
│   ├── sse.go               # Server-Sent Events 推送
│   └── fnos.go              # fnOS open-gateway 客户端(路径转换等)
└── frontend/                # Vue 3 管理界面
    ├── index.html           # 注入 <base> 由后端运行时改写
    ├── vite.config.ts       # 相对 base + 运行时 baseurl 方案
    └── src/
        ├── main.ts / App.vue / style.css
        ├── router/index.ts  # /?view= 切换 + 旧路径 302 重定向
        ├── stores/          # Pinia:settings / toast / plugins / directories
        ├── composables/     # useTheme / useI18n / useMobileLayout / useKeypadPage
        ├── serverapi/index.ts   # 运行时 baseurl 感知的 API / SSE 客户端
        ├── components/      # Toast / ConfirmDialog / 快捷指令 / 终端辅助键条 / 版本与更新区
        └── views/           # 概览 / 设置 / 目录 / 插件 / 终端 / 日志

构建

前置:Go ≥ 1.26、Node.js ≥ 24。

构建会把前端 dist 拷入 backend/embed,再用 //go:embed 把静态资源打进行内嵌进 Go 二进制,最终生成单文件自包含的 backend/harness。

# 开发构建(默认,非 stripped)
make            # 或 ./build.sh

# Release 构建(strip + 外部链接),可指定版本号
make release V=1.0.1

# 清理构建产物
make clean

两种方式等价;build.sh 额外内置了 nodejs 的 PATH 与版本号参数:

./build.sh               # dev
./build.sh release 1.0.1 # release + 指定版本

不含终端功能的构建

终端页、PTY 会话与快捷指令可以在编译期整体剔除:产物里不会留下终端接口与 PTY 逻辑, 前端连 xterm 都不会打包(侧边栏入口、旧 /terminal 路径一并收起)。

TERMINAL=0 make                  # 等价于 make noterminal
TERMINAL=0 make release V=1.0.1
TERMINAL=0 ./build.sh            # build.sh 同样认这个开关

./build-noterminal.sh            # 一次交叉编译 amd64 + arm64 发布产物

两半是同一个开关:Go 侧 -tags noterminal,前端侧 VITE_TERMINAL=0。直接调 go build / vite build 时需要自己给这两个参数。

./build-noterminal.sh 固定按 linux/amd64、linux/arm64 编译(CGO_ENABLED=0), 产物为 dist/harness-amd64 与 dist/harness-arm64;第一个参数可换输出目录, VERSION=1.0.1 可覆盖版本号。

GitHub Actions(.github/workflows/)提供 CI 构建:

  • build.yml — 手动触发,交叉编译 amd64 / arm64 的 harness 二进制。
  • server-build.yaml — 每 8 小时自动从 @deepseek-ai/dsh 打包 server 并发 Release。 新形态下控制台已不再使用这些包(dsh 服务改由控制台从 npm 镜像源 npm install,见「dsh 服务的多版本管理」);该 workflow 目前保留未动,仅供旧版控制台 或人工取用。新版控制台升级 dsh 不会再碰它。

控制台不再下载、也不再生成任何「更新包」发布资产(自我更新已整体移除)。

本地备份(dsh-data-*)恒为 .tar.gz(tgzDirAs),解压直接走 gz 解压器。

归档解压:只有 gzip 一条通路

控制台现在只需要解 .tar.gz:dsh-data-* 数据备份(extractTarGz)。tar 遍历、 路径越界与软链逃逸防护都在 extractTar 里(extractArchive 保留为唯一入口, 当前等价于 extractTarGz)。

旧版本为 dsh server 的 .tar.xz 发布资产准备过「外部 xz 优先、纯 Go 兜底」两条 解码通路;随着 server 压缩包这条链路整体移除,xz 相关代码与依赖已经删除 (设备上不再需要安装 xz-utils)。


运行 / 环境变量

二进制经 fnOS 平台以 /var/apps/Harness 部署,监听 Admin Unix Socket (默认 <appDest>/app.sock),前端由 nginx 反代到该 socket 的 baseurl 前缀。 反向代理本身有两条监听:TCP 端口(配置项 proxyPort,默认 3079,占据站点根) 与可选的 Unix Socket 子路径挂载(HARNESS_PROXY_SOCK + HARNESS_PROXY_BASEURL,见下文「子路径部署」)。

环境变量说明默认
HARNESS_DATA_DIR统一的后端数据目录:日志、PID、配置、会话密钥、终端会话镜像与 dsh 数据备份全部在其下派生(见下表)。单独的路径环境变量已移除 —— 要换位置只改这一个$TRIM_PKGVAR(缺失时退到当前工作目录)
HARNESS_ADMIN_SOCKAdmin Unix socket 路径$TRIM_APPDEST/app.sock
HARNESS_ADMIN_BASEURL前端资源 baseurl 前缀$TRIM_APPDEST
HARNESS_AUTOSTART设为 0 时不自动启动 dsh1
HARNESS_PROXY_SOCK反向代理的子路径挂载 Unix socket(空或 off 关闭)$TRIM_APPDEST/dsh.sock
HARNESS_PROXY_BASEURL该 socket 对外占据的子路径(反代剥掉后再转发给 dsh)/app/Harness/dsh
HARNESS_DSH_DIAG设为 1/true/yes 时给 dsh 页面注入移动端诊断打点(默认关闭,排查真机问题用;也可用页面 URL 的 ?dsh-diag=1 只对单次访问开启,见「移动端模型 / 推理等级菜单(iOS)」一节)空
HARNESS_MASTER_KEY落盘加密的主密钥(32 字节 base64 或 hex):加密 quickcmds.json 的命令内容与 dsh-accounts.json 账号库。给了就不再读数据目录里的 master.key;解不出来时直接失败,不会静默回退到文件。想让数据目录的备份/卷拷贝里没有可用密钥,就由启动脚本注入它空(用 <数据目录>/master.key)
DSH_HOMEdsh 的数据根(dsh 自己的解析规则:$DSH_HOME > ~/.dsh;平台启动脚本会注入)。控制台只按它定位凭据文件 <DSH_HOME>/.credentials.yaml —— 位置写错会让登录「写入成功但 dsh 不认」;其余 <HOME>/.dsh/...(profiles、备份)仍按 HOME 拼<HOME>/.dsh
PROXY_PORT已废弃:反代监听端口改为设置页配置项(config.json 的 proxyPort,默认 3079),此环境变量不再生效—
dsh_port / TARGET_PORTdsh web 端口13080
proxy_mode设为 1 时默认打开设置页的「代理dsh」(只影响 dsh 进程自身的出网)0
proxy_addr代理地址(「代理dsh」使用)http://127.0.0.1:7890
auth_mode / PROXY_AUTH启用鉴权true
password登录密码空
auth_ttl_hours登录鉴权有效期(小时)4
TRIM_API_TOKEN / TRIM_APPNAMEfnOS gateway 凭据—

数据目录下的内容(backend/config.go 的 loadRuntimeEnv 统一派生;要换位置改 HARNESS_DATA_DIR 即可,不要再新增单独的路径环境变量):

路径内容
<数据目录>/config.json运行时配置(设置页保存的目标)
<数据目录>/harness.log日志落盘文件(进程停止时删除)
<数据目录>/harness.pidharness 控制台自身 PID(恒不变)
<数据目录>/dsh.piddsh 服务实时 PID(随 dsh 启动/自重启刷新,dsh 停止时移除)
<数据目录>/session.key会话 cookie 签名密钥(0600,跨重启保住登录态)
<数据目录>/quickcmds.json终端快捷指令持久化文件(0600;命令内容是密文,名称/是否自动回车仍是明文)
<数据目录>/dsh-accounts.json控制台的 DeepSeek 账号库(0600;整份列表是一个密文信封,多账号的 token 都在里面)
<数据目录>/master.key落盘加密的主密钥(0600,缺失时自动生成):quickcmds.json 的命令内容与 dsh-accounts.json 都是它加密的;删掉它 = 这两份密文再也解不开(想避免密钥与密文同放,用 HARNESS_MASTER_KEY 注入)
<数据目录>/terminal-sessions/终端会话历史临时镜像(停止时整目录清除)
<数据目录>/backup/dsh 数据备份包(dsh-data-*,用户主动创建);老版本自我更新遗留的 pending/ 在启动时自动清理
<数据目录>/server/<版本>/每个已安装的 dsh 版本(npm install --prefix 装出来的目录,可用版本就在 node_modules/.bin/dsh)
<数据目录>/npm-cache/控制台安装 dsh 版本时使用的 npm 缓存(取消下载会连同安装目录一起清除)

例外:pnpm 目录不在数据目录下,仍由 PNPM_HOME 单独指定(dsh 进程与终端都 按它设置环境变量)。

dsh 进程还会拿到 DSH_AGENTS_HOME=$HOME/.agents(共享 agent 配置根:技能 / 全局指令等跨 agent 工具共享的配置,见 @deepseek-ai/dsh-home-paths 的 resolveAgentsHome)。它由 DshManager.buildEnv 显式下发,免得 dsh 用 os.homedir() 兜底而跑到平台共享目录之外;目录本身由控制台启动时检测并创建 (ensureAgentsHome,创建成功不记日志),dsh 启动时不会自建。

运行时配置(config.json)字段:dshPort、proxyPort、proxyEnabled、 proxyAddr、npmRegistry、authEnabled、password、authTTLHours、dshMemLimit、 dshMemAuto、dshVersion、browserCompat。其中 dshVersion 是 当前选中的 dsh 版本(即 ${TRIM_PKGVAR}/server/ 下的目录名,空 = 未安装),由概览页的 版本列表写入,设置页不暴露;npmRegistry 是设置页「npm 镜像源」的取值(aliyun / tencent / huawei / npmjs,默认 aliyun),dsh 服务与两个受管插件的安装/更新/版本检测 都只用它。控制台只使用平台通过环境变量(PATH)提供的 node24, 不再检测或切换 node 版本(nodeVersion 配置项已移除)。

代理卡片只有一个开关:

  • 代理dsh(proxyEnabled,默认关闭)—— 只影响 dsh 进程自身的出网:开启时给 dsh 下发 http_proxy / https_proxy(大小写与 all_proxy 一组,见 dsh.go 的 buildEnv), 保存后需重启 dsh 才生效。控制台自身的版本检测、dsh 版本安装与插件安装都不走它。

「node 环境」卡片里还有「npm 镜像源」下拉(npmRegistry,默认阿里云):dsh 服务与两个 受管插件(市场 / 增强插件)的安装、更新和版本检测都只用所选源,失败会报错并提示换源; 它需要点「保存配置」才落盘生效(npm 安装不走代理)。

node 堆内存上限(dshMemLimit,设置页「node 堆内存上限」)在手动设置 (dshMemAuto 关闭)时有两档阈值:低于 800 MB 输入框下黄字提醒(仍可保存), 低于 500 MB 红字报错并阻止保存 —— 前端在 store.save() 里拦截 (MEM_LIMIT_MIN_MB),后端 handleSaveSettings 用同阈值 minDshMemLimitMB 兜底。 「自动设置」(默认)不受此限:上限由系统 node 的 heap_size_limit 决定,输入框只展示它。

其中 proxyPort(反代本身对外监听的 TCP 端口,设置页「反代监听端口」)与 dshPort 语义不同:dshPort 由 dsh 进程绑定、必须停 dsh 才能改;proxyPort 是 harness 自己的 监听,保存时先绑定新端口、成功后才关闭旧监听并落盘,因此改完立即生效,端口被占用则 整次保存被拒绝(旧监听继续服务)。


子路径部署:反代挂载点与 Unix Socket 前置

除了 TCP 端口(根挂载,端口见配置项 proxyPort),反代还会在 HARNESS_PROXY_SOCK(默认 $TRIM_APPDEST/dsh.sock) 上再监听一个 Unix Socket,并把它挂在 HARNESS_PROXY_BASEURL(默认 /app/Harness/dsh) 子路径下:

浏览器 https://<fnip>:<port>/app/Harness/dsh/…
      ↓ 平台网关(fnOS open-gateway)原样转发(路径带前缀)
Harness 反代 @ unix socket
      ↓ 剥掉 /app/Harness/dsh,补上 dsh 会话 Cookie
dsh web @ 127.0.0.1:13080   ← 只认 /、/api、/plugins

默认值刻意比控制台 baseurl(/app/Harness,走 admin socket)深一层,这样同 一台设备上两条线各占一段路径、互不抢:控制台在 /app/Harness/…,dsh GUI 在 /app/Harness/dsh/…。

为什么这样就成立:dsh 0.1.7-alpha.1 起其前端产物全部使用文档相对路径 (dsh-host-frontend-static 注入 <base href="./">,插件 bundle 引用、/api RPC、HMR 的 SSE、流 mux 的 WebSocket 都是去前导斜杠的相对形式)。页面在 /app/Harness/dsh/ 下加载时,浏览器自动把请求拼成 /app/Harness/dsh/api/...; 反代剥掉前缀后 dsh 收到的仍是它认识的 /api/...。dsh 侧不需要任何 baseurl 配置,配对成立的唯一条件是反代把前缀剥干净。

反代的实现要点(proxyMount,见 proxy.go):

  • 进站路径先剥前缀,后续鉴权、握手、就绪门禁、等待页、转发全部工作在挂载内路径上;
  • 反代自留路径与跳转目标补回前缀:/_login、/_logout、/_ready、未登录时的 302、登录成功后的 next、等待页的轮询地址与 Refresh 兜底 URL;
  • 裸挂载点(/app/Harness/dsh 无尾斜杠)301 到 /app/Harness/dsh/:dsh 前端的 <base href="./"> 以目录为基准,缺尾斜杠会让相对路径解析到站点根;
  • 不属于该挂载的路径直接 404(网关配置错误时不会把流量悄悄转给 dsh)。

部署时注意:

  • dsh 需 ≥ 0.1.7-alpha.1(控制台现在只允许安装/切换 0.2.0-rc.1 及更新版本,见 「dsh 服务的多版本管理」)。更早版本注入的是 <base href="/"> 且 API/SSE/WS 用绝对根路径, 在子路径下必然 404(正是该版本的修复项)。
  • 网关若不剥离前缀(原样把 /app/Harness/dsh/... 交给本 socket),反代也能 正确剥离——前缀本来就由反代处理,网关只需保证路径原样透传、不额外改写。
  • 网关/nginx 必须按更长的前缀优先匹配:控制台那条规则(admin socket, /app/Harness)在前缀上包含 dsh 这条(/app/Harness/dsh),先匹配到控制台就 会把 dsh 的流量截走。nginx 天然按最长前缀匹配,自建规则时注意这一点。
  • 该监听是可选的:目录不可创建 / socket 被占用 / 监听失败时只记日志并跳过, 不影响控制台与 TCP 反代启动。HARNESS_PROXY_SOCK=off 可显式关闭。

DeepSeek 官方账号登录(控制台侧完成授权)

dsh 自带的账号登录在本部署下用不了,原因是平台侧的硬规则:auth_init 只接受 http:// + 主机名 ∈ {localhost, 127.0.0.1, [::1]} 的 redirect_uri(路径任意); 公网 https/http、局域网 IP、甚至 https://127.0.0.1:… 一律 50005 PAYLOAD_SCHEMA_MISMATCH; login_source 取 web/desktop/空都一样;平台只有 auth_init/auth_exchange/auth_cancel 三个端点,没有设备码轮询。而浏览器经平台网关访问时 window.location.origin 是公网地址 —— dsh 的 loginOrigin() 与平台一样只认 loopback,于是「点登录不跳转」。

控制台因此自己完成这套 OAuth(backend/account.go + 概览页独立的「DeepSeek 账号」卡片与「添加账号」弹窗):

步骤谁做
① POST /auth-api/v0/dsh/auth_init(控制台自持 PKCE verifier/challenge 与 state)控制台
② 打开平台授权页并完成授权浏览器(用户点一下)
③ 回收 ?code=&state=:loopback 来源会自动落到 /oauth/callback;否则把地址栏那条地址粘回控制台控制台(admin 线与反代线共用同一个 handler)
④ POST auth_exchange 换 token(校 state、单次有效、10 分钟过期)控制台
⑤ 按 dsh 的格式写入 <dsh home>/.dsh/.credentials.yaml:deepseek-account-platform/default 的 grant + device 记录控制台
⑥ 用 dsh 自己的 account/getState / getProfile / getBalance 核验并展示状态控制台 → dsh

要点:

  • 写入/删除都要等 dsh 认账:dsh 的 credentials-local 用 chokidar 监听该文件(去抖 ~100ms),写完(或退出登录删完)立刻查询会拿到旧状态 —— 登录会被误判成失败并回滚, 退出登录会让界面停在「已登录」只能手动刷新。两处都在窗口内(5s / 250ms)轮询 account/getState 直到 dsh 给出新状态;问不到 dsh(未就绪)不算失败,只记日志。
  • 写入立刻生效,不需要重启 dsh:dsh-credentials-local 用 chokidar 监听该文件,且模型侧 每次请求现读凭据(dsh-llm-deepseek-account 的 resolveToken → readRecord)。真机验证: 写入后 account/getState 立刻从 signed-out 变成 credential-stored,getProfile/getBalance 随即可用。
  • 写入是行级手术:只在 records: 下删/插那两条记录(flow 风格一行),文件里用户自己的 其它 refs/records 逐字节不变;临时文件 + rename 落盘、权限强制 0600(dsh 会拒绝属主之外 可读的凭据文档)。写完立即向 dsh 核验,只有 dsh 明确回答 signed-out 才回滚并报错; 问不到 dsh(未就绪)不算失败。
  • 回调路径必须是 /oauth/callback(accountCallbackPath):平台只在 auth_init 那一步 宽松地接受任意路径,用户点「登录」真正授权时会拒掉别的路径,页面只显示「授权失败」 (实测对照:/oauth/callback 成功、/app/Harness/dsh/_account/callback 授权失败,请求头无关)。 反代线因此自留 /oauth/callback,等于遮蔽 dsh 自己的账号回调路由 —— 本部署里 dsh 自己 那条路本来就走不通,但将来若要恢复原生流程,得先撤掉这处自留。
  • 回调地址的选择(accountRedirectURI):浏览器来源是 loopback(同机 / SSH 本地转发 / 飞牛客户端把网页代理到 127.0.0.1:<port>)时用 <origin><控制台 baseurl>/oauth/callback; 否则用 http://127.0.0.1:<proxyPort>/oauth/callback 作占位 —— 平台接受、授权页能拉起; 有到该端口的本地转发时回调自动落到控制台(ssh -L <proxyPort>:127.0.0.1:<proxyPort>), 没有时用户把地址栏那条地址粘回来即可(或把 host 换成设备后打开,三种写法都自留)。

多账号(添加 / 切换 / 移除)

dsh 自己没有多账号概念:账号凭据只有一条固定键(deepseek-account-platform/default), 谁在里面谁就是「当前账号」。所以控制台的做法是:自己存一份账号库,切换时把对应那份 token 重新写进那条记录。

  • 账号库在控制台自己的数据目录:$HARNESS_DATA_DIR/dsh-accounts.json(0600)。 刻意不往 dsh 的凭据文件里塞额外记录 —— 那个文件里装着用户其它密钥、格式也属于 dsh, 控制台只写它认识的那两条(default / device)。

  • 「当前生效的账号」是推导出来的:拿凭据文件里的 token 与库里各条目比对(token 每次登录 都会换新,条目按平台 user.id 归并)。库里没有、而 dsh 正在用的 token,会在查询状态时 自动导入(老设备先登录后升级控制台的情况,否则列表里看不到在用的账号、切成别的就再也 切不回来)。

  • 切换 = 把该账号的 token 写进凭据记录 + 等 dsh 认账(同登录的写入路径,失败回滚到切换前 的凭据)。凭据变更会让账号插件清掉资料/余额缓存(它订阅 credentials/record-updated), 所以切完立刻就是新账号的余额与联系方式。

  • 退出登录只是让 dsh 不再使用当前凭据,账号仍留在库里(可以随时切回,不用重新授权); 移除才会请平台作废 token 并从库里删除。移除时若账号库写盘失败会如实报错(本机凭据 已经删掉,但账号还在库里,可以重试),不会假报「已移除」。

  • 账号库的每次变更都在锁内重读再落盘(accountVaultMutate):切换要等 dsh 认账、移除要请 平台作废 token,中间隔着最长几秒;拿那一刻的库快照整份写回去,会把这期间别人的改动(新加的 账号、刚移除的账号)静默覆盖掉。判断与写入因此在同一把锁内完成(与快捷指令的 quickCmdsMu 同一取舍)。

  • 兼容性:0.2.0-rc.2 与 0.2.1-alpha.4 的账号插件用的是同一个键名与同样的 grant 格式 (credentialKey("deepseek-account-platform", "default") / "device"),所以凭据写入对两者 都成立;更早、没有该插件的版本只会把它当普通记录存着(credentials-local 是通用键值存储, 支持 grant / api-key 两种 kind),不影响启动。

  • 凭据写在 $DSH_HOME/.credentials.yaml(dsh 自己解析的数据根,默认 ~/.dsh):位置与 dsh 一致 (见上表 DSH_HOME),写入后向 dsh 核验,只有它明确说「没这笔凭据」才回滚。余额按 dsh 自己的 规则显示(截断两位 + 千分位 + 货币符号 + 子分显示 <$0.01,见 frontend/src/utils/money.ts)。

  • dsh 自己的账号入口仍然不可用(平台规则使然):请用概览页独立的「DeepSeek 账号」卡片 (未登录是灰色圆形头像 + 「未登录」;登录后显示平台头像、联系方式与余额/赠送金, 下方是账号列表:当前账号标「使用中」,其余可一键切换或移除)。点「添加账号」打开的弹窗里 是两步流程:「打开授权链接」(跳转 / 复制)→「粘贴回调链接」并完成登录。

  • 授权页是一次性的:同一个 authorize_id 授权成功后浏览器会跳到那条打不开的 127.0.0.1 地址上(跳到那里就已经表示授权成功了,把地址粘回控制台即可完成);此时按「后退」再点一次 「登录」必然「授权失败」——那不是配置问题。

  • 退出登录:只删本地凭据记录(让 dsh 不再使用这个账号),账号留在库里可随时切回, 不碰平台侧;只有「移除」才请平台作废 token(POST /auth-api/v0/users/logout + x-dsh-auth-token),平台侧失败不阻塞本机删除。

概览页的「飞牛入口」

概览页 dsh 卡片右上角的**「进入」按钮打开飞牛入口**——当前访问环境下经平台网关 访问 dsh 服务的地址,无需用户配置:控制台的当前访问地址(由请求的 Origin / Referer 推断,缺失时退回 Host + X-Forwarded-Proto)剥离控制台 baseurl (HARNESS_ADMIN_BASEURL)得到飞牛 OS 的访问源,再拼上控制台入口下的 dsh 反代路径 /gui/:

控制台 http://192.168.1.111:5666/app/Harness
  ├─ 剥离 /app/Harness      → 飞牛 OS 访问源 http://192.168.1.111:5666
  └─ 拼接 /app/Harness/gui/ → http://192.168.1.111:5666/app/Harness/gui/

为什么不拼独立的 /app/Harness/dsh(HARNESS_PROXY_BASEURL):飞牛 App 的网关 鉴权是按入口(gatewayPrefix)注入的,控制台入口的凭据不会自动带到独立的 dsh 入口。App 内从控制台跳过去时,第一请求会被 trim_http_cgi 以 200 + 13 字节 "invalid token" 拒绝(手动刷新一次才好)。/gui/ 由控制台自己的 app.sock 反代到 dsh(AdminMux.dshUI,见 backend/admin.go),请求始终落在控制台入口的 鉴权范围内;dsh 前端仍然靠 <base href="./"> 在 /gui/ 下解析相对路径。

换算在 backend/admin.go 的 fnosEntryURL 完成,随 GET /api/settings 的 runtime.fnosEntryURL 下发;后端拿不到访问地址时前端退回浏览器自身地址 (document.baseURI 的 origin + runtime.consoleDshURL)自行换算;两者都拿不到时 按钮不显示,避免给出指向站点根的错地址。桌面浏览器点击后新标签页打开;飞牛 App 内则用 全屏 iframe 承载(网关鉴权按入口注入,新 WebView 拿不到控制台入口的凭据)。App 内打开时 会压入一条同 URL 的历史记录:返回手势/浏览器后退先关掉这层全屏容器回到控制台,而不是 把用户直接带出应用。

这条历史记录之所以有效,是因为 portal 的返回键(/appview 的 createAppViewCloseHandler)在 history.length > 1 时调用 history.back(),而同源 iframe 里的 same-document 条目属于 joint session history —— 顶层回退会先弹掉它,并把 popstate 派发到控制台的 window。注意这个长度是组件挂载时读取并闭包捕获的:若 App 把 /appview 当作首个历史条目打开(length=1),返回键会直接 navigate('/index'),任何前端 代码都拦不住 —— 那种情况需要页面内的关闭入口,不能只依赖宿主返回。


飞牛网关访问(跳过登录鉴权)

平台网关(fnOS open-gateway)转发应用请求时会注入三个身份头,代表飞牛 OS 已完成 登录认证:

请求头含义
X-Trim-Username当前飞牛 OS 用户名
X-Trim-Userid当前飞牛 OS 用户 UID(正整数)
X-Trim-Isadmin是否管理员(1 / true / yes)

对 dsh 服务而言,这类请求无需再输一遍控制台密码:

  • 跳过登录鉴权 —— 网关那条线(Unix Socket 监听,HARNESS_PROXY_SOCK)上的请求 只要带齐 X-Trim-Username + X-Trim-Userid,就不再被重定向到 /_login; WebSocket 升级共用同一份判定(不再回 401)。
  • 等待界面照旧 —— 跳过鉴权不等于跳过就绪门禁:dsh 未就绪时照样是等待页, /_ready 轮询、dsh 会话凭据判定(SessionSettled)与转发行为都不变。
  • 登录列表照旧记录 —— 网关请求不签 harness 会话 Cookie,因此按飞牛用户 (UID)+ 客户端 IP标识:同一个人从不同环境(网络)访问飞牛时 IP 不同,各占一条 记录,不会被合并刷新成同一条;同一环境的多次访问刷新同一条。列表里标注来源 「网关访问」(端口访问一侧在 IP 前标注「端口访问」,两类来源一眼可分)、显示飞牛 用户名与来源 IP(X-Forwarded-For / X-Real-Ip,都取不到时为空),不显示登录 有效期,不提供注销(没有凭据可吊销)—— 但登录列表里的这条记录可以手动删除 (DELETE /api/visitors 对网关条目只清列表、不拦访问;该用户再次经网关访问时 会重新出现)。这类条目闲置达到登录有效期(复用 authTTLHours,未配置时回退 4 小时,见 gatewayVisitorIdleTTL)后也会自动清除,避免列表长期堆积早已离开的 用户。

只有网关那条线认这三个头(reverseProxy.gatewayLine,由 newReverseProxyAt 构造、startProxySocket 使用)。TCP 端口线(proxyPort,局域网可达)与回环地址 一概不认:请求头是客户端自己就能加的,端口线若认它,任何人加三个头即可绕过端口 鉴权。因此局域网直连端口的伪造请求仍然要登录;网关线上不带身份头、或身份头 不完整时也退回原有 Cookie 鉴权(例如本机进程直连 socket)。

设置页「启用登录鉴权」开关下方有对应提示:网关访问跳过鉴权,端口访问建议开启鉴权。


浏览器兼容模式(browserCompat)

用途 — 一处开关、一处只影响触屏 iOS(WebKit) 的兼容修复,默认关闭,在设置页 「node 堆内存上限」与「启用登录鉴权」之间切换;桌面与 Chromium 内核(Chrome / Edge)开启无副作用:

  • iPhone 上模型 / 推理等级菜单点选项没反应 —— WKWebView 在菜单内搬家焦点时 focusout 的 relatedTarget 为 null,触发上游守卫误关菜单(见下文 「移动端模型 / 推理等级菜单(iOS)」一节,该注入只在触屏设备上武装)。

它曾经还管着另一项(Firefox / Zen / Safari 等非 V8 引擎上「会话历史一直显示『载入历史…』」), 该修复已随 dsh 上游的变化从控制台移除 —— 原因见本节末尾。

生效时机

开关状态不写进任何资源字节,而是由每次都回源的 HTML 注入 window.__DSH_BROWSER_COMPAT__(browserCompatFlagScript),bootstrapScript 的守卫在页面 加载阶段读它,所以「切换开关 → 普通刷新」即可生效。

这与 dsh 插件资源(/plugins/??…&rev=<dsh 自己的 rev>,响应带 Cache-Control: public, max-age=31536000, immutable 且无 ETag,rev 由 dsh 生成、URL 无法被 harness 改写)无关 —— 那条缓存约束只在反代改写 bundle 字节的年代才存在,现在已是历史。 页面可用 window.__DSH_MODEL_MENU_FOCUS_GUARD__ === true 确认守卫已武装。

已经移除的两处反代注入

两处都随 dsh 上游的变化失去目标,留着只会让人误判「兼容模式到底还在补什么」:

  1. bundle 里的 V8-only 原生函数格式判断改写(原「浏览器兼容模式」第 1 项)。dsh 客户端 曾用精确字符串比较判断「这是不是本 realm 的原生构造器」:

    Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
    

    非 V8 引擎把原生函数源码格式化为多行(实测 Firefox 156 与 WebKit 均为 "function Object() {\n [native code]\n}"),该比较恒为 false → 普通对象被判为 「非本 realm 原生原型」→ snapshotJsonValue 返回 undefined → 客户端抛 TypeError: Assistant stream raw chunk must be a lossless JSON object,历史渲染在首条消息 前中止;而它是普通 TypeError 而非 RemoteError,dsh 不会归类为可重试的远端失败, openState 永久停在 "loading"(必须刷新页面或切换会话才能恢复)。触发条件是 「流式进行中刷新」:该校验(expandAssistantStream)只在处理 assistantStream.activeAttempt.stream 时执行,已完成的历史不走这条路,所以在 Chromium 上 难以复现。

    上游现在把该判断改成了引擎无关的形式:

    Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === "Array" ? Array : Object)
    

    (比较两端都取自当前引擎,格式自然一致;见 dsh-api-session-controller / dsh-experimental-session-inspector 的 client.js)。原来那个字面量锚点在 0.2.x 的客户端 产物里零命中(0.2.0-rc.2 / 0.2.1-alpha.4 / 0.2.1-alpha.5 实测均无;带该字面量的只剩服务端 产物),而这类改写以「锚点未命中即原样返回」为前提,于是长期静默空转 —— 已整体删除。 今后若又在非 V8 浏览器上看到「载入历史…」,不要再指望反代这层改写,需要重新定位 (方向是 dsh 客户端报错与上游产物,而不是网关 / 代理)。

  2. 会话自愈探针(旧 sessionWatchdogInit)—— 针对 dsh ClientSessions.followCurrent() 只在 current !== watched 时打开事件窗口、open() 失败便无重试路径的缺陷。新版 dsh 已重构 该机制:sessions.list 快照不再有 current 字段、followCurrent() 方法已被删除,改由视图层 显式 sessions.retain(target, { source: "mainView" }) 打开窗口 —— 探针的入口条件恒不成立, 只剩一个空转定时器,故已移除。

两处的完整来龙去脉见 backend/proxy.go 中「转发路径上不再改写 JS 产物」那段说明。


移动端模型 / 推理等级菜单(iOS)

症状 — iPhone(实测:飞牛 App 的 WKWebView,iOS 18.7,网关子路径访问)上点 composer 的 模型名:菜单能打开、菜单根面板的两行(「模型」「推理等级」)也点得动,但一进二级面板 点具体选项就毫无反应:菜单随即消失、模型名不变、网络里也没有任何 session/selectModel。 桌面(Firefox/Windows,同一个子路径 URL)鼠标点击一切正常——包括同一会话、同一时刻。

问题成因

dsh 0.1.7 的模型座位(dsh-client-ui-model-selection 的 ModelSelect)自己实现了两级菜单, 并在根节点上用 onBlur 关菜单:

const onBlur = (event) => {
  if (event.relatedTarget instanceof Node &&
      (rootRef.current?.contains(event.relatedTarget) === true ||
       menuRef.current?.contains(event.relatedTarget) === true)) return;
  close();
};

它假定「失焦目标一定是个 Node」。桌面成立:点选项时焦点落到那个选项上,relatedTarget 就是它、且在 menuRef 里 → 守卫放行 → 菜单留着 → click 派发给选项 → 选择生效。

iOS WebKit 不成立:菜单内按钮之间的焦点搬家,focusout 的 relatedTarget 是 null (真机打点实测:菜单里已聚焦的选项 focusout 事件 relatedTarget === null)。守卫于是越过 return 直接 close(),菜单在 mousedown 之后、click 之前被卸载 → 选项的 click 没有目标、React 的 onClick 不跑,也就永远不会发出 session/selectModel。

真机数据(诊断打点,模型座位内事件)可直接对上:

观察数据
二级面板选项上的事件pointerdown → touchstart → pointerup → mousedown(齐全)→ 没有 click
紧跟着的 focusouttarget = BUTTON._7KE1Ra_option _7KE1Ra_selected, relatedTarget = null
同一时刻[role=menu] 消失(菜单被卸载);座位本身 disabled=false、座位中心命中就是它、defaultPrevented=false
根面板两行click 正常派发(那两行是进入二级面板的唯一入口,所以"能钻进去但点不动")

复现与回归(无需真机):在 Chromium/WebKit 里把「菜单内 focus 搬家」按 iOS 的方式模拟 (在菜单内 mousedown 时对手上已聚焦的菜单按钮调用 blur() —— 程序化 blur 的 relatedTarget 同样是 null),未修复时选项点击 0 次选择、修复后 1 次。

修复方式

bootstrapScript 第 6 段(随设置页的**「浏览器兼容模式」开关 + 触屏判定一起武装):触屏上把模型座位自己那个菜单内部**的 focusout 在捕获阶段 拦掉传播——React 的委托监听挂在更低的容器上,拦在最外层就让它收不到这次失焦,菜单不会被 误关,随后的 click 正常落到选项上。边界刻意收窄:

  • 只认模型座位([data-slot="conversation.input.model"])且菜单确实开着(aria-expanded="true");
  • 菜单用触发器的 aria-controls 定位(规范属性,不依赖 dsh 的样式哈希),命令 / 权限 / 会话行等其他菜单一律不碰;
  • 只拦 target 在该菜单内的 focusout:编辑面与菜单以外的失焦照旧(「点菜单外关菜单」走的是 mousedown,不受影响,实测仍能关闭);
  • 只在触屏设备上武装(navigator.maxTouchPoints / ontouchstart),桌面行为与官方 dsh 完全一致。

生效方式是 HTML 注入(不是改写 bundle 字节),HTML 每次刷新都回源,因此普通刷新即生效, 不受 dsh 插件 bundle 的 immutable 强缓存影响(这点与开关里第 1 项不同)。页面可用 window.__DSH_MODEL_MENU_FOCUS_GUARD__ === true 确认守卫已武装(开关关闭时该标记不会出现)。

真机诊断(默认关闭)

这类只在真机上出现的问题(事件被吞、元素在两次事件之间被卸载)靠"看有没有发出请求"是查不出来的。 反代内置了一套默认关闭的打点:环境变量 HARNESS_DSH_DIAG=1(进程级)或页面 URL 带 ?dsh-diag=1(访问级,例如手机上打开 https://<网关>/app/Harness/dsh/?dsh-diag=1)即注入。 它把「模型座位与菜单」相关的指针/焦点/菜单出现消失/座位节点替换/JS 报错,以 <prefix>/dsh-diag/<事件>/<分片>/<数据> 的 URL path 形式打回本站(dsh 回 404,无副作用), 落进平台网关 / nginx 的 access.log——不需要新开后端接口就能取回一个真机客户端的现场。 解读方式与判定要点见 AGENTS.md「真机诊断打点」一节。

与移动端页面插件的关系

不是 dsh-web-mobile-fix / dsh-web-mobile 之类插件造成的,证据:

  • dsh-web-mobile-fix 的捕获期 click 处理只作用于「侧边栏展开时落在中栏」的点击,而且它的 第一条豁免就是 [role="menu"] 子树;模型菜单是 portal 到 document.body 的,既不在中栏 也不在侧边栏列里——代码路径根本到不了。
  • 真机上模型菜单里的事件 defaultPrevented 全是 false,且钩子注册在 <head>(比任何插件都 早),没有任何插件拦掉这些事件;根面板两行的 click 也正常派发。
  • 另一台设备(桌面 Firefox)、同一个子路径 URL、同一会话正常工作。
  • 该实例上 dsh-web-mobile 本来就是被 cordis.patch.yml 停用的(其 client bundle 根本没加载)。

顺带记录一个独立的移动端小坑(与本缺陷无关、属插件设计):dsh-web-mobile-fix 在 ≤700px 把展开的侧边栏做成浮层,并在捕获期吃掉「侧边栏展开时落在中栏的点击」用于收起侧边栏——因此 侧边栏展开时点模型名只会收起侧边栏,需要再点一次。


插件市场(dshmarket)与增强插件(dsh-fnos-advanced)

两者是同一种形态的受管插件(普通 profile 插件、npm 包),检测/安装/更新/卸载 共用 backend/market.go 的 managedPlugin 实现;下文以插件市场为例,增强插件 只是把包名换成 dsh-fnos-advanced、状态槽位换成 kind=advanced。

插件市场(dshmarket)的安装与更新

形态变了:过去 dshmarket 是被 CI 写进 dsh server 包 dependencies 的「内置 bundle」 (见 .github/workflows/server-build.yaml),控制台做的是「就地把 server 目录里那份 dshmarket 换成 npm 最新版」。现在 dsh 由控制台从镜像源 npm install 官方包, 里面不含任何预置插件市场,于是市场退化成一个再普通不过的 profile 插件,四个动作 全部经 dsh 自己的插件命令完成(backend/market.go):

动作命令
检测dsh plugin --profile web list(输出里有没有 dshmarket)
安装dsh plugin --profile web add dshmarket@<最新版本号> --registry=<镜像源>
更新同安装(再 add 一次最新版本号)
卸载dsh plugin --profile web remove dshmarket(不带 --registry:pnpm 的 remove 没有这个选项,带上会直接 Unknown option: 'registry' 失败;卸载也不下载任何东西,与镜像源无关,因此也不按镜像源回退)
  • 安装/更新的镜像源与 dsh 版本安装完全一致:只用设置页选中的那一个源(默认阿里云, 可选腾讯云 / 华为云 / 官方源),不自动换源,失败就报错并提示去设置页换源。卸载不涉及 registry: remove 走「执行一次」的通路(runMarketPluginCmdOnce),失败就把 dsh/pnpm 的原话 报出来,不会甩锅给镜像源。
  • 未安装是正常状态:检测确实查到了、只是没装时,概览页版本行显示「未安装」, 点开弹窗即可安装最新版本 —— 「未安装 → 装上最新版」这条路径只有控制台能提供 (市场面板的自更新入口只适用于已经装好的情况)。
  • 「查不到」不等于「未安装」(UpdateStatus.Unavailable):市场版本只能靠 dsh plugin --profile web list 查,而这条命令在 dsh 服务就绪之前(启动中 / 已停止 / 本机没选中任何 dsh 版本)必然失败。这时不能把空的 localVersion 当成「没装」, 否则用户会以为市场没装、去点安装。规则:
    • 检测失败 → unavailable=true,版本行显示 「—」,市场弹窗只说「暂时查不到」并 不给安装入口(装没装过都不知道,谈不上装哪个),诊断文本(marketDir)仍照给;
    • 只有命令成功退出才写 localVersion:空 = 真没装 →「未安装」;
    • 检测失败时保留上一次已知的版本号(插件不会因为 dsh 重启而消失,清空比留着旧值 更像谎话),只有从未查到过才没有值;
    • 首帧(进程刚起、还没检测过)也是 unavailable=true:没检测过 = 不知道。 实现见 market.go 的 applyPluginLocalResult(本地检测结果的唯一写入口,市场与增强插件共用)与 回归测试 backend/market_unavailable_test.go。
  • dsh 一就绪就重查(RefreshMarketAfterDshStart):既然「查不到」是常态,就必须在 能查的时刻补一次,否则「—」会一直挂到下一次小时级检测。触发点:控制台启动流水线收尾 (dsh 起来 + 依赖装好)、概览页的「启动 / 重启 dsh」、切换 dsh 版本。异步执行、只查一次 (探测失败的结果不进缓存,所以下一次触发一定真查)。
  • 缓存:本机装的是哪一版(dsh plugin list 子进程)与镜像源上的最新版都会缓存, 只在安装/卸载完成后、后台自动检测、手动「检查更新」以及「dsh 刚就绪」时刷新 (见下一条)—— 详见「dsh 服务的多版本管理」末尾的缓存策略表。本地检测失败 (unavailable)的结果不缓存:它只是「这次没查到」,缓存住会让「—」一直挂着。
  • 「最新版本」= 版本号最高的那一版,不是 dist-tags.latest:latest 是发布者手动 移动的标签,可能滞后(发了新版忘了挪标签)、可能指向另一条线(稳定线 / 预览线), 镜像源同步时也可能只更新了版本集合而没更新标签。因此检测与安装都按版本号排序取最高 (newestVersion),安装命令带精确版本号(add dshmarket@<该版本>,不是 @latest)。 副产物:某个镜像源还没同步到这一版时,那次尝试会失败并自动换下一个镜像源。 (dsh 服务版本行也是同一套语义:红点只看版本号大小 —— 版本列表里最高的一版比当前 选中的高就亮,不区分 dist-tags;列表里仍标注 latest / alpha / next,但标签不参与判定, 见 server.go 的 refreshDshStatus。)
  • 没有备份 / 回滚:安装失败就是把错误推给弹窗,不生成备份包,也没有回滚入口。
  • 不自己下载 tarball、也不做完整性校验:pnpm 会按 registry 元数据里的 integrity 校验它下载的字节,控制台再下一份只是重复劳动。
  • 顺序:先跑插件命令、再重启 dsh。安装/卸载都是 pnpm 在 profile 目录里跑, 会持有 profile 写锁(profiles/web/package.json.lock),绝不能在它跑的时候停 dsh ——停 dsh 会连带终止持有锁的那个进程组,留下陈旧锁,之后所有插件操作都会白等 120 秒 再失败。因此命令跑完才 restartDshForPlugin():先过忙守卫、停 dsh、等端口释放、 startDshCaptured() 拉起并重新换取会话凭据,再等它真正监听端口(上限 30 秒;进程退出 立即判失败)让新 bundle 生效。
  • 进度:阶段(安装中 / 卸载中)+ 插件命令的输出尾行 + 「正在重启 dsh 服务」, 经同一条 SSE 推送。

忙守卫(replaceBusyGuard)是所有这些停 dsh 路径的共同前置检查:正在装/卸插件时 拒绝操作并说明原因(被拒绝时不产生任何停机、也不改盘)。两个来源互补:

  • 市场面板发起的安装 → 查 /dsh-market/status 的 busy;
  • 控制台自己的插件命令(插件页、市场弹窗)→ 查 DshManager.PluginCmdRunning()。

市场不回答(没装市场 / dsh 已经坏了)时视为「不忙」,所以恢复 dsh 数据这类抢修操作 不会被挡住。唯一例外是概览页的「停止 / 重启 dsh 服务」按钮 —— 那是用户明确的即时 意图,弹窗打开时查一次 GET /api/dsh/busy 并显示风险提示,由用户决定。

备份包的留存规则

<数据目录>/backup/(默认 $TRIM_PKGVAR/backup)下按 <类型>-<版本>-<时间戳>.tar.gz 命名。 现在唯一需要长期留存的只有用户主动的 dsh 数据备份:

类型谁生成留存原因
dsh-data-*目录页「备份」(用户主动)保留用户的数据备份,只能手动删;不参与自动清理
harness-* / server-* / market-*旧版本的自我更新 / 服务更新 / 市场更新不再生成三条链路都已整体移除(升级只走 fpk,dsh 换版本 = 换一个已安装的版本目录)

老版本留下的 server-* / market-* 包仍由每日清理任务按 30 天回收。

新增更新分支时沿用同一条规则:没有回滚入口就别把备份包留在盘上。

dsh 数据备份:服务端异步执行(进度 / 取消)

目录页的「备份」在服务端 goroutine 里打包,弹窗只是观察者 —— 这条边界的三个后果 都必须保持:

接口行为
POST /api/dsh/backup只做「主目录 / .dsh 存在性 + 互斥」检查后立刻返回,打包在后台进行
GET /api/dsh/backup/status进度快照:bytes/totalBytes(预扫描的原始总字节数)、files/totalFiles、running/cancelling/done/ok/cancelled
POST /api/dsh/backup/cancel中止打包并删除不完整的备份文件;没有备份在跑时是幂等空操作
  • 关掉弹窗(乃至刷新页面、切换视图)都不终止备份:进度状态是进程级的 (update.go 的 dshBackup 跟踪器),前端 composables/useDshBackup.ts 是模块级共享 状态 —— 重开弹窗时先拉一次快照再续上轮询,落定即停,不留长期计时器。
  • 取消是取消而不是失败:走 errBackupCancelled 哨兵错误,只记 INFO、不记 ERROR, 也不把半成品留在 backup/ 里(半个包比没有更糟)。
  • 备份要读整个 ~/.dsh,因此与「恢复数据 / 安装更新 / 回滚」共用一把 applying 互斥 (双向):备份进行中恢复被拒,更新进行中备份被拒。
  • 打包只有一份实现:tgzDirAsProgress(tgzDirAs 是它的 prog=nil 包装)。 进度按块汇报、每块检查一次取消 —— 大文件也能被及时取消。

dsh 服务的多版本管理

dsh 服务不再是内置在 fpk 安装包里的预构建产物,也不再从 GitHub Release 下载压缩包。 控制台按需从设置页选中的 npm 镜像源安装官方 npm 包(backend/server.go):

${TRIM_PKGVAR}/server/<版本>/node_modules/.bin/dsh
环节做法
版本列表镜像源的 packument(/@deepseek-ai%2fdsh),隐藏 0.2.0-rc.1 之前的所有版本(新版控制台锚定 0.2.0-rc.1 及更新版本;更早的 dsh 前端也不走文档相对路径,在控制台的子路径挂载下必然 404),并标注 dist-tags(latest / alpha / next…)。「有更新」的红点/「最新版本」取列表里版本号最高的一版,标签只作展示、不参与判定(refreshDshStatus)。列表缓存到下一次刷新为止:打开弹窗、切页面、状态轮询一律吃缓存(不联网),只有弹窗里的「刷新」与后台自动检测 / 手动「检查更新」会重拉(见下)
镜像源设置页选中的那一个源(默认阿里云):阿里云 registry.npmmirror.com / 腾讯云 mirrors.cloud.tencent.com/npm / 华为云 repo.huaweicloud.com/repository/npm / 官方源 registry.npmjs.org;只用所选源,不自动换源
失败处理npm install --fetch-timeout=5000 --fetch-retries=0:任意一次 HTTP 请求 5 秒拿不到响应即失败;同一个源不重试,直接报错并提示去设置页切换镜像源
安装命令npm install --prefix <版本目录> @deepseek-ai/dsh@<版本> --save-exact --registry=<镜像源>,用 PATH 中 node24 自带的 npm;缓存在 <数据目录>/npm-cache(不写用户的 HOME)
进度逐行解析 --loglevel=http 的包获取行:阶段(下载 / 处理依赖包 / 校验)+「已获取 N 个包」+ 输出尾行。依赖总量 npm 不预先给出,因此不显示假百分比(不确定进度条)
取消支持取消(不支持暂停):终止整个 npm 进程组、删除安装目录与 npm 下载缓存,状态标记为「已取消」
安装后打上 fnOS 需要的附件 fsync 补丁(见下),再执行 <版本>/node_modules/.bin/dsh -V 校验版本号;校验不过则删掉该目录并报错(不自动换源)
切换概览页版本列表里点「切换」→ 写 config.json 的 dshVersion → 停 dsh → 用该版本启动(startDshCaptured 重新换取会话凭据)。切换前先过忙守卫(正在装插件时拒绝)
删除删除版本目录(磁盘占用约 500 MB / 版本)。当前正在使用的版本不允许删除
未安装没有选中任何可用版本时:概览页版本行显示「未安装」,反代等待页显示「尚未安装 dsh 服务」并提示去概览页下载 + 切换(boot.go 的 phaseNotInstalled)

停止维护的旧版本(低于 0.2.0-rc.1)—— 列表里不展示、也不能下载或切换。但如果它 已经装在盘上,会被标上「停止维护」单独补到列表最底部(snapshot() 里带 Deprecated 的条目),行内只留两个动作:「删除」,以及它正被使用时的「切换到 <最新版>」(且最新版本地 已装着才出现)。不允许切回:直接调接口 Switch 也会被拒(err_version_deprecated)。 这样既不留下「看不见又删不掉」的 500 MB 目录,也不会把服务切回一个没人维护的版本。

版本数据的缓存策略(refreshVersions(force) / detectPluginLocal(force)):这两个来源 都是「慢查询」——前者要打一次所选镜像源,后者要起一个 dsh plugin --profile web list 子进程 (1~3 秒)——因此都按缓存优先处理,force=false 时只要有缓存就直接返回、不联网:

触发dsh 版本列表市场本地检测 / 最新版
打开弹窗 / 切页面 / 状态轮询 / 诊断接口吃缓存(缓存为空时才拉一次)吃缓存(缓存为空时才查一次)
弹窗里的「刷新」按钮强制重拉—(市场弹窗没有刷新按钮,改由下面的检测刷新)
后台自动检测(每小时)/ 手动「检查更新」强制重拉强制重查(本地版本 + 镜像源最新版)
安装/卸载市场之后—强制重查(本机版本确实变了)

失败时保留上一份缓存(旧列表/旧版本号仍然可用),只把错误写进状态与弹窗提示, 所以「刷新失败」不会把界面变空。

<版本>/node_modules/.bin 会前置到 dsh 进程与 dsh plugin … 子进程的 PATH (DshManager.buildEnv),而启动 dsh / 执行 CLI 用的是该版本的绝对路径 (DshManager.dshBinPath)—— exec.Command("dsh") 的 LookPath 走的是 harness 自己的 PATH,不能用来解析「当前选中的版本」。

Web 终端里的 dsh 走稳定入口 ${数据目录}/server/.bin/dsh:它是符号链接,启动时 由 syncDshShim 对齐到当前选中版本,切换版本时原子改指向。终端 PATH 只在会话创建时 构造一次,直接沿用启动环境的旧 <版本>/node_modules/.bin 会在切换/删除版本后让 dsh 命令消失;稳定入口让路径恒定存在,已经开着的终端会话也能跟着切换(.bin 以点开头, 不会被版本列表当成一个版本)。

附件 fsync 补丁(必须打,否则 fnOS 上传文件 / 图片整体失败): @deepseek-ai/dsh-attachment-local 落盘附件时会对每级祖先目录 open(O_RDONLY) + fsync 并一路上溯到 /,而 fnOS 把 /vol1 与 /vol1/@appshare 设为 mode 000(仅由 trim_acl 授予穿越),应用读不了这些祖先目录 → EACCES。补丁把这段上溯包进 try/catch,遇到 EACCES / EPERM 即停止上溯(不可读的祖先本就无法 fsync)。过去它由 CI 打在预构建包里 (server-build.yaml),现在 dsh 由控制台自己 npm install,因此必须在安装后由控制台 补上(server.go 的 patchAttachmentFsync:幂等、带锚点校验,锚点缺失时只记 WARN 不阻塞安装)。


控制台版本与升级方式

控制台自身没有就地自我更新:backend/update.go 里原有的「下载发布资产 → 解压 → 备份并替换二进制 → syscall.Exec 换映像」整条链路(连同 下载/安装/暂停/取消/删除更新包 的 API 与前端弹窗、ProxyUpdate「代理harness更新」开关、发布资产的 .sha256 校验) 已整体移除。

升级控制台只走 fpk 安装包:平台侧的部署脚本、依赖与 dsh 的配套版本都在 fpk 里, 换包会把它们一起换掉;让控制台自己替换一个二进制,只会得到「二进制换了、平台侧没换」 的半升级状态。

由此带来的约定:

  • 概览页的版本卡片只剩 dsh 服务 / 插件市场 / 增强插件 三行;控制台版本不在界面上 展示(需要时看 fpk 的 manifest 或启动日志)。
  • 更新检测(每小时 + 手动「检查更新」)只针对这三条链路,全部走设置页选中的 npm 镜像源。
  • harnessVersion(-ldflags -X main.harnessVersion=…)仍保留:它作为控制台版本号 随官方账号登录的客户端元数据上报,不再参与任何更新判定。
  • 不要再把自我更新的任何变体加回来(包括「只允许小版本」那套闸门、GitHub tag 探测、 发布资产下载、代理更新开关):要升级就发新 fpk。

启动期间的等待页与放行门禁

反代在 dsh 启动之前就已经监听配置的反代端口(proxyPort,见 main.go:startProxy 紧跟在 Admin socket 之后)。旧实现把反代放在“dsh 启动 → 换取 Cookie → 安装 node-pty”之后, 这期间访问反代端口既没有页面也没有响应,只能干等(首次启动装依赖时可能是几分钟)。

现在的顺序与门禁:

  1. 反代开始监听,先鉴权:未登录的访客直接看到登录页(旧顺序是先判 dsh 是否就绪、 后鉴权,导致启动期间根本无法登录);/_login、/_logout、/_ready 是反代自留 路径,不会转发给 dsh。唯一例外是飞牛网关访问(网关那条线上带 X-Trim-* 身份头,见「飞牛网关访问」一节),它跳过登录鉴权、直接进入下一步。
  2. 已登录但 dsh 未就绪 → 等待页(serveWaitingPage)。页面只显示一句话「正在等待 DeepSeek Harness 服务就绪」,并按 1.5 秒轮询 GET /_ready (JSON:{phase, ready, detail})——换取凭据、安装 node-pty 等内部阶段不对外展示。
  3. /_ready 报告 ready=true 时,页面立即 location.replace 跳到真正的 dsh 界面 (不再靠整页刷新撞时机);无 JS 客户端仍有 10 秒 <meta refresh> 兜底。

放行(把请求转发给 dsh)只有一个判定入口:reverseProxy.state(),三个条件全部满足 才放行,等待页与 /_ready 的返回值都来自同一处,避免“轮询说就绪 → 跳转 → 又是等待页” 的抖动:

条件含义不满足时的阶段
启动流水线已收尾不在 starting / auth / deps 阶段(收尾可能重启 dsh,提前放行会让界面随即失效)starting / auth / deps(页面统一显示「等待服务就绪」)
dsh 端口已监听配置的 dshPort 可连(反代的上游)进程在 → starting;进程不在 → stopped
本代凭据已落定DshManager.SessionSettled():本代 dsh 的 dsh-auth-* Cookie 已换取(或确认无需凭据)auth

phase 取值(/_ready 原样返回,便于排查;页面只区分「等待中」与「需要动手」两类):

  • 等待中(页面统一显示「正在等待 DeepSeek Harness 服务就绪」):starting(拉起 dsh)、 auth(换取凭据)、deps(安装/校正 node-pty),以及启动流水线之外的 starting (dsh 正在自重启/被拉起)。
  • 需要用户动手(页面停转并给出对应指引):stopped(dsh 未在运行)、failed (dsh.Start() 失败,detail 为原因)、disabled(HARNESS_AUTOSTART=0)、 not-installed(本机没有任何已选中的 dsh 版本,detail 给出安装目录与下一步: 去概览页下载 + 切换)。
  • 等待超过 30 秒才出现“可能需要排查”的黄色提示(旧版固定在 10 秒后就报 「启动失败」,属误报)。

改动这块时注意:

  • 凡是启动或重启 dsh 的路径,都必须经 captureDshSession 换取凭据(它会调用 markSessionSettled)。漏掉就不会标记落定,反代会一直停在等待页。 DshManager.Start() 每次都会递增启动代号,使上一代凭据立即失效。
  • 旧版 dsh 不打印 token(WaitToken 空等 15 秒超时)时同样算“落定”,最坏多显示 15 秒等待页,不会永久卡住;反代随后可以不带 Cookie 转发(既有行为)。
  • WebSocket 升级同样有门禁:未登录 → 401;凭据未落定 → 503;端口未就绪时保留 10 秒容忍窗口(dsh 市场一键自重启期间客户端已在界面上,等它回来比立刻断开友好)。

主要流程

  1. 启动 — 解析环境变量 → 读取配置 → 校验密码 → 启动 Admin socket → 启动反向代理(TCP 根挂载 + 可选的 Unix Socket 子路径挂载;先监听、先鉴权, dsh 未就绪时给等待页)→ 自动启动 dsh:先看有没有选中的 dsh 版本,没有就进入 not-installed 阶段(等待页提示去概览页下载 + 切换),有则启动该版本的 dsh web --no-open --port <port>(除非 HARNESS_AUTOSTART=0)。
  2. 凭据交换 — 从 dsh 日志扫描一次性访问 token(?token=),用它访问一次 dsh 地址,从 Set-Cookie 换取 dsh-auth-* 会话 Cookie,并标记本代凭据已落定 (markSessionSettled,反代据此放行)。
  3. 放行 — 三条件齐备(流水线收尾 + 端口就绪 + 凭据落定)后,反代携带该 Cookie 把 dsh 反代到配置的反代端口;等待页轮询到 ready 即自动跳转。详见上文 「启动期间的等待页与放行门禁」。
  4. node-pty — 等待 $HOME/.dsh/profiles/web 目录生成后安装并 patch node-pty (仍在放行门禁内:此阶段即使端口已通也不放行)。
  5. 版本与更新 — 两条链路的形态不同(控制台自身只能换 fpk,见上文 「控制台版本与升级方式」):
    • dsh 服务(多版本):从 npm 镜像源 npm install --prefix <数据目录>/server/<版本> 官方包,可同时装多个版本并切换;下载可取消(清安装目录 + npm 缓存),安装成功可删除, 未安装任何版本时控制台显示「未安装」、反代等待页显示「尚未安装 dsh 服务」。 详见上文「dsh 服务的多版本管理」。没有压缩包备份 / 回滚:旧版本目录留着就是回退。
    • 插件市场:dsh plugin --profile web add dshmarket@<最新版本号> / remove dshmarket(使用设置页选中的镜像源;安装带精确版本号,不用 @latest 标签), 未安装时控制台可装上最新版;安装/卸载成功后重启 dsh 让新 bundle 生效。 详见上文「插件市场(dshmarket)的安装与更新」。同样没有备份 / 回滚。
  6. 退出 — 收到 SIGINT/SIGTERM/SIGQUIT 或 stopCh 后停止 dsh、移除 socket。

常见问题排查

  • Admin socket 已被占用 — 说明已有实例在运行,主进程会直接退出。
  • 鉴权未启用 — 未设置 password 或密码强度校验失败时后端会打印警告,任何人可访问。
  • 反代不带凭据 — 旧版 dsh 或日志未就绪导致未捕获 token 时,反代将不带 Cookie。
  • 控制台启动期间打开反代地址只看到等待页 — 正常:dsh 还没就绪。页面只显示 「正在等待 DeepSeek Harness 服务就绪」,就绪后自动跳转(内部阶段不对外展示);等待 超过 30 秒会出现“可能需要排查”的提示。若一直停在那里,去控制台看日志与概览页的 dsh 版本状态(等待页在 failed / not-installed 阶段会直接给出失败原因或下一步)。 想手动确认状态可带登录 Cookie 请求 GET /_ready (返回 {phase, ready, detail},phase 见上文「启动期间的等待页与放行门禁」)。
  • dsh 重启后页面白屏/接口报错 — dsh 正在重启(市场一键重启、切换 dsh 版本等),反代 此时返回等待页;刷新即可,等待页也会在 dsh 回来后自动跳转。
  • 概览页 dsh 服务版本显示「未安装」 — 本机 ${TRIM_PKGVAR}/server/ 下没有任何已选中 的可用版本:去版本列表里下载一个并点「切换」。这是新形态的正常初始状态(dsh 不再随 fpk 预置),不是故障。
  • 下载 dsh 版本失败(所选 npm 镜像源不可用) — 错误里会点明是哪个源。去设置页换一个 源再重试(默认阿里云,可选腾讯云 / 华为云 / 官方源)。安装直连镜像源、不走那套 http 代理 设置;官方源 registry.npmjs.org 在国内通常不可直连,只有主动选中时才会使用。
  • dsh 装好后在 fnOS 上无法上传文件 / 图片(EACCES) — 附件 fsync 补丁没打上。看日志里 有没有 attachment fsync patch not applied(安装完成后注入,见「dsh 服务的多版本管理」); 该补丁失败不会让安装失败,但会让上传功能不可用。
  • CPU/内存读不到 — dsh 装插件自重启后 PID 变化,后端会自动在 /proc 中重新发现。
  • Firefox / Safari 打开会话只有「载入历史…」(且一直显示「深度求索中…」) — dsh 客户端旧版的原生函数格式判断只适配 V8,上游现已改成引擎无关的比较,控制台那层 bundle 改写也随之移除(见「浏览器兼容模式」一节)。若仍出现,先确认 dsh 版本是否过旧 (新版控制台只允许 0.2.0-rc.1 及更新版本),再按 dsh 客户端的报错定位 —— 不要再指望反代的 兼容模式。
  • 改动前端(内嵌 dist)或 dsh 插件资源后刷新看不到变化 — 反代注入走 HTML,每次刷新都 回源,普通刷新即生效;但 dsh 插件资源带一年期 immutable 强缓存且无 ETag,普通刷新不回源, 需清除浏览器缓存或强制刷新;必要时重启 dsh 使其 rev 变化。
  • vue-tsc 报 baseUrl 已废弃(TS5101) — TypeScript 6 起 baseUrl 被标记废弃。 本项目已移除 baseUrl,paths 改用相对本 tsconfig 的 ./src/* 写法(@/* 别名 在 vue-tsc 与 Vite 两侧均保持可用)。改回 baseUrl 会重新触发该报错。
  • 前端类型检查命令 — cd frontend && npm run typecheck(即 vue-tsc --noEmit)。 注意 TypeScript 需为 6.x:TS 7 移除了 typescript/lib/tsc 子路径导出, vue-tsc 3.x 依赖它,会直接以 ERR_PACKAGE_PATH_NOT_EXPORTED 崩溃。

许可

MIT © 2026 shuangji66

更多设计背景与说明见 genesis-DESIGN.md。

Plugin correlati