dsh-remote-ssh:把远端机器当本地用的 DSH 插件

前言

DSH(DeepSeek Harness)跑的活,默认都落在本机工作区:agent 读写本地文件、终端在本地起。一旦任务的目标机器在另一台服务器上,就要在本地工作区和远端 SSH 会话之间反复手工搬东西——开多个终端窗口、手动 scp、靠记忆记端口转发。也有另一类做法:干脆把整个 harness 搬到远端执行。这个插件走的是中间路线:本地工作区仍是执行现场,远端是它的「影子」,面板、终端、文件、同步、转发都收进 DSH 侧边栏。

下面介绍的是 dsh-remote-ssh,由 LinHao100076 维护(仓库 LinHao100076/dsh-ssh-remote,GitHub 3 stars,分类客户端,MIT 许可)。它不是通用 SSH 客户端,而是给 DSH 增加一个「远程 SSH」面板,覆盖文件树、远端终端、编辑同步、传输断点续传和 -L / -D / -R 三种转发。

这是什么

一句话定位:在 DSH 侧边栏里浏览远端文件系统、开远端终端、按映射方向同步本地改动、并在本机与远端之间建立端口转发。维护者 LinHao100076,仓库公开,MIT 许可。

仓库对自身定位写得很明确(见 docs/design.md 与 README.md 的「核心判断」):

  1. 这不是 SSH 客户端,而是「本地工作区的远端影子」,因为 agent 和侧边栏都在本地工作。
  2. DSH 有 ctx.fs / ctx.subprocess 执行世界抽象,理论上可以把整个 harness 搬到远端;但 dsh-better-sidebar 绕过 ctx.fs 直读本地,两条路线混用会产生 split-brain。
  3. 因此 0.x 版本走「本地镜像 + 旁挂远端通道」,执行世界替换留作 1.0 的独立 profile 实验。

版本情况:package.json 里是 0.7.0;CHANGELOG.md 中 0.7.0 标注日期为 2026-11-06。README 正文仍写着「当前版本 0.6.1 —— 第一个正式版」,两个位置的说法不一致,以仓库实际文件为准。0.7.0 的主要增量是密码 / OTP 认证和多跳跳板链路;0.6.1 修掉了 Node 20 上宿主端 bundle 加载即抛的问题,0.6.0 补 socks5 动态转发与 -R,0.5.0 加断点续传,0.4.0 加远端搜索。

入口与界面

插件提供一个「远程 SSH」面板,在两个位置出现,取哪条路取决于 dsh-better-sidebar 有没有装:

装了什么 出现在哪 怎么打开
有 dsh-better-sidebar better-sidebar 侧栏里一个名为「远程 SSH」的标签页 侧栏里点「远程 SSH」
没有 / 停用了 dsh-better-sidebar DSH 自带的右侧边栏,同一个面板 会话头部右上角的服务器图标按钮

两条路径互斥:若 better-sidebar 在启动后才激活,插件会把原生入口撤掉、换成同一个标签页,不会同时出现两个入口。面板由主机头 + 一条标签条(文件 / 终端 / 同步 / 转发 / 设置)+ 状态栏组成,宽度按 424px 基准、跟着右侧栏走,深色模式复用同一套 token 自动跟随 DSH 主题。界面基准是仓库里的 ui/prototype.html(单文件可直接打开,支持 ?view=terminal 等直达链接)。

安装与启用

README 给的安装方式是发布包 + dsh plugin 命令,没有提供 dsh plugin add github:owner/repo 形式的直接安装:

npm run pack:rel     # 生成 dist/dsh-remote-ssh-<版本>.tgz
dsh plugin --profile <profile> add -w "/path/to/plugin.tgz"

两点要注意:-w 不能省,profile 里有 pnpm-workspace.yaml,不带它会报 ERR_PNPM_ADDING_TO_ROOT;不要解压 tgz 后指向解压出来的目录,pnpm 会写成 link:,链接目录不安装自身依赖。0.3.1 起后一条路也已可用——发布包内零运行期依赖,ssh2、ws、chokidar 全部被 esbuild 打进 lib/server.mjs(约 1 MiB)。

装完 package.json 会变成:

"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-remote-ssh"] } },
"dependencies": { "dsh-remote-ssh": "file:C:/.../dsh-remote-ssh-<版本>.tgz" }

插件把主机与映射存在 ~/.dsh/storages/dsh-remote-ssh/state.json。密码和口令永不落盘,只存在浏览器内存里,每次连接现取。

启动命令(app 参数直接跟在后面,不写 web):

dsh --profile <name> --no-open --port 4987

desktop profile 由桌面应用独占,CLI 会拒绝(profile "desktop" is managed exclusively by the Electron application),这种 profile 请在应用内插件页安装,或手工改 package.json 的 bundles 与 cordis.patch.yml 后重启应用。开发期想直连源码目录时,可以只在 cordis.patch.yml 里插入:

- insert:
    - id: dsh-remote-ssh
      name: "dsh-remote-ssh"

核心功能

文件树与远端搜索

文件视图提供远端文件树、文本预览、上传下载。工具条上的放大镜在远端跑 rg(没有则 grep -r),只把命中的行拿回来,不下载文件、不建本地索引,所以能搜的范围永远比已映射的目录大。

1、默认字面量 + smart-case:输入 foo 忽略大小写,输入 Foo 区分大小写,Aa 强制区分,.* 才按正则解释。
2、结果按文件分组,点一条即打开文件并滚到那一行;超长行围绕命中位置裁剪。
3、默认范围是当前目录,rg 尊重 .gitignore;没有 rg 时 grep 显式跳过 .git、node_modules、.venv、__pycache__、target、dist、build、.next、.cache、.idea。
4、上限 200 条且明确标出截断,切断发生在远端(head -n)。
5、输出通道用 rg --json / grep -rInZ,避免 path:line:text 在带冒号路径或正文时解析错;老 rg 不认 --json 时降级到 grep 并记住。

终端与 tmux

远端有 tmux 时自动执行 tmux new -A -s dsh-<host>,刷新页面、IDE 重启、网络抖动之后都重新接回同一个 shell。远端没有 tmux 时退回普通登录 shell,并在终端上方明确警告「刷新页面或断线后,本次会话的进程会一起结束」,而不是假装一切正常。两个分支都在 test/e2e-profile.mjs 覆盖,包括实际下发的 argv 形状。

认证与跳板链路

0.7.0 补上了密码登录与 keyboard-interactive(2FA / OTP):只开 PasswordAuthentication 的服务器、只问键盘交互的服务器都能连;服务器连续问多个问题就弹多个框;猜错一次后改为直接问人,后台重连遇到需要凭据的场景返回 needs-credential 而不是卡在看不见的对话框上。

跳板不是独立页面,而是主机参数的一部分。支持多跳,每跳带自己的端口、账号与认证方式,摘要行形如 2 跳 bastion-a:22 → bastion-b:2222;~/.ssh/config 里的 ProxyJump a,b 会展开成两跳,首次连接后整条链路写回成可编辑的 hops。每跳各记自己的 TOFU 指纹(跳板换钥匙不会被当成目标机换钥匙),指纹按跳保存,密码与目标机密码分开存在内存里,都不写 config.json。

同步语义

按映射决定同步方向,规则如下:

  • 默认单向本地 → 远端,agent 改的文件自动推上去;打开双向后远端变更才会被下载。
  • 本地是真相:冲突判定 = 本地改过 且 远端 mtime 相对基线漂移超过 2s;冲突只报告,由 UI 给「保留本地 / 保留远端 / 忽略」。
  • 删除不传播:本地删除只在队列里记一笔,绝不在远端 rm。
  • 写入用临时文件 + rename 落地,避免读到半个文件;远端可执行位按本地 mode 推(Windows 上靠 modeTemplate 补)。

断点续传

传输按 1 MiB 分块顺序写,不是整包 fastPut / fastGet:

1、两个方向都先写同目录的 part 文件(<名字>.dsh-part-<大小>-<mtime>),结束后才 rename 成真名。
2、part 文件的大小就是断点:中断、断线、进程重启后重试同一个文件,会从 part 末尾接着传,只补发剩余部分。
3、part 名字里带源文件的大小和 mtime,源文件变了指纹就变了,旧 part 不会被拿来拼接,宁可重传。

端口转发

转发标签页里一条规则的形状是「一端监听 → 一端目标」,类型决定哪一端在本机:

类型 监听 目标 等价于
本地 本机 远端 host:port ssh -L 6379:127.0.0.1:6379 me@server
动态 本机(SOCKS5) 每次连接再定 ssh -D 1080 me@server
反向 远端 本机 host:port ssh -R 8080:127.0.0.1:3000 me@server

行为细节:

  • 绑定地址默认 127.0.0.1;选 0.0.0.0 会在面板顶部标红——对 -L / -D 是把远端服务暴露给整个局域网,对 -R 是把你的本机暴露给服务器所在的网络。
  • 默认随主机连接自动启动,也可关掉改成手动。
  • 走主机已建立的 SSH 连接(-L / -D 用 direct-tcpip,-R 用 forwarded-tcpip),无需二次认证。
  • 掉线恢复:本地监听器不随连接断开销毁,下一条入站连接惰性重连;-R 的监听端口在服务器上、断了就没了,重连后自动重新申请,没申请回来时面板显示「服务器上的端口暂时不存在」。
  • 端口冲突按「监听端 + 端口」判重,0.0.0.0:8080 与 127.0.0.1:8080 视为冲突;-L 与 -R 用同一端口号不算冲突。
  • 停止规则或断开主机时关闭监听、掐断隧道,-R 还会通知服务器停止监听(cancel-tcpip-forward)。
  • SOCKS5 只实现 CONNECT,BIND / UDP ASSOCIATE 明确回 0x07 command not supported。
  • 远端服务拒绝连接与 SSH 层失败是两种错误码(target-refused / transport),面板分别提示。

典型用法:没有服务器也能试

仓库里有本地假远端 scripts/dev-remote.mjs:exec 走本机 sh(命令拼错、引号写错当场暴露),SFTP 只读(删不掉真文件),但转发能力是真的,direct-tcpip 从本机连出去、tcpip-forward 在本机开监听再推通道回来:

node scripts/dev-remote.mjs /path/to/project 2202
# 面板里新建主机 127.0.0.1:2202,用户和密码随便填

建好后可以对着它真跑三种转发、远端搜索、文件传输断点续传,以及多跳跳板链路。

适用场景与注意事项

适合的场景:DSH agent 在本机跑、但要操作另一台服务器(看日志、改远端配置、起远端 dev server、把本地改动同步上去、临时 tunnel 给服务器用)的开发者;希望这些操作都留在 DSH 侧边栏里、不想另开 SSH 客户端的人。

需要注意:

  • 插件运行在当前 dsh 进程内,以该进程的权限执行,宿主端 bundle(lib/server.mjs,约 1 MiB,ssh2 / ws / chokidar 全部内联)由插件直接加载。安装前应先检查源码与许可证(MIT)再投入使用。
  • 0.x 的同步默认是本地 → 远端单向、本地为真相,冲突不自动裁决,先理解这条语义再用双向映射。
  • -R 会把本机端口暴露给服务器所在网络,0.0.0.0 绑定需谨慎。
  • 密码与口令只存浏览器内存,插件不负责记住密码(0.7.0 明确「还没做」),每次连接要重新输入或回到连接页问一次。
  • 生态背景:DSH 的理念是「一切皆插件」,社区目录站点(如 SkillHub)是独立站点,与 DeepSeek / 幻方无官方从属关系;该插件是第三方开源项目,不代表官方应用商店收录。

结尾

这套插件的价值在于把「本地工作区 + 远端机器」收敛进同一个 DSH 界面:文件、终端、同步、转发各占一屏,而不是四个独立工具。能力边界也写得很清楚:不做通用 SSH 客户端,不替换 DSH 的执行世界,不自动裁决冲突,不记住密码。

目录页:https://www.skillhub.cn/plugins/LinHao100076/dsh-ssh-remote

GitHub:https://github.com/LinHao100076/dsh-ssh-remote

羽毛球分组比赛记分
小程序二维码

欢迎使用《羽毛球分组比赛记分》微信小程序

小夜