dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
把本机 / WSL / 虚拟机 / SSH 远端上另一套 DSH 的 WebUI 镜像进桌面版 DSH,用同一套界面和体验管理它们。DSH 插件,MIT。
把本机 / WSL / 虚拟机 / SSH 远端上另一套 DSH 的 WebUI 镜像进桌面版 DSH,用同一套界面和体验 管理它们:点一下窗口上边的下拉框,主区就换成那台机器的那套 DSH;启动、停止、更新也都在这里。
镜像出来的界面就是 DSH 自己的前端(随远端版本走),桌面窗口的外壳、主题、侧栏与快捷键 全部复用当前应用,不另造一套 UI。
状态:M0–M8 完成。 本机、WSL、SSH(含虚拟机 / 远程主机)三种实例都能并发启动、镜像、停止; 面板、设置页、日志抽屉、预检、容器降级链都已接好,并且在真实浏览器里驱动界面验证过 (见下方截图与
pnpm verify:ui)。M5 起本机那一项默认吸附桌面应用自己那套 DSH (打开应用就是「运行中」,不另起进程);M6 起默认是统一界面——选中那台机器的 DSH 界面 铺满主区,和 WebUI 是同一套 UI 与体验,我们自己的列表 / 工具栏 / 日志收在「管理」里。

左:统一界面(默认)——主区就是那台机器的 DSH,界面和 WebUI 一模一样。 右:管理界面(控制条上的「管理」)——列表、工具栏、预检、日志都在这里。

「全窗口」把官方侧栏收成窄条,远程那套界面就拿到整扇窗:

切换机器是窗口上边那条控制条里下拉框的事(桌面版里它钻进标题栏那条带子,谁也不挡);
换过去之后前一台不卸载,切回来是同一帧——连 WS 都没断(pnpm verify:ui 里量到了:两台
都还挂在 .drd-keep 里,一显一隐)。
# 1) 装插件(本地目录 / npm 包 / git 地址都行)
dsh plugin --profile desktop add D:\code\dsh-remote-desks
# 直接从仓库装:dsh plugin --profile desktop add github:TBChaos/dsh-remote-desks
# 或者用桌面版设置里的 Plugins 页安装
# 2) 完整重启桌面应用
源码在 github.com/TBChaos/dsh-remote-desks(MIT), 版本改动见 CHANGELOG.md。 想改代码:
pnpm install→pnpm verify(类型检查 + 构建 + 315 项冒烟)。
改完必须完整重启,刷新窗口不够。 这一条是实测出来的(
node scripts/probe-client-reload.mjs): 宿主在启动时就把各插件的客户端 bundle读进内存了(bundle 地址带?rev=<内容哈希>, 内容在启动那一刻定死)。所以哪怕只改了客户端那半边,刷新页面也拿不到新代码—— 这里踩过一次,写下来免得再猜。
重启后侧栏会出现 「远端工作台」 入口(在「插件」下面)。此时还没有实例,把下面三段里
你需要的抄进 C:\Users\<你>\.dsh\profiles\desktop\cordis.patch.yml:
- id: remote-desks
config:
instances:
- id: wsl-ubuntu # 在 WSL 里跑一个 DSH,用它做 Linux 侧的活
kind: wsl
label: WSL · Ubuntu-24.04
distro: Ubuntu-24.04
cwd: /home/you/project
- id: local-dev # 本机那套 DSH——桌面应用打开时它本来就在跑
kind: local
label: 本机
# profile / cwd 在吸附时用不上(压根没有新进程);要另起一份独立进程就写 attach: false
- id: build-server # 连到服务器上已经在跑的 DSH
kind: ssh
label: 构建机
host: 10.0.0.8
username: deploy
cwd: /srv/app
auth: { method: privateKey, privateKeyPath: C:\Users\you\.ssh\id_ed25519 }
switcher: # 窗口角上的「本机 / WSL」下拉框
enabled: true
corner: top-center # top-center(默认)| top-right | top-left
offsetX: 56 # 距边缘的像素(默认躲开官方那颗侧栏开关)
# offsetY 留空 = 跟随官方 --dsh-frame-overlay-top(桌面版 = 标题栏高度 + 20px)
layout:
mode: immersive # immersive 统一界面(默认)| manage 管理界面
collapseSidebar: false # true = 进统一界面时顺手把官方侧栏收成窄条
改完再重启一次(配置在启动时读取)。
| 形态 | 长什么样 | 什么时候用 |
|---|---|---|
| 统一界面(默认) | 选中那台机器的 DSH 界面铺满整个主区,我们自己的列表 / 工具栏 / 日志全部收起;窗口上边那条控制条上有关键字、状态与全窗口 / 重载 / 管理。镜像区域里一个我们画的浮层都没有 | 平时干活:本机 / WSL / 虚拟机 / 远程,点一下就是那台机器的那套界面 |
| 管理界面 | 实例列表 + 工具栏(预检 / 启停 / 更新 / 回滚)+ 镜像 + 日志抽屉 | 配置、排障、更新版本 |
layout.mode 决定,用户点过之后以用户为准;窗口上边那一条(drd-switch)是这个插件唯一常驻的界面,它同时干了三件事:
本机 / WSL · Ubuntu-24.04 /
SSH · 虚拟机 Ubuntu),旁边两三个字是状态(运行中 / 已连接 / 未启动 / 出错),
完整说明(端口、吸附、错误原因)在悬停提示里;!,悬停看原因——
正常用 iframe 不会在界面上唠叨。选中一项就做三件事:切到「远端工作台」面板 → 需要的话把它跑起来 → 显示它的完整界面。
以前这些动作分在两处:右下角一条浮条 + 左下角一条"内嵌框架(iframe)"浮签——它们正好压住 远端界面自己的输入区和侧栏底部(用户截图反馈过)。现在镜像区域里一个插件画的元素都没有。
摆位是自己算的(useSwitcherPlacement),目标是"谁也不挡":
| 情形 | 位置 | 为什么 |
|---|---|---|
| 桌面版(有窗口标题栏) | 钻进标题栏那一条,水平居中、垂直居中在带子里 | 那条带子左边是窗口菜单、右边是三个窗口按钮,中间是空的;会话表头在它下面一行,所以谁也不挡 |
| 纯 Web 外壳(没有标题栏) | 官方给帧级浮层准备的 --dsh-frame-overlay-top | 没有窗口 chrome 可钻,就用官方那条基线 |
镜像里不会再长出第二个「本机」:镜像出来的就是同一个应用,它自己也会加载本插件的客户端;
现在只要发现自己被嵌在 iframe 里(self !== top)就什么都不画。
--dsh-frame-top-clearance(桌面版 = 标题栏高度,
Web 外壳没有这个值),不写死平台、也不猜类名;resize + ResizeObserver + 属性 MutationObserver + 1 秒兜底轮询)——
窗口一改,它跟着重新居中,不是"挂上去就不动了";-webkit-app-region: no-drag,否则点不动;switcher.offsetY(像素);switcher.corner 另有 top-right / top-left,
贴右上角时面板表头会通过 --drd-switch-reserve 自动让位。其他:状态点跟着当前那台走(灰=未启动,绿=运行中);起不来时在那里直接写明原因;
面板里的列表与它共用同一个选中项;不想看到它就 switcher.enabled: false。
不用新的 kind:虚拟机走的就是 ssh(虚拟机里跑 sshd)或 wsl(WSL2 里的发行版),
拉起方式、镜像代理、预检、更新全都复用现成的三条腿。给它起个短 label,
下拉框里就是那个关键字:
- id: vm-ubuntu
kind: ssh
label: 虚拟机 Ubuntu # 下拉框里显示「SSH · 虚拟机 Ubuntu」
host: 192.168.64.7
username: dev
cwd: /srv/app
auth: { method: privateKey, privateKeyPath: C:\Users\you\.ssh\id_ed25519 }
桌面应用打开时,本机那套 DSH(app.asar 里的内置运行时)本来就在跑——你现在看的这个界面
就是它。所以 kind: local 的实例默认吸附它,而不是再拉一份同样的运行时:
127.0.0.1:19387;profile / cwd / entry 在吸附时都不参与(没有进程要启动);什么时候会吸附,什么时候不会:
| 配置 | 行为 |
|---|---|
attach 留空(默认) | 桌面版内置运行时才吸附;其它宿主(dsh web、验证脚本起的临时宿主)照旧自己拉进程——老配置的语义不会被悄悄改掉 |
attach: true | 无论宿主是什么都吸附(宿主自己的 web 端口 + 进程令牌就够) |
attach: false | 永不吸附,照旧用独立 profile 拉一份自己的 DSH 进程 |
宿主要是拿不到自己的端口或令牌(很老的版本),吸附会明说做不到,不会悄悄退回"再拉一份"。
| 位置 | 含义 |
|---|---|
| 状态点 | 灰=未启动,黄=启动中/停止中,绿=运行中,红=出错(旁边写明原因) |
| 「运行中,远端端口 N」 | 那个 DSH 在它自己机器上监听的端口(只绑回环,外部访问不到) |
| 「运行中(吸附宿主自身,端口 19387)」 | 这一项没有自己的进程,镜像的就是桌面应用自己那套 |
| 镜像区下方小字 | 当前用的是哪种容器(桌面原生视图 / 内嵌框架 / 系统浏览器)及降级原因 |
| 「预检」结果 | 逐项红绿,缺什么一目了然 |
| 日志抽屉 | 该实例进程的输出;启动失败、更新结果都写在这里 |
实例已停止时,工具栏的「更新」可用:先读出当前版本 → 跑更新命令 → 再读一次版本并记录 结果(面板显示「上次更新:0.2.0-rc.2 → 0.2.1-rc.2」)。想退回就点「回滚」,它用记下来的旧版本 再跑同一条命令。
默认更新到「跟桌面版同版本」,不是 npm 的 latest 标签——这两个会不一样。工具栏那个
「更新到」输入框可以改:填版本号(0.2.1-rc.2)或 dist-tag(latest / next / alpha),
留空就是跟桌面版一致。
为什么不是
latest:npm 的 dist-tag 由发布方维护,实测桌面版已经跑到0.2.1-rc.2、 而latest还停在0.2.0-rc.2。以前默认写死@latest,点「更新」等于把同一个版本 原地重装 76 秒,版本号纹丝不动——看起来就是"更新不了"。 现在:目标是具体版本且已经就是它(或实例比它还新)时,命令根本不跑,直接回 「已经和桌面版同版本(0.2.1-rc.2),无需更新」。
默认命令按实例类型给:
| 实例类型 | 默认更新命令 |
|---|---|
wsl / ssh | npm i -g @deepseek-ai/dsh@{version}({version} 默认是桌面版的版本,回滚时换成旧版本) |
local | 没有默认值。本机那份 DSH 可能是内置运行时、全局 npm 或自己 clone 的,猜错只会装出一份用不到的副本——请在实例上写 updateCommand |
三条硬规矩:
app.asar 里那份)拒绝更新,并告诉你该走桌面应用自己的更新,
而不是假装成功。想用自己的方式更新?在实例配置里写 updateCommand,命令里可带 {version} 占位符
(带占位符才支持精确回滚):
- id: local-dev
kind: local
updateCommand: npm i -g @deepseek-ai/dsh@{version}
openMode: rightbar(把镜像开进官方右栏浏览器标签)是实验性的,见下文容器表。同一台机器上经常不止一套 DSH:Windows 本机一套、WSL 里一套、服务器上还有几套。 它们的 WebUI 各自独立,切换要开浏览器、记端口、贴带 token 的地址。
这个插件把那些实例的界面直接搬进桌面应用:左侧列出实例,点一下就在主面板里看到它的 完整界面,并且能在这里启动、停止它们。
| 约束 | 结论 |
|---|---|
桌面窗口允许 <webview>,但无 lease 的挂载会被主进程拦掉 | 用官方 window.dshDesktop.browser.acquire() 拿 lease,再挂 <webview> |
| guest 禁止访问与宿主同端口的回环地址 | 每个实例一个独立端口的镜像端点,不挂在本地 DSH 的 19387 上 |
桌面渲染层 origin 是 dsh-app://app,其非静态路径会转发给本地 host | 插件注册的 host 路由在桌面版与纯 Web 版里都能用相对路径 fetch 到 |
| 插件路由默认不鉴权 | 控制接口自带闸门:优先复用官方 connection.requestRejection,否则用等价的回环校验 |
远端 UI 全部使用文档相对路径(<base href="./">、WS 取 document.baseURI) | 代理可以原样转发,不需要改写远端 HTML |
desktop profile 由桌面应用独占,且 19387 是硬编码端口 | 自己拉起的实例一律用独立 profile + --port 0 |
pnpm install
pnpm verify # tsc + 客户端 bundle + 冒烟测试
dsh plugin --profile desktop add <本目录路径>
cordis.patch.yml 会被自动并入 profile(dsh.bundle.patch)。装好后重启桌面应用,
侧栏会出现「远端工作台」入口;设置页里也有一节同名页面,可以查看宿主能力矩阵。
- id: remote-desks
name: dsh-remote-desks
config:
instances:
- id: wsl-ubuntu
kind: wsl
distro: Ubuntu-24.04
user: you
cwd: /home/you/project
# 慢速远端可以调大等就绪行的上限(毫秒,默认 90000)
# readyTimeoutMs: 180000
- id: local-dev
kind: local
label: 本机
# 吸附(默认,桌面版内置运行时):不拉进程,打开应用就是运行中。
# attach: true # 任何宿主都吸附
# attach: false # 永不吸附,用下面这份 profile 自己拉一个独立进程
profile: mirror-local-dev
cwd: D:\work\demo
- id: build-server
kind: ssh
host: 10.0.0.8
username: deploy
auth:
method: privateKey
privateKeyPath: C:\Users\you\.ssh\id_ed25519
switcher:
enabled: true
corner: top-center
offsetX: 56
layout:
mode: immersive # immersive 统一界面(默认)| manage 管理界面
collapseSidebar: false # true = 进统一界面时顺手把官方侧栏收成窄条
密码与口令不写进配置,只写凭据名(passwordCredential / passphraseCredential),
值放在 DSH 凭据库里。
| 字段 | 默认 | 说明 |
|---|---|---|
layout.mode | immersive | 面板默认形态:统一界面(镜像铺满)/ 管理界面。用户在面板上点过之后以用户为准(记在本地) |
layout.collapseSidebar | false | 进统一界面时是否顺手把官方侧栏收成窄条(控制条上的「全窗口」随时可以手动切) |
switcher.* | 见上 | 窗口角上下拉框的开关、贴角与偏移;offsetY 留空 = 跟官方 --dsh-frame-overlay-top |
| 字段 | 默认 | 说明 |
|---|---|---|
attach | 留空 = 自动 | local 实例是否吸附宿主自身:留空时桌面版内置运行时自动吸附,其它宿主照旧拉进程;true 强制吸附;false 强制不吸附 |
kind | 必填 | 只有 local 参与吸附(wsl / ssh 上的 DSH 不可能"就是宿主自己");虚拟机走 ssh / wsl,不需要新 kind |
label | 同 id | 下拉框里的关键字:括注会被去掉(本机(内置运行时) → 本机),没写类型词就补一个(虚拟机 Ubuntu → SSH · 虚拟机 Ubuntu) |
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /remote-desks/api/state | 能力矩阵 + 配置摘要 |
GET | /remote-desks/api/ping | 存活探测 |
GET | /remote-desks/api/instances | 全部实例的运行时快照 |
GET | /remote-desks/api/instances/:id | 单个实例快照(含 phase / 镜像地址 / 退出码) |
GET | /remote-desks/api/instances/:id/logs?offset=N | 增量拉日志(行号偏移,环形缓冲 600 行) |
GET | /remote-desks/api/instances/:id/check | 预检:不启动实例,只列出缺什么(含 DSH 版本) |
POST | /remote-desks/api/instances/:id/start|stop|restart | 生命周期操作 |
GET | /remote-desks/api/instances/:id/version | 探测该实例的 DSH 版本(本机读 package.json,WSL/SSH 跑 dsh -V) |
POST | /remote-desks/api/instances/:id/update | 更新:升级该实例上安装的 DSH。可选 JSON 体 {"target":"0.2.1-rc.2"}(也收 dist-tag:next / alpha / latest),省略即跟桌面版同版本 |
POST | /remote-desks/api/instances/:id/rollback | 回滚到最近一次更新前的版本(要求命令里带 {version}) |
响应是 UpdateOutcome:target(这次实际拿去的目标)、skipped(true 表示没跑命令——已经
是目标版本或比它还新)、command、before / after(两次版本探测)、record.detail(一句话结果)。
更新与回滚的拒绝语义(都是 409 + message):实例未停止、内置运行时(app.asar)、
本机实例没配 updateCommand、没有可回滚的记录。未知实例一律 404。
预检(面板工具栏上的「预检」按钮)回答的是"点了启动会不会失败":
DSH_HOME、profile 状态;
吸附的实例不启动任何进程,所以只回答吸附本身(吸附目标端口 + 说明 profile/cwd 不参与)检查项都是廉价只读探测,不产生副作用;失败项会直接给出"缺什么"而不是一句报错。
两道闸门叠加,缺一不可:
connection.requestRejection(认识 trustedHosts 与 Sec-Fetch-Site,并校验会话 cookie);127.0.0.1 / localhost / [::1],带 Origin 时其 Host 必须与请求 Host 一致)。控制接口能启停进程,所以第二层不是多余的。判定的结果会写在 /api/state 的 control.gate 里。
因此:裸 curl(没有会话 cookie)拿到 401,伪造非回环 Host 拿到 403,桌面版与纯 Web 版
(都带 host 自己发的会话 cookie)才拿得到 200。
每个运行中的实例独占一个只监听回环的 HTTP 端口,把远端实例的 UI 原样搬到
http://127.0.0.1:<port>/,HTTP / WebSocket / SSE 都转发。为什么不做成本地 DSH 的一条子路径:
isApplicationHost),挂在 19387 上会被直接拦掉;/api(confused deputy),独立端口天然做掉源隔离。访问需要票据:首次打开 ?k=<ticket> 换成 cookie,之后所有子请求与 WS 握手都靠它;
无票据或票据错误一律 403。转发时会把 host / origin 改写成远端自己的 authority、
注入启动时换好的远端会话 cookie,并剥掉 content-security-policy / x-frame-options
(否则嵌不进来)与逐跳头,同时把 set-cookie 归一成属于镜像 origin 的形态。
桌面版里顶层文档是 dsh-app://app(不是 http 源),所以嵌进去的镜像是第三方 iframe——
而 SameSite=Lax 的 cookie 在第三方 iframe 里不会被带上,症状就是每一个子请求都撞回
403「需要票据」。所以那张票据 cookie 按 Sec-Fetch-Site 自适应:
| 这次访问 | 下发的 cookie |
|---|---|
cross-site(或拿不到这个头) | SameSite=None; Secure——Secure 在 http://127.0.0.1 上是允许的(Chromium 把回环当可信来源) |
same-origin / same-site | 仍旧 SameSite=Lax,少给一点权限 |
pnpm verify:ui 里有一条跨站 iframe 回归:用另一个窗口把顶层分别换成 http://localhost:<port>
(与 127.0.0.1 不同站)与 file:///…(更接近桌面版那种非 http 顶层),iframe 直指镜像入口,
再到主进程侧读子帧内容——两种顶层都要渲染出远端 UI、都不能出现「需要票据」。
mirror:
host: 127.0.0.1 # 只允许回环
portRange: [19500, 19510] # 端点在这个范围内找空位;全占满则退回系统分配
openMode: auto # 容器偏好,见下表
openMode 决定镜像用哪种容器承载,四种取值都真的生效(不是解析完就放着):
| 取值 | 行为 | 真机验证 |
|---|---|---|
auto(默认) | 直接内嵌框架,不再先试桌面原生视图(那条路要申请租约、等放行,失败还要等看门狗超时——用户实测就是"渲染时间过长") | ✅ 浏览器里实测走 iframe,且第一屏立刻开始加载 |
webview | 强制桌面原生视图(官方 webview lease,独立分区、不与应用共享 DOM);挂不上 / 没导航 / 渲染不出来都会自动降级成内嵌框架并写明原因 | ✅ 实测按预期降级并给出说明 |
iframe | 同 auto 的内嵌框架(显式写法) | ✅ 实测 iframe + 镜像 UI 渲染 |
browser | 不做内嵌,交给系统浏览器打开 | ✅ 实测面板不再内嵌、给出说明 |
rightbar | 实验性:先把主区切回对话(右栏是会话作用域的,本面板占着主区时工作面根本没挂载),再尝试把镜像开进官方右栏浏览器标签;失败会退回内嵌框架并说明原因 | ⚠️ 标签能建起来,但右栏不一定真的打开;且 ui-sidebar-browser 在 Web profile 默认禁用。故不承诺 |
面板上会写明当前用的是哪种容器,方便对着现象排查。
远端那套前端是按需组合的(plugins/??a,b,c&rev=… 这种 URL,远端第一次请求时才现读现拼几 MB),
再叠上"下载 → 解析 → 挂载 → 连 WS",一次冷启动本来就不便宜。四条改动把这份成本挪走或摊掉:
| 改动 | 效果 |
|---|---|
auto 改成内嵌框架优先 | 省掉申请租约 + 等主进程放行 + 看门狗超时(原本最坏要等 6–8 秒才降级) |
| 镜像端口按实例复用(记住上次用的那个) | 镜像 origin 就是浏览器缓存与镜像里那套 DSH 的 localStorage 的键;端口稳定 = 第二次打开命中缓存、远端界面的偏好也还在 |
镜像端点给内容寻址的前端资源做内存缓存(rev= / -<hash>.js,32 MB 预算,x-drd-cache: hit 可查) | 同一份 bundle 不再反复回上游要;/api/* 与无哈希路径一律不缓存 |
| 实例一就绪就预热:后台把首页引用的 bundle 全抓一遍(并发 3、60 秒预算、纯尽力而为) | 远端的"现拼"成本在用户切过去之前就付掉了;日志里能看到 预热:N/M 个前端资源已进镜像缓存 |
再加一条交互上的:最近看过的 3 台镜像保持挂载(隐藏但不卸载),切回来连 WS 都没断, 是真正的瞬间切换——不是"重新渲染一遍"。
M4 之前有人看到的现象是:镜像区一直在闪、永远显示不出东西。两个原因,都已修掉,也都进了冒烟断言:
<webview> 的 dom-ready 事件对每一次导航都触发,而当时的代码是
"每次 dom-ready 都 setAttribute('src', entryUrl)"——于是加载 → dom-ready → 再设一次 src →
再加载……每几百毫秒一次,界面自然一直在抖,也永远渲染不出东西。现在导航只做一次(navigated 闸门),
并且导航后另有 10 秒的超时兜底。inject 回调每次调用都返回新对象)。外壳一重渲染,effect 就"拆掉视图 → 重新挂一个",
表现同样是闪。现在服务走 ref,依赖只剩「镜像地址」和「容器偏好」——只在这两件事变了才重建。另外,桌面原生视图"挂上了却是空的"现在会自动降级成内嵌框架(不是只给一个按钮):加载完成后 等 2.5 秒探一眼 guest 里到底有没有内容,确实是空的就换载体并在下方写明原因。
试桌面原生视图时,三步各自有上限,超了就换内嵌框架并把原因写在镜像区下方:
| 看门狗 | 上限 | 说明 |
|---|---|---|
| 挂载 | 6 秒 | 挂上去连 dom-ready 都没来:多半是主进程没放行(租约 / partition 不匹配) |
| 导航 | 8 秒 | 已经发出导航但没加载完:被拦、或上游挂着 |
| 内容 | 加载完 + 2.5 秒 | 加载"完成"了里面却是空的(远端 UI 没渲染出来) |
踩过的坑:
did-finish-load对最初那一跳about:blank#<lease>也会触发。曾经把 "还是 about:blank 就算没导航"直接写在这个回调里——于是在导航还没开始时就误判成 "主进程拒绝了挂载",并立刻降级。现在降级判定只在真的发出过导航之后才生效,并且导航后 1 秒内到达的那次"加载结束"直接忽略;"没导航"那条提示还会把当时的地址一起打出来。
写 WSL 启动命令时踩到的坑,已固化在默认命令里:
P=...、export P=...、declare P=... 都会被 wsl.exe 当环境变量赋值吃掉
(所以路径一律内联 $HOME);另外两个环境事实:发行版里的 node 常常只配在 ~/.bashrc(nvm),登录 shell 不加载,所以默认命令
会显式 source ~/.nvm/nvm.sh;WSL 里绑 127.0.0.1 的端口能否被 Windows 直连取决于网络模式
(镜像网络可以,默认 NAT 不行),监管器会先探测再决定,不通就给出明确诊断而不是留一个连不上的镜像。
想用自己的启动方式?把
launchCommand写进实例配置即可,但上面三条约束同样适用。
- id: build-server
kind: ssh
host: 10.0.0.8
port: 22
username: deploy
cwd: /srv/app
auth:
method: password # privateKey | password | agent
passwordCredential: BUILD_SERVER_PASSWORD
# 可选:固定主机密钥指纹(SHA256 base64)
# hostKeyFingerprint: SHA256:xxxx
# 可选:跳板链
# jumpHosts:
# - { host: jump.example, port: 22, username: ops }
privateKey 读 privateKeyPath(口令走 passphraseCredential)、password 走
passwordCredential、agent 用 SSH_AUTH_SOCK(Windows 退到 \\.\pipe\openssh-ssh-agent)。*Credential 字段是 DSH 凭据库里的名字。名字也可以直接是环境变量名——
凭据服务本身就把进程环境当作一层来源,所以 CI/验证场景不必往凭据库里写东西。SHA256:… 指纹;把指纹写进
hostKeyFingerprint 即变成硬校验,不匹配直接拒连。direct-tcpip 通道
(forwardOut)。远端不需要暴露端口,也不用建本地端口转发;换 cookie 的那次请求同样走隧道。这台机器上既没有 Windows sshd、WSL 也没装 openssh-server,所以仓库自带一个测试对端
(scripts/ssh-test-server.mjs,用 ssh2 的 Server 实现,把 exec 与 direct-tcpip 都转给 WSL):
node scripts/ssh-test-server.mjs --port 12222 --password dsh-test # 手动起
node scripts/verify-live.mjs --ssh --pnpm <pnpm.mjs> --node <node.exe> # 验证脚本自己起
它只用于验证我们这一侧的连接、认证、通道与隧道代码,不要当生产服务用。
pnpm install
pnpm build # tsc -> lib/,再把客户端打成一枚 __ModuleLoader__ bundle
pnpm typecheck # host 与 client 两套 tsconfig
pnpm smoke # 载入真实产物跑行为断言(315 项)
pnpm verify # build + smoke
pnpm verify:live # 起一个真实 DSH 实例做端到端验证(见下)
另外一个专门的预演脚本——用桌面版自带运行时把"点启动"整条路径走一遍:
node scripts/verify-electron-local.mjs [--app "<DeepSeek Harness.exe>"]
它不宿主、不起 profile 树,而是直接调本插件的 planFor / exchangeSession / MirrorEndpoint:
用 app.asar 里的 DSH 入口 + Electron Node 模式构造启动计划(校验 argv 与 env),真的把进程
起起来,等就绪行、换会话 cookie、起镜像端点、经代理取回远端 UI。这条路径与你第一次点「启动」
时执行的完全一致。
第三道 · pnpm verify:ui(真实浏览器驱动界面) 起宿主后,用 Electron(与桌面应用同一套
Chromium)打开真实界面,走正常入口(token → cookie),然后真的去点:
ok 界面已加载 — /
ok 窗口角上出现切换下拉框 — {"corner":"top-right","gapRight":56,"pointerEvents":"auto","options":["本机预演实例"],"value":"ui-local"}
ok 切换器在视口内、贴右上角 / 自己接回指针事件(浮层是点击穿透的)/ 列出了实例
ok 侧栏里找到本插件入口 {"label":"远端工作台", …}
ok 默认形态是统一界面 — {"immersive":true,"hasList":false,"hasToolbar":false,"buttons":["启动","管理界面"]}
ok 未运行时给的是启动卡片(不是把人丢进管理界面) — 启动 / 管理界面
ok 卡片上的「管理界面」能切过去 / 切换后管理界面出来了
ok 面板渲染出内容 / 面板里看得到实例 / 切换器的选中项与面板一致
ok 点到了「启动」 → 面板显示运行中 → 镜像容器类型符合预期(iframe)
ok 管理界面上的「统一界面」能切回去
ok 统一界面下镜像铺满主区 — 容器 {"w":987,"h":611}|镜像 {"w":987,"h":611}
ok 统一界面下切换器还在(它在窗口上边那条里,不在镜像上)
ok 镜像区域里没有任何插件浮层
ok 「全窗口」把官方侧栏收成了窄条 / 再点一次能展开回来
ok localhost:镜像帧存在 / 不再显示「需要票据」/ 渲染出远端 UI
ok 跨站 iframe 下发的票据 cookie 是 SameSite=None; Secure — dsh_mirror=…; SameSite=None; Secure
ok file:镜像帧存在 / 不再显示「需要票据」/ 渲染出远端 UI
ok 控制台无报错
跑完把截图写到 .recon/ui/(panel-stopped.png / panel-running.png / panel-immersive.png /
panel-immersive-fullwindow.png / cross-site-*.png),可以直接看。
--open-mode all 会把五种容器各跑一遍(rightbar 是实验性,只验"不崩、有说明"):
openMode=auto 通过(11 项) openMode=iframe 通过(9 项)
openMode=webview 通过(9 项) openMode=browser 通过(9 项)
openMode=rightbar 通过(3 项,实验性,不做严格断言)
这个脚本当初一跑就抓到一个会让面板完全不出现的 bug:slot 的
inject回调里读了ctx.layout却没在插件inject列表里声明,Cordis 抛 "cannot get property ... without inject",注册整体失败。冒烟、SSR、活体 HTTP 全都测不出来——只有真壳子会暴露。
第一道 · pnpm smoke(285 项) 跑真实产物,不复述实现:
lib/index.js,检查插件契约(name / inject / Config / apply),
用真实 Config 校验空配置补全、默认值、非法 kind / corner 与缺 id 的拒绝;ctx 调 apply(),再拿真实 handler 发请求:回环 200、IPv6 回环 200、
局域网 Host 403、跨站 Origin 403、未知路径 404、非 GET 404、缺 Host 403;
再用带 connection.requestRejection 的 ctx 验证两道闸门叠加(官方拒绝 401、
官方放行但非回环仍 403);node:vm 里加载 lib/client.js,喂假的 window.__ModuleLoader__ 与 require
(非基线模块直接报错),取出工厂并调 apply(),断言它挂上了
sidebar.panellist / main / settings.section / shell.overlay 四处注册,且面板 id 与入口 id 一致;react-dom/server 把面板(统一界面 / 管理界面 / 空配置 / 带数据 / 吸附中 / 未运行的启动卡片)、
实例列表、工具栏、切换器(正常 / 关掉 / 无实例)真的渲染一遍,并单测短标签与状态词
(去括注、补类型词、退回 id、吸附时叫「已连接」);SameSite=None; Secure、同站拿到 Lax、
带上跨站 cookie 能取回上游内容、不带仍旧 403;缓存判据(只认内容寻址、未压缩的静态资源);303 + set-cookie 的假宿主,走一遍
「等令牌 → 换 cookie(含启动早期的 404 退避)→ 起镜像端点 → 预热 → 票据换 cookie →
取回宿主 UI → 同一份 bundle 第二次命中缓存」,并断言全程没有调用 subprocess、
停止不动宿主、再吸附复用同一个镜像端口、autoAttach 只接该接的那条。第二道 · pnpm verify:live(三条腿:本机 / WSL / SSH) 起一个真实 DSH web 宿主(临时 profile,
dsh-base + dsh-web-app + 本插件),在里面驱动插件真的拉起实例,并逐条验证:
node scripts/verify-live.mjs \
--pnpm "C:\Users\you\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\pnpm\bin\pnpm.mjs" \
--node "C:\Users\you\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" \
--wsl Ubuntu-24.04 --ssh
它会:等 dsh web: 就绪行 → 裸请求被拒(401)→ token 换 cookie → 能力矩阵 200
(gate = connection.requestRejection + loopback-guard)→ 伪造非回环 Host 得 403 →
index 的 window.__DSH_BOOT__ 里出现本插件的行 → 取到 __ModuleLoader__ 形态的客户端 bundle;
然后对每一个实例:启动 → 就绪 → 无票据 403 → 票据换 cookie 302 → 镜像 UI 200 →
镜像里的子资源 200 → 停止 → 端点收摊。跑完自动清理 profile(--keep-profile 可保留)。
它还带两个故意坏掉的实例,专门盯失败路径:入口文件不存在的本机实例(进程退出 → error
→ 有可读原因 → 不暴露端点 → 仍可重试),以及永不打印就绪行的 WSL 实例(按
readyTimeoutMs 超时 → error → "等待就绪行超时")。
吸附宿主自身也在里面走了一遍(临时 profile 里那条 attach: true 的实例):
ok 吸附实例启动时自动进入 running — running:运行中(吸附宿主自身,端口 19413)
ok 吸附的"远端端口"就是宿主端口 — 19413 vs 19413
ok 吸附时没有起第二个进程(日志里没有启动命令)
ok 吸附镜像取回宿主 UI → 200 — 200
ok 吸附镜像就是宿主自己那套(含本插件行)
ok 停止吸附后宿主自己照旧在跑 — 200
ok 再吸附后回到 running / 换了新票据 / 镜像照旧可取
这一节压的是两条启动时序:进程令牌要等 connection 挂上,/ 要等前端静态兜底认领——
两处都不是"失败就该放弃",而是"等一等/重试几轮"。
更新与回滚则在三条通道上各走一遍(本机 / WSL / SSH),命令统一用无害的
echo update-probe {version}——验证脚本不该动你机器上的真实安装:
ok 显式 dist-tag 原样渲染 — echo update-probe next
ok 已是最新 → skipped=true — true
ok 已是最新 → 不执行命令 — (未执行)
ok 已是最新 → 说明里写了"无需更新" — 已经是指定版本 0.2.0-rc.1,无需更新
ok 默认目标是具体版本,不是 latest — 0.2.0-rc.1
ok 默认目标要么跳过、要么按该版本渲染命令 — (未执行)(skipped=true)
ok WSL 通道探测到版本 — 0.2.1-rc.2(来源 dsh -V)
ok WSL 通道按指定目标渲染 — echo update-probe next
ok WSL 通道回滚成功 — echo update-probe 0.2.0-rc.1
这几条各自压着不同的代码路径:本机读 package.json;WSL 走 wsl.exe + bash(要带 nvm 前置,
否则非交互 shell 里找不到 node);SSH 临时开一条连接、用完即收(日志里能看到"目标 SSH 连接已关闭")。
「已是最新 → 不执行命令」这条是这一版的回归重点:默认目标就是桌面版版本时,不许再原地重装一遍。
第三道 · 装进桌面版:dsh-remote-desks 已装入 desktop profile,dsh.bundle.patch 会把
remote-desks 行插进 Loader 树。桌面版是启动型 profile,加载新 bundle 行需要重启一次
桌面应用;重启后侧栏出现「远端工作台」入口,窗口右上角出现「本机 / WSL」下拉框,
kind: local 那一项应当已经是「运行中(吸附宿主自身)」。
客户端那一半是应用启动时读进内存的(bundle 地址带内容哈希),所以改了客户端必须完整重启, 刷新窗口不够——这条是实测出来的(
node scripts/probe-client-reload.mjs),M5 也一样适用。
src/
index.ts host 入口:装配、控制路由、自机端点探测、就绪日志
config.ts 配置 schema 与类型(含 attach / switcher / layout)
capabilities.ts 宿主能力探测(服务、运行形态、DSH 发行版入口、自机端口)
control/routes.ts 控制接口 + 回环闸门
instances/attach.ts