前言¶
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 的「核心判断」):
- 这不是 SSH 客户端,而是「本地工作区的远端影子」,因为 agent 和侧边栏都在本地工作。
- DSH 有
ctx.fs/ctx.subprocess执行世界抽象,理论上可以把整个 harness 搬到远端;但dsh-better-sidebar绕过ctx.fs直读本地,两条路线混用会产生 split-brain。 - 因此 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