Bundle
@jk-uzi/dsh-multi-remote
DSH 插件:SSH / WSL / Docker 三合一远程开发——agent 的文件读写、命令执行、搜索与终端全部落在目标环境,大脑留在本地
- Source
- jk-uzi
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-multi-remote
DSH(DeepSeek Harness)插件:**同时支持 SSH / WSL / Docker 三种远程环境**。
连上之后,agent 的文件读写、bash 执行、搜索全部在目标环境里执行——**大脑留在本地,文件和命令在远端**。
> **当前状态:三种后端全部可用(SSH / WSL / Docker)。** 见 [CHANGELOG](CHANGELOG.md)。
## 这是什么
| | 在哪 |
|---|---|
| 模型调用、思考、决策 | **本地**(装了 DSH 的这台机器) |
| 文件读写、命令执行 | **远端**(文件只有一份,就在远端) |
| 界面要看的文件树 / `@` 补全 / 改动对比 | 本地锚点目录里的**只读镜像** |
### 那个本地锚点目录是干什么的?会把我整个项目拉下来吗?
**不会。** 它是给**界面**看的:DSH 的文件树、编辑器、改动对比读的都是本地文件,
没有这份镜像,界面上就是空的。但它是**有界的、只读的、且不是 agent 干活的地方**。
**分两个阶段,各有边界**(都超出即停,并**如实标记截断**):
**① 打开工作区时**(要快 —— 用户在等着):
| 边界 | 默认值 | 超了会怎样 |
|---|---|---|
| 目录深度 | 12 层 | 更深的子树不搬 |
| 条目数 | 20 000 个(文件 + 目录) | 剩下的不搬 |
| 单文件大小 | 256 KiB | 本地留一个**占位文件**(见下) |
| 墙钟时间 | 30 秒 | 到点就停,树可能只覆盖一部分 |
> **拉取有多快**(2026-10-09 实测,WSL、197 个文本文件的项目):
> 首次全量 **~2.3 秒**(批量读:一次调用拉一批,而不是每文件一次);
> 重连 **~1.1 秒**(指纹命中跳过);改动一两个文件后重连,**只拉变化的那几个**。
> SSH 走并发拉取(SFTP 长连接天然支持流水线),Docker 的文件内容本来就走
> Engine API 的 archive(一次请求拿一批)。
**② 连上之后的「后台补拉」**(可以慢 —— 不阻塞任何操作):
| 边界 | 默认值 | 说明 |
|---|---|---|
| 只拉**文本类**文件 | 按扩展名(源码/配置/文档/脚本…) | 网格、数据库、压缩包、图片**不拉** —— 它们本来也不适合在界面里读 |
| 单文件大小 | 2 MiB | 比首轮大得多,覆盖绝大多数源码与文档 |
| 总字节上限 | 64 MiB | 到点就停(不会因为连了个 8 GB 的工程就往本地灌 8 GB) |
| 墙钟时间 | 120 秒 | 每次连接都会补一次;上一轮拉过的会跳过,所以**会一轮比一轮覆盖得多** |
> 为什么要后台补拉:界面的读文件路径是**宿主独占**的(我们插不进手),
> 所以「点开文件时自动从远程拉」做不到。退而求其次 —— **在点开之前**把
> 你真正会看的文件先补到本地。实测:一个 1.5 GB 的代码仓库,后台补拉
> 拉回 **138 个文本文件(3.1 MB)**,README / 配置 / 源码点开就有内容。
**占位文件**:超过单文件上限的文件,本地会留一个几百字节的说明文件,内容形如:
```
<!-- dsh-multi-remote:not-synced -->
这个文件没有同步到本地镜像。
远端大小:69.8 MB(单文件上限 256.0 KB)
远端路径:/home/user/my-project/data/mesh.msh
它只在远端 —— 内容没有拉下来(避免为了显示一个文件把几十 MB 搬过来)。
要看或改它,直接在会话里让 agent 读(agent 的文件读写走的是远端,不受这里限制),
或者在会话里用 bash 查看(head / less / file / python 都行)。
```
这样文件在界面的文件树里**看得见**(以前是直接跳过 → 树里根本不显示,用户以为不存在),
点开也能立刻知道「它在哪、多大、怎么看」。
> ⚙️ 后台补拉可以关掉或调参:插件配置 `mirrorDeepen = { enabled, maxFileBytes, maxTotalBytes, maxDurationMs }`。
**实测例子**(一个 8.2 GB / 44 662 个文件的工程):
```
远端:8.2 GB,44 662 个文件
本地锚点:0.37 MB,30 个文件 ← 0.005%,且 >256 KiB 的内容一个字节都没拉
```
> 上面量的是**首轮格局层**(①)的边界效果。之后的**后台补拉**(②)会在此基础上再补进
> 「文本类 + ≤ 2 MiB」的文件,所以实际落到本地的会比这个数字多一些 —— 但仍然**有界**。
> ⚠️ **代价要如实说**(2026-10-09 按代码复核过):
>
> - 项目特别大时,镜像会**被边界截断** —— 被截掉的那部分子树**本地根本没有**,
> 界面的文件树里也不显示(首轮的深度 / 条目数 / 30 秒墙钟这三个边界)
> - **首轮超限(> 256 KiB)的文件,本地只有占位**:文件树里**看得见**、点开有一句说明,
> 但**内容没有拉下来** —— 所以界面里看不到内容、也不能编辑
> (2026-10-07 之前是直接跳过:那些文件在树里根本不显示,用户以为不存在)
> - **后台补拉会把占位换成真内容** —— 只要它是文本类且 ≤ 2 MiB;
> 非文本类(网格 / 数据库 / 压缩包 / 图片)或超过 2 MiB 的,**保持占位**
>
> 这几条都**不影响 agent 干活**(读写、命令、搜索全在远端,不受镜像边界约束),
> 只影响界面里能看到多少。想看得更全,就把工作区指向**更小的目录**(比如指向代码仓库本身,
> 而不是包含备份和数据集的上层目录)。
**远端不需要装任何东西**:
- **SSH**:只要有 `sshd` 和一个能登录的账号 —— 不需要联网、不需要 API key、不需要装 DSH
- **WSL**:只要有发行版本身(本机进程,连凭据都不需要)
- **Docker**:只要容器已在运行(连凭据都不需要)
## 支持的后端
| 后端 | 状态 | 说明 |
|---|---|---|
| **SSH** | ✅ 可用 | 用 `ssh2` 库;host key TOFU,变更一律拒绝 |
| **WSL** | ✅ 可用 | 驱动 `wsl.exe`;**不需要凭据** |
| **Docker** | ✅ 可用 | 直连 Engine API(不依赖 `docker` CLI);**只连已运行的容器**,**不需要凭据** |
三者共用同一套上层(镜像层 / 预设层 / 会话绑定 / 工具)——
加一个后端只需要写一个 transport 文件,上层零改动。
### 模型能用上的能力
| 工具 | 干什么 |
|---|---|
| `remote_connect` / `remote_profiles` / `remote_list` / `remote_disconnect` / `remote_refresh` | 连接管理 |
| 宿主自带的 `bash` / `read` / `write` / `edit` | **自动落到远程**(在隔离域内换掉了 `fs` 与 `shell`) |
| **`glob` / `grep`** | **也在远程执行**(我们自己实现,用远程的 `find` / `grep`;见下) |
| 人设 / `AGENTS.md` / 技能 / 目标 / 计划模式 / 后台任务 | 与本地会话同一套(预设里逐项对齐) |
| **`remote_terminal`** | **远程交互式终端(真 PTY)**:步进调试器、REPL、需要跨调用保留状态的探索 |
`remote_terminal` 的要点:
- **真 PTY**(Docker / WSL 实测拿到 `/dev/pts/N`),不是管道 —— `sudo`、密码输入、
全屏 TUI 都正常
- **状态跨调用保留**:`cd`、导出的变量、函数、后台任务都留着
- **有界滚动缓冲** + 分页读,不会因为一条 `yes` 把内存吃光
- **`SIGINT` 真的能打断前台命令**(走 PTY 控制字符,命中前台进程组)
- 返回的 `waitReason` 告诉你「跑完了」有多可靠:`session_exit` 最确定,
`inferred_idle` 是推断,`timeout` 表示可能还在跑
> 有界的一次性命令请用 `bash` —— 它更省事,而且有输出上限与超时保护。
> 终端是给「状态必须留在终端里」的场景用的。
### 搜索也是**在远程执行**的
`glob` / `grep` 由本项目**自己实现**(`src/world/search.js`),在隔离域内通过
`ctx.shell` 调用**远程的** `find` / `grep`。
之所以不用宿主自带的那两个:它们用的是**打包在宿主里的 ripgrep 可执行文件**,
通过 `subprocess` 接缝运行(宿主源码注释明确写着 *"never `ctx.shell`"*)——
也就是说**跑在你本机**、搜的是**本地镜像**:大于 256 KiB 的文件、
超过 12 层或 2 万条目之外的部分**根本搜不到,而且不会告诉你漏了**。
自己实现之后,搜索与读文件、跑命令一样是**原生远程**的 —— 实测在一个
**328 KB 的文件**里搜到了内容,而那个文件**本地镜像里压根没有**。
与宿主那份的差异(**已写进工具描述,不静默**):
| 项 | 说明 |
|---|---|
| 正则方言 | 用远程的 `grep -E`(POSIX 扩展正则),不是 ripgrep 语法;rg 特有的写法(如 lookaround)不支持 |
| 忽略规则 | 用内置剪枝名单(`.git`、`node_modules`、`__pycache__`…),**不读远程的 `.gitignore`** |
| 结果上限 | 超过上限**截断并如实标注**(宿主那份会写 spill 文件) |
| `include` | 支持简单 glob 与大括号(`*.{js,jsx}` 会展开成多个 `-name`) |
> 需要的话仍可用 `bash` 跑 `rg`(若远程装了)—— 那是另一条路,不受上面的方言限制。
## 连接远程工作区
有**两个入口**,它们最终走**同一套内部实现**,所以行为不会漂移。
### 入口一:侧边栏的「远程开发」弹窗(推荐)
侧边栏里(和官方的 **插件** 并列)有一行 **远程开发** —— 点开是一个**固定大小**的弹窗:
左边选后端,右边是**识别出来的目标**(不用自己填名字):
```
┌────────────────────────────────────────┐
│ 远程开发 ✕ │
│ 连上 SSH / WSL / Docker 机器… │
├───────────┬────────────────────────────┤
│ WSL 2 个 │ 选择发行版 │
│ SSH 用过 │ ○ Ubuntu-24.04 │
│ Docker │ ○ docker-desktop │
├───────────┴────────────────────────────┤
│ [取消] [连接] │
└────────────────────────────────────────┘
```
**三个后端各自怎么选目标**:
| 后端 | 怎么选 |
|---|---|
| **WSL** | **自动识别**已安装的发行版(`wsl -l -q`,**不会启动**发行版)—— 一个就直接点,多个就在列表里挑。列表与 `wsl -l -q` **完全一致**;Docker Desktop 自己装的那个系统发行版(`docker-desktop`)只是**排在最后**。**busybox 发行版也支持**(没有 bash 就用 `sh`,`find`/`stat` 走 busybox 兼容写法) |
| **Docker** | **自动识别**容器(运行中的排在前面并标出状态);守护进程没跑时**如实说明原因** |
| **SSH** | 没有「自动识别」这回事(那需要凭据),所以列出**用过的连接** —— 点一条就把主机 / 端口 / 账号 / 私钥路径填好,**只需再给密码**;也可以「新建连接」 |
> **列表长了会自己出现筛选框**(容器可能几十个);标题栏的 **↻** 是「重新识别」——
> Docker Desktop 是按需启动的,启动完点一下就有容器了,不用关掉弹窗再开。
>
> **「探测失败」和「成功但没有」是两回事**:探测**失败**(守护进程没跑、API 不对…)
> 会显示原因并给一条**手填兜底**;而探测**成功、只是没有目标**(没有容器 / 没装发行版)时,
> 给的是**空状态 + 引导**(`docker run` / `wsl --install`,然后点 ↻)——
> 那种情况下给输入框只会让人以为「填对了就能连」。
**然后分两步**(和 VS Code / ZCode 一样):
1. **连机器** —— 只连机器,**不用填项目路径**
2. **选项目文件夹** —— 连上后显示**远程真实的目录树**:点进去、返回上一层,
找到项目后点「就用这个文件夹」。
选好之后插件自动建好工作区(镜像 + 预设 + 锚点),关掉弹窗并**切回会话** ——
你只要在那个工作区下面新建会话。
> **弹窗里没有「打开本机文件夹」**:那是 DSH 自己「添加工作区」的活。
> 本地目录不属于远程开发流程,混在一起只会让人以为它也是远程的一步。
> **为什么不用一次填完**:那要求你**盲填**远程项目根。填错了(比如漏写一层
> `桌面/`)连接照样「成功」,但之后每条命令都切不到目录 —— 非常难排查。
> 改成点选之后,路径不再靠猜。
> ⚠️ **为什么入口在侧边栏,而不是「工作区」旁的 `+` 按钮里**:
> 我们试过把 `+` 按钮的选目录流程换成自己的界面 —— 结果**官方的
> 选文件夹插件激活失败,整个 DSH 打不开**(那个流程已经有官方归属,
> 插件之间会冲突)。所以改成完全**加法式**的入口:多一行导航,谁也不影响谁。
>
> 实现上用的是宿主自己的三个槽位:`sidebar.panellist`(列表型 —— 多一行导航)、
> `main`(keyed —— 那一行必须对应一格,用一个新 key 就是加法)、
> `shell.overlay`(全窗口浮层 —— 弹窗挂在这里才盖得住整个框架)。
> **图标由宿主画**,我们只交一个 `currentColor` 线框 SVG,
> 所以选中态、配色、留白都和官方一致;弹窗配色走 `--dsw-alias-*` 主题变量。
### 入口二:让 agent 调用 `remote_connect`
```
连到 WSL 的 Ubuntu-24.04,远程目录 /home/user/我的项目
```
agent 会调用 `remote_connect`(kind 为 `ssh` / `wsl` / `docker`),
然后把返回的**锚点目录**告诉你。
### 连上之后(两个入口都一样)
1. 把返回的**锚点目录**作为工作区打开
2. 在那个工作区里**新建会话** —— 预设只能在会话第一轮之前选定
> **这一步不能省**:旧会话已经定型了。漏了它,你会发现命令还是跑在本机。
### 关于「模式」:列表里为什么会多一个「远程模式」
新建会话时可以选模式(标准 / PTC / 极简 / 创造)。连上远程之后,
**列表里会多出一个「远程模式」** —— 那不是另一种功能,而是**远程会话的载体**:
- DSH 要求一个会话的插件组合来自一个**已注册的预设**;
插件要把会话切到「文件与命令都在远程执行」,就必须先注册这么一个预设
- 它的**功能与标准模式逐项对齐**(读/写/编辑、命令、搜索、终端、计划、目标、技能、
后台任务、AGENTS.md 全都有)—— 差别只在「底下的文件系统与命令执行换成了远程版」
- 它排在最后,是因为**连接时才注册**(前四个是 DSH 开机就有的)
- **DSH 没有「注册但不显示」的开关**,所以它一定会出现在列表里
名字固定叫「远程模式」,**「是哪台机器、项目在哪」写在它下面那行说明里**:
```
远程模式
WSL docker-desktop · /root/.docker/desktop ← 发行版/容器/主机 · 项目根
```
> 为什么名字不拼目标:拼出来是 `远程:<派生 id>(WSL <发行版>)`,
> 而派生 id 又长又没信息量,会把真正有用的部分**挤出显示宽度**(实测被截断成
> `远程:wsl-root-docker-desktop(WSL docker-de`)。现在把目标放进说明里,整行都是它的。
**你不用管它。** 在那个远程工作区里新建会话时,插件会**自动**把会话切到它:
```
会话创建 → 工作目录在某个远程工作区里吗?
├─ 不在 → 什么都不做(本地会话完全不受影响)
└─ 在 → 切到该工作区的远程预设 → **回读确认**真的生效
→ 失败就拒绝这一步并报错,**绝不让你在本机跑**
```
> ⚠️ 所以:**在远程工作区里选别的模式会被切回来** —— 这是故意的。
> 不切的话,那个会话的文件读写会落在**本地镜像**上:
> 你以为在改远程的代码,其实改的是本地那份副本(改了、以为生效了、远程根本没变)。
**工作区的名字**形如 `ssh:my-proj`、`wsl:test-proj` —— 后端 + 最后一层目录名。
(完整路径不挤进名字里:选目录那一步的弹窗里已经显示过。)
## 安装
### 用自带安装器(推荐)
```bash
node scripts/install.mjs --profile <你的 profile>
```
安装器会按顺序做完三件事,并把每一步的结果打出来:
1. 往你的 profile 写入 **pnpm 构建脚本表态**(本插件零原生依赖,见下)
2. 调用 `dsh plugin --profile <p> add @jk-uzi/dsh-multi-remote`
3. **回读校验**插件真的被登记进了 `dsh.profile.bundles`,没登记就非零退出
> **为什么需要第 1 步和第 3 步**:pnpm 11+ 默认拒绝任何「没提前表态」的依赖构建脚本,
> 并且**以非零退出码失败**;而 `dsh plugin` 只在安装成功时才把插件登记进 bundle 栈。
> 结果是**包装进了 `node_modules`、却永远不会被加载,而且没有任何提示**。
> 安装器就是用来堵这个洞的。
### 手动安装
```bash
dsh plugin --profile <profile> add @jk-uzi/dsh-multi-remote
```
手动装完之后**请确认** `dsh.profile.bundles` 里有 `@jk-uzi/dsh-multi-remote`。
没有的话插件不会生效。
### 从源码(开发用)
```bash
git clone https://github.com/jk-uzi/dsh-multi-remote.git
dsh plugin --profile <profile> add "link:$PWD/dsh-multi-remote"
```
### 卸载
```bash
dsh plugin --profile <profile> remove @jk-uzi/dsh-multi-remote
```
## 使用
连上一台机器,然后用 agent 的常规工具干活。
### SSH
```
> 连接 user@example.com:22,项目根 /home/user/proj,用 ~/.ssh/id_ed25519
(调用 remote_connect 工具,kind='ssh')
已连接 …(user@example.com:22)
远程项目根:/home/user/proj
本地镜像锚点:<本地锚点目录>/mr-…
预设:multi-remote-…(可用)
下一步:把上面的「本地镜像锚点」作为工作区打开,然后新建会话。
```
> **SSH 掉线了怎么办?** 长连接会被网络抖动、服务端重启 sshd、NAT 表项过期打断。
> 断掉之后插件会**自动重连一次**,通常你不需要做任何事。
> 重连时 host key 校验**照旧生效**(不会因为重连就放行陌生主机)。
### WSL
```
> 连上本机的 Ubuntu-24.04,项目根 /home/user/proj
(调用 remote_connect 工具,kind='wsl', distro='Ubuntu-24.04')
已连接 …(WSL Ubuntu-24.04)
远程项目根:/home/user/proj
本地镜像锚点:…
预设:multi-remote-…(可用)
```
> **WSL 不需要任何凭据** —— 它是本机进程。用 `wsl -l -q` 可以列出可用发行版。
### Docker
```
> 连上容器 my-app,项目根 /app
(调用 remote_connect 工具,kind='docker', container='my-app')
已连接 …(容器 my-app)
远程项目根:/app
本地镜像锚点:…
预设:multi-remote-…(可用)
```
> **Docker 不需要任何凭据** —— 它连的是本机守护进程。用 `docker ps` 看有哪些容器在跑。
> 只连**已运行**的容器,不会替你创建或启动。
> **Docker Desktop 重启过怎么办?** 引擎重启会让旧连接失效,插件会**自动重连一次**,
> 通常你不需要做任何事。但注意引擎重启会把**容器也停掉** —— 此时报错会说清
> 「容器没有在运行」并提示 `docker start`,而不是丢一个看不懂的错误给你。
> ⚠️ **用户流程有硬约束**:必须**先选远程工作区(= 锚点目录)、再新建会话**。
> 远程预设只能在会话第一轮开始之前选定,反过来换不了。
> 走反了的话,插件会**拒绝这一步并明确报错**,而不是让命令静默落在本机。
### 工具
| 工具 | 作用 |
|---|---|
| `remote_connect` | 连接一台机器,建立本地镜像 + 注册远程预设 |
| `remote_profiles` | 列出 / 删除已保存的连接档案(`list` / `delete`) |
| `remote_list` | 列出当前已连接的远程工作区 |
| `remote_disconnect` | 断开(本地镜像保留,重连后界面立刻有内容) |
| `remote_refresh` | 重新拉取远程目录结构到本地镜像 |
### 凭据
- 连接档案只保存**非敏感字段**(主机、端口、账号、路径、私钥**路径**)
- 口令与私钥内容**永不落盘**,每次连接现给
- 档案文件权限 `0600`
## 设置
**设置 → 远程开发** 里可以直接改这几项(不用编辑配置文件):
| 项 | 说明 | 默认 |
|---|---|---|
| 连上之后在后台补拉镜像 | 把「文本类 + 上限内」的文件补到本地镜像,界面里点开就有内容 | 开 |
| 后台补拉的单个文件上限 | 只管**后台补拉**那一趟(MiB)。首轮遍历另有一套更小的常量,不受这项影响 | 2 |
| 后台补拉的总量上限 | 同上(MiB) | 64 |
| 审计日志条数上限 | `GET /multi-remote/audit` 的环形缓冲容量 | 500 |
> **想彻底关掉插件?** 用 **DSH 自己的插件开关**(插件管理器里关掉它,
> 或往 profile 的 `cordis.patch.yml` 写 `disabled: true`)—— 宿主会整行不加载。
> 我们**不自造**第二个「显示 / 隐藏入口」的开关:既和宿主重复,又半残
> (客户端半边跑在浏览器里,改了配置要刷新页面才生效)。
> ⚠️ **保存后会重载插件**:已连接的远程工作区会断开,重新连一次即可。
> 这是宿主 `configEditor` 的正常行为 —— 配置变更走 Loader 重新应用。
改动**不自己写文件**:走宿主的 `configEditor` 服务(校验 + 持久化 + 重新应用),
落点是 profile 的 `cordis.patch.yml` 里一条 `id: multi-remote` 的**定向覆盖**。
> 如果那个 profile 没有 `configEditor` 服务(精简 profile),这一页会**如实说明改不了**,
> 并给出手工改 `cordis.patch.yml` 的办法 —— 不会假装有个能用的开关。
## 本地开发
**不要动你日常在用的 profile。** 用独立的 `DSH_HOME` 建一个隔离 profile:
```bash
# 1) 找一个仓库外的目录当隔离 DSH_HOME
export DSH_HOME="$HOME/.dsh-multi-remote" # Windows PowerShell: $env:DSH_HOME = "$HOME\.dsh-multi-remote"
# 2) 从内置模板建一个隔离 profile
dsh --profile dev --from-default-profile web --dump-config
# 3) 挂载本仓库
dsh plugin --profile dev add "link:$PWD"
# 4) 启动
dsh --profile dev --no-open --port 19388
```
> 本仓库**无构建步骤**——宿主直接加载 `src/*.js`,改完代码重启 profile 即可生效。
### 自检
| 检查 | 期望 |
|---|---|
| `GET http://127.0.0.1:19388/multi-remote/ping` | `{"ok":true,...}`,含已连接工作区 |
| `GET http://127.0.0.1:19388/multi-remote/selfcheck` | 5 个工具 `registered: true`、`visibleToModel: true`,并报告**三种后端各自的可用性** |
| `GET http://127.0.0.1:19388/multi-remote/workspaces` | 已连接工作区的安全描述(不含凭据) |
| `GET http://127.0.0.1:19388/multi-remote/audit` | 审计日志(`?limit=` / `?event=` 过滤,`?clear=1` 清空) |
#### 审计日志
记**面向操作**的轨迹:连过哪些机器、成没成、失败原因、**栅栏拒绝**(安全事件)。
三条约束:**有界**(环形缓冲,默认 500 条,超界会报 `dropped`)、
**绝不落凭据**(统一脱敏 —— 字段名、错误信息、连 URL 里的 `?token=` 都会抹掉)、
**绝不抛错**(审计坏了不能让连接失败)。
```bash
curl 'http://127.0.0.1:19388/multi-remote/audit?limit=20'
curl 'http://127.0.0.1:19388/multi-remote/audit?event=connect-failed'
curl 'http://127.0.0.1:19388/multi-remote/audit?clear=1'
```
自检最有用的部分是 `backends` 那一段 —— 它**先回答「这台机器上哪种后端能用」**:
```json
{
"ssh": { "status": "client-ready", "detail": "客户端可用;能不能连上取决于目标 sshd 与账号" },
"wsl": { "status": "available", "detail": "找到 2 个发行版", "distros": ["Ubuntu-24.04"] },
"docker": { "status": "available", "detail": "守护进程 29.8.1,API 1.56",
"endpoint": "\\\\.\\pipe\\dockerDesktopLinuxEngine" }
}
```
`status` 的取值:`available` / `unavailable` / `unsupported` / `client-ready` / `unknown`。
探不到时会**带上原始错误**,不会只说一句「失败」。
> **SSH 那条永远只说「客户端可用」**,不会说「你的服务器连得上」——
> 自检不该要凭据,也不该在你没让它连的时候发连接。
>
> 探测默认开启(三个并行,实测约 50 ms)。脚本里只想要工具状态可以加 `?backends=0` 跳过。
### 跑测试
```bash
node --test # 静态闸门 + 镜像层 + 预设 + transport + 安装器
```
其中 SSH / WSL / Docker 的端到端测试需要真机,默认跳过:
```bash
# SSH(需要一台能登录的机器)
DSH_MR_SSH_TEST=1 \
DSH_MR_SSH_HOST=<host> DSH_MR_SSH_PORT=<port> DSH_MR_SSH_USER=<user> \
DSH_MR_SSH_KEY=<私钥路径> DSH_MR_SSH_SANDBOX=/tmp/dsh-mr-e2e \
node --test
# WSL(需要 Windows + WSL2)
DSH_MR_WSL_TEST=1 DSH_MR_WSL_DISTRO=Ubuntu-24.04 \
DSH_MR_WSL_SANDBOX=/tmp/dsh-mr-wsl-e2e \
node --test
# Docker(需要一个已在运行的容器)
DSH_MR_DOCKER_TEST=1 DSH_MR_DOCKER_CONTAINER=<容器名> \
DSH_MR_DOCKER_SANDBOX=/tmp/dsh-mr-docker-e2e \
node --test
```
> 端到端测试只会在 `*_SANDBOX` 指定的远程目录里读写,不会碰其他文件。
## 结构
| 路径 | 作用 |
|---|---|
| `src/index.js` | 宿主半边入口:组装各层、注册 `remote_*` 工具与自检路由 |
| `src/diagnostics.js` | 后端可用性探测(自检里的 `backends` 那一段) |
| `src/audit.js` | 审计日志:有界、脱敏、绝不抛错 |
| `src/transport/` | `Transport` 契约 + 三个后端:SSH(`ssh.js`)/ WSL(`wsl.js`)/ Docker(`docker.js`)+ 最小 tar(`tar.js`) |
| `src/mirror/` | 锚点目录的只读镜像(格局层 / 照片层 / 路径围栏 / git 基线) |
| `src/session/` | 连接档案、连接管理、预设注册(`preset.js`:**单一「远程模式」预设**)、会话绑定、**工作区路由(`workspace-routes.js`:路径→连接)** |
| `src/session/plan-mode-section.js` | 计划模式的提示词段(由 `scripts/sync-plan-mode-section.mjs` 生成,别手改) |
| `src/world/` | 隔离域内的 provider:`fs` / `shell` / **`search`(远程 `glob`/`grep`)**、终端后端与工具的挂载点(`terminal.js`);**按路径动态路由**(见 `session/workspace-routes.js`) |
| `src/terminal/` | 交互式终端:与后端无关的会话机械(`session.js`)、三个后端的 PTY(`backends.js`)、宿主 `TerminalBackend` 适配(`backend.js`)、模型工具(`tool.js`) |
| `src/http/fence.js` | HTTP 路由的 Host / Origin 栅栏(fail-closed) |
| `src/client.js` | 客户端半边:设置页小节(文案走宿主 i18n,中英双语) |
| `scripts/install.mjs` | 安装器(构建脚本表态 + 安装 + 回读校验) |
| `scripts/sync-plan-mode-section.mjs` | 从宿主的默认预设同步计划模式提示词段 |
## 依赖
**只有一个运行时依赖:`ssh2`**(纯 JavaScript)。
它的可选原生加速 `cpu-features` 被**显式拒绝构建**(见 `pnpm-workspace.yaml`):
允许它会让没有 C++ 工具链的用户直接装不上,换来的只是一点特性探测。
所以本插件**零原生依赖**,任何平台都能直接装上。
## 兼容性与测试矩阵
### 宿主与运行时
| 项 | 要求 | 实测于 |
|---|---|---|
| DSH | 见 `peerDependencies`(`@deepseek-ai/dsh-tools >=0.2.0-rc.2 <0.3.0`) | 0.2.0-rc.2 |
| Node | `>=20`(`engines`) | v24.14.0 |
| 插件主机 | Windows / macOS / Linux | Windows 11 25H2 |
| 目标环境 | **POSIX shell**(原生 Windows 远端不在范围) | Ubuntu / Debian / Alpine |
### 三个后端各自的前置条件
| 后端 | 需要什么 | 不需要什么 | 可用平台 |
|---|---|---|---|
| **SSH** | 可达的 `sshd` + 一个能登录的账号 | 远端不装任何东西(无 DSH、无 API key、不联网) | 全部 |
| **WSL** | Windows + WSL2 + 一个发行版 | 凭据、网络、远端安装 | **仅 Windows** |
| **Docker** | 可达的 Docker 守护进程 + **已在运行**的容器 | `docker` CLI(我们直连 Engine API)、凭据 | 全部(按平台自动选端点) |
Docker 的端点按平台解析:Windows 用命名管道(`dockerDesktopLinuxEngine` → `docker_engine`),
类 Unix 用 `/var/run/docker.sock` → `/run/docker.sock`;`DOCKER_HOST` 显式给出时优先。
**`tcp://` 与 `ssh://` 不在支持范围**(需要 TLS 与额外协议),会明确报错而不是假装能连。
> WSL 的**交互式终端**额外需要发行版里有 util-linux 的 `script`
> (用来分配 PTY)。没有的话终端会明确报错,其他功能不受影响。
### 目标环境的实测覆盖
| 目标 | 文件读写 | 命令执行 | 交互式终端 |
|---|---|---|---|
| **真实 OpenSSH 服务器**(Linux,有登录 banner) | ✅ | ✅ | ✅ PTY `/dev/pts/N`、resize 生效 |
| OpenSSH 服务器(容器夹具,CI 可复现) | ✅ | ✅ | ✅ PTY `/dev/pts/0`、resize 生效 |
| Ubuntu-24.04(WSL) | ✅ | ✅ | ✅ PTY `/dev/pts/N` |
| `node:22-bookworm-slim`(Docker) | ✅ | ✅ | ✅ PTY `/dev/pts/N` |
| **`alpine:3.20`**(Docker) | ✅ | ✅ | 未测 |
| **`scratch`**(无 shell、已停止) | ✅ 内容读写仍可用 | ⛔ 明确报错 | ⛔ 明确报错 |
> **真机比夹具多测出三件事**(夹具环境太干净,这些都不出现):
> 登录 banner、中文 motd 造成的终端**折行**、以及 shell 启动期间的就绪竞态。
> 其中就绪竞态是个真 bug,已修(见 [CHANGELOG](CHANGELOG.md))。
> `alpine` 上实测发现 busybox 的 `find` **没有 `-printf`**、`stat` 没有 `--printf`,
> 所以内部脚本用的是两者都支持的写法(见 [CONTRIBUTING](CONTRIBUTING.md) §4.11)。
> 交互式终端的**窗口大小变更**:SSH / Docker 支持;WSL 不支持
> (util-linux 的 `script` 没有这个能力),`resize` 是空操作 —— 如实的能力差异。
> 交互式终端**首次发送**前会先等登录 banner 打完再写命令
> (否则命令输出会被 banner 的间隙吞掉)。已经安静的终端几乎不增加延迟;
> 一直不安静也会照常发送,并在结果里如实标 `readyReason: 'not-quiet-yet'`。
### 实测过的宿主版本
| 组件 | 版本 |
|---|---|
| DSH | 0.2.0-rc.2 |
| Node | v24.14.0 |
| 宿主 OS | Windows 11 25H2(build 26200.9457) |
| WSL | 2.7.3.0 / kernel 6.6.114.1-1 / Ubuntu-24.04 |
| Docker | Desktop 4.93.0 / Engine 29.8.1 / API 1.56 |
> 上表是**实测过**的组合,不是支持范围的下界。`engines` 声明的是 `node >=20`;
> 其他平台(macOS / Linux)上 SSH 与 Docker 应当可用,但**没有实测过** ——
> 按本项目惯例,没测过的就标成没测过。
### 测试怎么跑
```bash
node --test # 全部单元与契约测试(不需要任何外部环境)
```
真机端到端测试**默认跳过**,按环境变量开关(见下一节)。当前规模
(2026-10-13 复核:`node --test` → **521 通过 / 14 跳过 / 0 失败**):
| 类别 | 数量 |
|---|---|
| 单元与契约测试 | 521 |
| 真机端到端(默认跳过) | 14 |
| **合计** | **535** |
> **SSH 的多数测试不需要靶机**:断线重连那组用 `ssh2` 自带的 `Server` 在**进程内**
> 起一个真 SSH 服务端(真握手、真认证、真断线),所以**不需要网络也不需要 Docker**,
> 已经算在上面「单元与契约测试」里。只有「容器里跑 sshd」那组真机端到端默认跳过。
> ⚠️ **同时打开多个真机端到端时请加 `--test-concurrency=1`**:
> Node 的测试运行器默认并行跑不同测试文件,而 `wsl.exe` 在并发下会明显退化
> (见 [CONTRIBUTING](CONTRIBUTING.md) §4.6),会把 Docker 的用例拖到超时。
> 实测:两个都开 + 默认并发 → Docker 用例 32 秒超时;`--test-concurrency=1` 则通过。
> 这不是代码问题,是**测试环境互相干扰**。
## 贡献
欢迎参与。**动手前请先读 [CONTRIBUTING.md](CONTRIBUTING.md)**——里面写了开发环境、
插件 API 契约,以及几个很容易踩的坑。
## 许可证
[MIT](LICENSE)
Install
dsh plugin --profile web add github:jk-uzi/dsh-multi-remote
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install jk-uzi-dsh-multi-remote from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.