dsh-remote 0.8.44 → 0.8.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +33 -3
- package/README.md +58 -2
- package/lib/client.js +208 -2
- package/lib/forwards.js +23 -6
- package/lib/index.js +19 -1
- package/lib/pool.js +38 -3
- package/lib/web-attach.js +384 -17
- package/lib/web-deploy.js +43 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -133,7 +133,17 @@ A new tab opens showing that remote machine's full DSH interface (chat, tool tre
|
|
|
133
133
|
no second instance will be started.
|
|
134
134
|
- When the remote `dsh` is not on the SSH login PATH, point `webAttachCommand` at its absolute path; to
|
|
135
135
|
keep an attached instance out of the remote user's own sessions and settings, point `webAttachDshHome`
|
|
136
|
-
at a scratch directory.
|
|
136
|
+
at a scratch directory. This is **per machine**: Settings → Remote workspace → the machine card →
|
|
137
|
+
expand "Advanced" → "Remote dsh command" (e.g.
|
|
138
|
+
`/home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js`).
|
|
139
|
+
Empty falls back to the global `webAttachCommand`. A successful "Deploy & verify" writes this field for you.
|
|
140
|
+
- The tunnel follows the SSH connection: when it drops, the UI shows "Disconnected" and the local DSH
|
|
141
|
+
process is **not** affected. After the pool reconnects the plugin re-resolves the remote port (restarting
|
|
142
|
+
the remote process it started, if that one died), so hitting "Reconnect" is enough to carry on.
|
|
143
|
+
- The remote node must be **≥ 20.12** (booting dsh needs `parseEnv` from `node:util`, which only exists
|
|
144
|
+
from 20.12). An older node is reported as a blocker by the check: npm only warns (EBADENGINE) and
|
|
145
|
+
installs anyway, and the crash arrives later as
|
|
146
|
+
`does not provide an export named 'parseEnv'` — so it has to be caught before the install.
|
|
137
147
|
|
|
138
148
|
### Cannot connect? Check the environment, then deploy in one click
|
|
139
149
|
|
|
@@ -245,7 +255,7 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
245
255
|
| `keyboardInteractive` | bool | `false` | allow keyboard-interactive auth (OTP/MFA) with the configured password |
|
|
246
256
|
| `backend` | string | `native` | SSH transport backend (issue #51). `native` = the built-in ssh2 client (password / private key / agent / OTP); `openssh` = **drive the local `ssh` command**, which is the only way to reach hosts whose sole authentication method is GSSAPI/Kerberos (log in once with `kinit`), and which also honours `ProxyCommand`, `PKCS11Provider`, `Match` blocks and the rest of `~/.ssh/config`. **Settable per machine.** The cost is a fresh connection per command (slower than native); SFTP still reuses one session. The host name is handed to ssh **verbatim** (so `~/.ssh/config` stays the single source of truth) and host keys are checked by ssh against your own `known_hosts` |
|
|
247
257
|
| `sshBinary` | string | `''` | the `ssh` executable the `openssh` backend drives (empty = the `ssh` on PATH) |
|
|
248
|
-
| `proxy` | object | — | jump host: `{ host, port?, username?, password?, privateKeyPath? }
|
|
258
|
+
| `proxy` | object | — | jump host: `{ host, port?, username?, password?, privateKeyPath? }`; it means "reach the target host:port from the bastion", which is also what reaches a docker container's sshd on the bastion (see the issue #56 entry in the FAQ) |
|
|
249
259
|
| `autoPush` | bool | `false` | auto-push edited mirror files back to the remote (watcher, debounced) |
|
|
250
260
|
| `auditLog` | bool | `true` | append executed commands to `$DSH_HOME/remote-workspaces/audit.log` |
|
|
251
261
|
| `encoding` | string | `utf-8` | text encoding for remote file reads/writes (e.g. `gbk`) |
|
|
@@ -260,7 +270,7 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
260
270
|
| `updateCheckIntervalMs` | int | 21600000 (6h) | how often `auto` mode checks npm (floor 60000) |
|
|
261
271
|
| `updateAutoReload` | bool | `true` | hot-swap the host half after an update lands; `false` defers it to the next process start and the panel reports `pendingReload` |
|
|
262
272
|
| `webAttachPortStart` | int | `3088` | first local port for the "Remote DSH Web UI" tunnel (later ports are tried when taken; loopback only) |
|
|
263
|
-
| `webAttachCommand` | string | `dsh` | command used to start `dsh web` on the remote; change it when the remote `dsh` is not on the SSH login PATH |
|
|
273
|
+
| `webAttachCommand` | string | `dsh` | command used to start `dsh web` on the remote; change it when the remote `dsh` is not on the SSH login PATH (also settable per machine under "Advanced" in the settings page, which wins over this default) |
|
|
264
274
|
| `webAttachDshHome` | string | `''` | `DSH_HOME` exported on the remote when attaching; empty reuses the remote user's own harness home |
|
|
265
275
|
| `webAttachWaitSeconds` | int | `45` | how long to wait for a freshly started remote `dsh web` to print its launch token |
|
|
266
276
|
| `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
|
|
@@ -273,6 +283,10 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
273
283
|
|
|
274
284
|
**`@` lists remote files but the built-in read tool cannot open them** — the harness's own file tools see the **local mirror**, which stays empty until `rw_sync` downloads it. Read remote files with `rw_read_file` or the sidebar remote tab.
|
|
275
285
|
|
|
286
|
+
**A file link in a remote Session does not open** (issue #55) — fixed. When a link is clicked the harness turns it into `dsh-resource://file/session/<session>/<path>` and hands it to the built-in text preview, which reads the **local** filesystem; a remote Session's cwd is a **local mirror directory** (empty until `rw_sync`), so the open simply failed. The plugin now registers a higher-priority handler for that address and opens it over SFTP through the same editor as the sidebar "Remote Files" tab. The safety rule is deliberately conservative: it takes over only when the Session's cwd is **provably inside a mirror** and the mirror registry has loaded, so local Sessions — and the instant right after a page load — keep the built-in preview and no local file is ever mistaken for a remote one. The two cases that still cannot be mapped say so instead of showing a blank page: an address with no mirror mapping, and a Session whose workspace is not a remote mirror directory.
|
|
287
|
+
|
|
288
|
+
**The terminal in a remote Session is still the local one** (issue #55) — that is **by design, and a plugin cannot change it**, but there is an equivalent: DSH's sidebar terminal is core, its PTY comes from the **context-global** `subprocess` service, and its cwd is the Session's own workspace directory (the local mirror for a remote Session). A plugin can neither replace that service nor retarget the core terminal onto SSH. For a **genuine remote terminal** use the "Remote DSH Web UI" (above): it opens the **remote machine's own** DSH in this browser, and that interface's terminal runs on the remote host, with no extra configuration. To run a single remote command instead, use `rw_exec` (add `pty: true` for an interactive PTY).
|
|
289
|
+
|
|
276
290
|
**Host key changed** — `/remote forget-key` (or Settings → machine → trust again).
|
|
277
291
|
|
|
278
292
|
**"Authentication failed"** — check the username/password/key path; fill in the passphrase for an encrypted key; enable keyboard-interactive when the host requires OTP.
|
|
@@ -281,6 +295,22 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
281
295
|
|
|
282
296
|
**The host only allows GSSAPI/Kerberos (`ssh` works after `kinit`, the plugin does not)** — in Settings → 远程工作区 → Advanced, switch **Backend** to `openssh` (drive the local `ssh`) and tick "use a ~/.ssh/config alias", with Host set to the **alias** in `~/.ssh/config` (not the IP). The login is then performed by your local ssh with your Kerberos ticket, and `ProxyCommand`, `GSSAPIAuthentication` etc. all apply per `~/.ssh/config` — **one source of truth for the configuration** (issues #50/#51). Command-line equivalent: `rw_connect(host="<alias>", useSshConfig=true, backend="openssh")`.
|
|
283
297
|
|
|
298
|
+
**Can a docker container on the remote host be the workspace?** (issue #56) — Yes, and **no plugin change is needed**. There is no container-specific code anywhere in the plugin, yet the two configurations below already cover the request. Pick by whether the container **publishes a port**:
|
|
299
|
+
|
|
300
|
+
- **A: the container maps its sshd** (e.g. `docker run -p 127.0.0.1:2222:22 …`) — a plain "jump host" configuration: Host `127.0.0.1`, Port `2222`, the container's user/key, and the **remote host** as the jump host. Why the files really land in the container: `proxy` means "connect to `<host>:<port>` **as resolved by the bastion**" (`lib/pool.js`: `pclient.forwardOut(...this.config.host, this.config.port...)`), and the resulting channel becomes the main connection's own transport (`opts.sock`), so SFTP rides the **container's** sshd — `rw_list_dir`/`rw_read_file`/`rw_write_file`/`rw_sync` all read and write container files. Command-line equivalent: `rw_connect(host="127.0.0.1", port=2222, username="<container user>", privateKeyPath="<key>")`. Requires `AllowTcpForwarding` on the remote sshd (the default).
|
|
301
|
+
- **B: the container publishes nothing** — pure `~/.ssh/config` plus the system-OpenSSH backend, on **your** machine:
|
|
302
|
+
|
|
303
|
+
```sshconfig
|
|
304
|
+
Host devbox
|
|
305
|
+
HostName devbox.invalid
|
|
306
|
+
User dev
|
|
307
|
+
IdentityFile ~/.ssh/id_ed25519
|
|
308
|
+
ProxyCommand ssh <remote-host> docker exec -i <container> sh -c 'exec socat - TCP:127.0.0.1:22'
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
then `rw_connect(host="devbox", useSshConfig=true, backend="openssh")`. Why **both** commands and file operations reach the container: the `openssh` backend hands the alias to `ssh` verbatim and deliberately never emits its own `ProxyCommand` (`lib/openssh.js` omits it so that `~/.ssh/config` decides), while `ssh -s <dest> sftp` and `ssh -- <dest> <cmd>` travel the same stream. Requires an sshd inside the container plus `socat` (or an `nc` that speaks stdio).
|
|
312
|
+
⚠️ **A Windows caveat**: POSIX OpenSSH shell-expands `ProxyCommand`, but **Windows' own OpenSSH 9.5 does not** (measured: it passes the whole string through as literal argv). So on a Windows host either keep the `ProxyCommand` a single command with no shell metacharacters, or point "ssh path" at Git Bash's `ssh.exe`. (Also: whether `docker exec -i` carries binary data byte-for-byte is unverified here — no docker on this machine — so try a small file with B first.)
|
|
313
|
+
|
|
284
314
|
**`rw_sync`/`rw_push` reports conflicts** — files changed on both sides are skipped and listed (never silently overwritten); merge manually and retry, or pass `force=true`.
|
|
285
315
|
|
|
286
316
|
**Windows remotes** — everything goes over SFTP, no POSIX shell needed; read Chinese files with `encoding=gbk`.
|
package/README.md
CHANGED
|
@@ -120,6 +120,15 @@ dsh plugin add dsh-remote
|
|
|
120
120
|
`http://127.0.0.1:<端口>/?token=…` 粘进输入框,点「连接已有实例」,插件不会再去启第二个。
|
|
121
121
|
- 远端 `dsh` 不在 SSH 登录 PATH 时,用 `webAttachCommand` 指向它的绝对路径;
|
|
122
122
|
想让它别污染远端用户自己的会话/设置,用 `webAttachDshHome` 指一个临时目录。
|
|
123
|
+
**按机器设**:设置 → 远程工作区 → 机器卡片 → 展开「高级」→「远端 dsh 命令」,
|
|
124
|
+
填绝对路径即可(例如
|
|
125
|
+
`/home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js`);
|
|
126
|
+
留空则用全局的 `webAttachCommand`。「部署并验证」成功后也会自动写进这个字段。
|
|
127
|
+
- 隧道跟着 SSH 连接走:连接断了界面会显示「连接已断开」,**不会**影响本机 DSH 进程;
|
|
128
|
+
池自动重连后插件会重新解析远端端口(必要时重启它自己启动的那个远端进程),界面点「重连」即可继续用。
|
|
129
|
+
- 远端 node 需要 **≥ 20.12**(dsh 启动要用 `node:util` 的 `parseEnv`,该导出从 20.12 起才有)。
|
|
130
|
+
node 太旧时体检会直接报 blocker:npm 只会给 EBADENGINE 警告、照样把包装完,
|
|
131
|
+
直到启动才崩在 `does not provide an export named 'parseEnv'`,所以必须在装之前拦住。
|
|
123
132
|
|
|
124
133
|
### 连不上?先体检,再一键部署
|
|
125
134
|
|
|
@@ -227,7 +236,7 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
227
236
|
| `keyboardInteractive` | bool | `false` | 允许 keyboard-interactive 认证(OTP/MFA)并复用配置的密码 |
|
|
228
237
|
| `backend` | string | `native` | SSH 传输后端(issue #51)。`native`=内置 ssh2(密码/私钥/agent/动态码);`openssh`=**调用本机 `ssh` 命令**,因此支持 GSSAPI/Kerberos(`kinit` 后免密)以及 `ProxyCommand`、`PKCS11Provider`、`Match` 等 OpenSSH 专有特性。**可按机器单独设置**。代价:每条命令新建一次连接(比 native 慢);SFTP 仍复用单条长连接。主机名会被**原样**交给 ssh(`~/.ssh/config` 是配置唯一来源),主机密钥也由 ssh 用你自己的 `known_hosts` 校验 |
|
|
229
238
|
| `sshBinary` | string | `''` | `openssh` 后端调用的 `ssh` 可执行文件(留空=用 PATH 里的 `ssh`) |
|
|
230
|
-
| `proxy` | object | — | 跳板机:`{ host, port?, username?, password?, privateKeyPath? }
|
|
239
|
+
| `proxy` | object | — | 跳板机:`{ host, port?, username?, password?, privateKeyPath? }`;语义是「在跳板机上连目标 host:port」,所以也能用来连跳板机上 docker 容器的 sshd(见「常见问题」里的 issue #56) |
|
|
231
240
|
| `autoPush` | bool | `false` | 镜像内文件被编辑后自动推回远端(watcher,带防抖) |
|
|
232
241
|
| `auditLog` | bool | `true` | 把执行的命令追加到 `$DSH_HOME/remote-workspaces/audit.log` |
|
|
233
242
|
| `encoding` | string | `utf-8` | 远程文件读写的文本编码(如 `gbk`) |
|
|
@@ -242,7 +251,7 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
242
251
|
| `updateCheckIntervalMs` | int | 21600000(6h) | `auto` 模式检查 npm 的间隔(下限 60000) |
|
|
243
252
|
| `updateAutoReload` | bool | `true` | 更新落地后自动热切换宿主半;`false` 则留到下次启动,设置页会显示 `pendingReload` |
|
|
244
253
|
| `webAttachPortStart` | int | `3088` | 「远程 DSH 界面」在本机监听的起始端口(占用则顺延,仅监听 127.0.0.1) |
|
|
245
|
-
| `webAttachCommand` | string | `dsh` | 在远端启动 `dsh web` 用的命令;远端 `dsh` 不在 SSH 登录 PATH
|
|
254
|
+
| `webAttachCommand` | string | `dsh` | 在远端启动 `dsh web` 用的命令;远端 `dsh` 不在 SSH 登录 PATH 时改这里(也可按机器在设置页「高级」里单独设,机器级优先) |
|
|
246
255
|
| `webAttachDshHome` | string | `''` | 远端启动时导出的 `DSH_HOME`;留空则复用远端用户自己的 harness home |
|
|
247
256
|
| `webAttachWaitSeconds` | int | `45` | 等待远端 `dsh web` 打印启动令牌的秒数 |
|
|
248
257
|
| `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
|
|
@@ -255,12 +264,59 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
255
264
|
|
|
256
265
|
**`@` 能列出远程文件,但内置读文件工具打不开** —— harness 自带工具看到的是**本地镜像**,要等 `rw_sync` 下载后才有内容。读远程文件请用 `rw_read_file` 或侧栏远程文件 tab。
|
|
257
266
|
|
|
267
|
+
**远程会话里的文件链接打不开**(issue #55)—— 已修。链接被点开时 harness 会把它变成
|
|
268
|
+
`dsh-resource://file/session/<会话>/<路径>`,然后交给内置的文本预览去读;而那个预览读的是**本机**
|
|
269
|
+
文件系统,远程会话的 cwd 是**本地镜像目录**(`rw_sync` 之前是空的),所以打开只会失败。
|
|
270
|
+
插件现在注册了一个更高优先级的地址处理器,把这个地址按远程主机解析后用 SFTP 打开(和侧栏「远程文件」同一个编辑器)。
|
|
271
|
+
安全边界是保守的:只有当该会话的 cwd **确实落在某个镜像目录里**、且镜像表已加载时才接管,
|
|
272
|
+
本地会话与页面刚加载的那一瞬间仍然走内置预览 —— 不会把本地文件误当成远程文件。
|
|
273
|
+
两个仍然开不了的情况会给出明确提示而不是空白页:链接指向的是**绝对**远端路径之外无法映射的地址,
|
|
274
|
+
或该会话的工作区不是一个远程镜像目录。
|
|
275
|
+
|
|
276
|
+
**远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
|
|
277
|
+
DSH 的侧栏终端是核心功能:它的 PTY 来自**全局唯一**的 `subprocess` 服务,cwd 取会话自己的
|
|
278
|
+
工作区目录(远程会话就是本地镜像),插件既不能替换那个服务、也不能把核心终端重定向到 SSH。
|
|
279
|
+
想要「真正的远程终端」,用**「远程 DSH 界面」**(见上文):它在本机浏览器里打开**远端机器自己**的
|
|
280
|
+
DSH,那个界面里的终端就跑在远端主机上,零额外配置。只想偶尔跑一条远程命令则用 `rw_exec`
|
|
281
|
+
(要交互式 PTY 就加 `pty: true`)。
|
|
282
|
+
|
|
258
283
|
**主机指纹变了** —— `/remote forget-key`(或设置页 → 机器 → 重新信任)。
|
|
259
284
|
|
|
260
285
|
**连接报「认证失败」** —— 检查用户名/密码/私钥路径;加密私钥要填 Passphrase;需要动态码时勾选 keyboard-interactive。
|
|
261
286
|
|
|
262
287
|
**连不上内网机器** —— 填「跳板机」主机(也可先把跳板机本身配成一台机器)。
|
|
263
288
|
|
|
289
|
+
**能不能拿远程主机上的 docker 容器当工作空间?**(issue #56)—— 能,**不需要改插件**;
|
|
290
|
+
插件对容器没有任何专门代码,但下面两条既有配置已经完整覆盖这个需求。请按容器**有没有对外暴露端口**二选一:
|
|
291
|
+
|
|
292
|
+
- **A:容器把 sshd 端口映射出来了**(例如 `docker run -p 127.0.0.1:2222:22 …`)—— 纯「跳板机」配置:
|
|
293
|
+
Host 填 `127.0.0.1`、Port 填 `2222`、用户/私钥填容器里的,再把「跳板机」主机填**那台远程主机**。
|
|
294
|
+
为什么这样文件就能落到容器里:`proxy` 的语义是「**在跳板机上**连 `<host>:<port>`」
|
|
295
|
+
(`lib/pool.js` 的 `pclient.forwardOut(...this.config.host, this.config.port...)`),
|
|
296
|
+
拿到的通道会被当成主连接自己的 transport(`opts.sock`),于是 SFTP 走的是**容器自己的 sshd** ⇒
|
|
297
|
+
`rw_list_dir`/`rw_read_file`/`rw_write_file`/`rw_sync` 全部读写的都是容器内的文件。
|
|
298
|
+
命令行等价写法:`rw_connect(host="127.0.0.1", port=2222, username="<容器用户>", privateKeyPath="<私钥>")`。
|
|
299
|
+
要求:远程主机的 sshd 允许转发(`AllowTcpForwarding yes`,默认就是)。
|
|
300
|
+
- **B:容器没有暴露端口** —— 纯 `~/.ssh/config` + 系统 OpenSSH 后端,在**本机** `~/.ssh/config` 写:
|
|
301
|
+
|
|
302
|
+
```sshconfig
|
|
303
|
+
Host devbox
|
|
304
|
+
HostName devbox.invalid
|
|
305
|
+
User dev
|
|
306
|
+
IdentityFile ~/.ssh/id_ed25519
|
|
307
|
+
ProxyCommand ssh <远程主机> docker exec -i <容器名> sh -c 'exec socat - TCP:127.0.0.1:22'
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
然后 `rw_connect(host="devbox", useSshConfig=true, backend="openssh")`。
|
|
311
|
+
为什么 `exec` 和文件操作**都会**进容器:`openssh` 后端把别名原样交给 `ssh`,
|
|
312
|
+
自己**从不**生成 `ProxyCommand`(`lib/openssh.js` 明确不传,就是为了让 `~/.ssh/config` 说了算),
|
|
313
|
+
而 `ssh -s <目标> sftp` 与 `ssh -- <目标> <命令>` 走的是同一条流 ⇒ 两者都穿过这个 ProxyCommand。
|
|
314
|
+
要求:容器里有 sshd,且有 `socat`(或用支持 stdio 的 `nc`)。
|
|
315
|
+
⚠️ **Windows 上有个坑**:POSIX OpenSSH 会把 `ProxyCommand` 交给 shell 展开,**Windows 自带的 OpenSSH 9.5 不会**
|
|
316
|
+
(实测它把整串按字面当成 argv)。所以在 Windows 跑本插件时,要么把 `ProxyCommand` 写成
|
|
317
|
+
不含 shell 元字符的单条命令,要么把「ssh 路径」指向 Git Bash 的 `ssh.exe`。
|
|
318
|
+
(另:`docker exec -i` 传二进制是否逐字节无损,我这边没有 docker 环境验证过,B 方案建议先用小文件试。)
|
|
319
|
+
|
|
264
320
|
**企业机器只允许 GSSAPI/Kerberos(`kinit` 后 `ssh` 能进,插件进不去)** —— 在设置 → 远程工作区 → 高级配置里把「连接后端」改成 **`openssh`**(调用本机 `ssh`),并勾选「使用 ssh_config 别名」,Host 填 `~/.ssh/config` 里那个**别名**(不是 IP)。这样登录由本机 ssh 用你的 Kerberos 票据完成,且 `ProxyCommand`、`GSSAPIAuthentication` 等全部照 `~/.ssh/config` 生效 —— **配置只有 `~/.ssh/config` 一个来源**(issue #50/#51)。命令行等价写法:`rw_connect(host="<别名>", useSshConfig=true, backend="openssh")`。
|
|
265
321
|
|
|
266
322
|
**`rw_sync`/`rw_push` 报冲突** —— 两边都改过的文件会被跳过并列出(绝不静默覆盖);手动合并后重试,或用 `force=true` 以一边为准。
|
package/lib/client.js
CHANGED
|
@@ -58,6 +58,13 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
'settings.backendOpensshAliasOk': '已勾选「使用 ssh_config 别名」:主机名原样交给 ssh,端口/用户/私钥/跳板机一律由 ~/.ssh/config 的 Host 块决定,对应的输入框在 openssh 后端下填了也不生效(这就是「配置唯一来源」,issue #50)。密码/Passphrase 仍会通过 askpass 交给 ssh,仅当 Host 块里没有可用认证时才用得上。',
|
|
59
59
|
'settings.sshBinary': 'ssh 路径',
|
|
60
60
|
'settings.sshBinaryPlaceholder': '留空 = 用 PATH 里的 ssh',
|
|
61
|
+
// Issue #54: the README told users to point `webAttachCommand` at a
|
|
62
|
+
// private-prefix dsh, but the settings UI offered nowhere to type it —
|
|
63
|
+
// it could only be written by the deploy flow or by hand-editing
|
|
64
|
+
// machines.json. It is an ordinary per-machine field now.
|
|
65
|
+
'settings.webAttachCmd': '远端 dsh 命令',
|
|
66
|
+
'settings.webAttachCmdPlaceholder': '留空 = 用全局 webAttachCommand(默认 dsh)',
|
|
67
|
+
'settings.webAttachCmdHint': '远端 dsh 不在 SSH 登录 PATH 时(私有前缀 / nvm 装的 node)填它的绝对路径,例如 /home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js。留空则用全局的 webAttachCommand。',
|
|
61
68
|
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
62
69
|
'settings.sshCollapse': '收起',
|
|
63
70
|
'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
|
|
@@ -308,6 +315,10 @@ window.__ModuleLoader__.load({
|
|
|
308
315
|
'file.downloadedAt': ': {path}',
|
|
309
316
|
'file.readFail': '远程读取失败: {msg}',
|
|
310
317
|
'file.listFail': '远程目录读取失败: {msg}',
|
|
318
|
+
// Issue #55: a file link in a remote Session cannot be mapped onto the
|
|
319
|
+
// remote host (no mirror for the Session's cwd), so say so instead of
|
|
320
|
+
// rendering an empty editor.
|
|
321
|
+
'file.linkUnresolved': '这个文件链接无法对应到远端路径(该会话的工作区不是一个远程镜像目录)。请用侧栏「远程文件」打开,或用 rw_read_file 读取。',
|
|
311
322
|
'common.refresh': '刷新',
|
|
312
323
|
'common.close': '关闭',
|
|
313
324
|
'common.confirm': '确认',
|
|
@@ -336,6 +347,11 @@ window.__ModuleLoader__.load({
|
|
|
336
347
|
'settings.backendOpensshAliasOk': '"Use a ~/.ssh/config alias" is on: the name is handed to ssh verbatim, and Port/User/IdentityFile/ProxyJump come from that Host block — the matching fields below have no effect (this is the "one source of truth" case, issue #50). A password/passphrase is still offered to ssh through askpass, and is only used if the Host block has no usable authentication.',
|
|
337
348
|
'settings.sshBinary': 'ssh path',
|
|
338
349
|
'settings.sshBinaryPlaceholder': 'Empty = the ssh on PATH',
|
|
350
|
+
// Issue #54: the README documented `webAttachCommand` but the settings
|
|
351
|
+
// UI had no field for it.
|
|
352
|
+
'settings.webAttachCmd': 'Remote dsh command',
|
|
353
|
+
'settings.webAttachCmdPlaceholder': 'Empty = the global webAttachCommand (default: dsh)',
|
|
354
|
+
'settings.webAttachCmdHint': 'Set the absolute path when the remote dsh is not on the SSH login PATH (a private prefix, or node installed with nvm), e.g. /home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js. Empty falls back to the global webAttachCommand.',
|
|
339
355
|
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
340
356
|
'settings.sshCollapse': 'Collapse',
|
|
341
357
|
'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
|
|
@@ -586,6 +602,7 @@ window.__ModuleLoader__.load({
|
|
|
586
602
|
'file.downloadedAt': ': {path}',
|
|
587
603
|
'file.readFail': 'Failed to read remote: {msg}',
|
|
588
604
|
'file.listFail': 'Failed to list remote directory: {msg}',
|
|
605
|
+
'file.linkUnresolved': 'This file link does not map onto a remote path (the Session workspace is not a remote mirror directory). Open it from the sidebar "Remote Files" tab, or read it with rw_read_file.',
|
|
589
606
|
'common.refresh': 'Refresh',
|
|
590
607
|
'common.close': 'Close',
|
|
591
608
|
'common.confirm': 'Confirm',
|
|
@@ -1139,15 +1156,17 @@ window.__ModuleLoader__.load({
|
|
|
1139
1156
|
proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
|
|
1140
1157
|
// Issue #51: the transport backend is per machine.
|
|
1141
1158
|
backend: m.backend === 'openssh' ? 'openssh' : 'native', sshBinary: m.sshBinary || '',
|
|
1159
|
+
// Issue #54: the per-machine remote-dsh command for Web UI attach.
|
|
1160
|
+
webAttachCommand: m.webAttachCommand || '',
|
|
1142
1161
|
id: m.id,
|
|
1143
1162
|
useSshConfig: !!m.useSshConfig,
|
|
1144
1163
|
})
|
|
1145
1164
|
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
|
|
1146
|
-
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1165
|
+
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || m.webAttachCommand || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1147
1166
|
setAdvOpen(true)
|
|
1148
1167
|
}
|
|
1149
1168
|
}
|
|
1150
|
-
const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', id: '', useSshConfig: false })
|
|
1169
|
+
const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', webAttachCommand: '', id: '', useSshConfig: false })
|
|
1151
1170
|
const save = (action) => {
|
|
1152
1171
|
setBusy(true); setErr(''); setMsg('')
|
|
1153
1172
|
if (action === 'delete') {
|
|
@@ -1161,6 +1180,8 @@ window.__ModuleLoader__.load({
|
|
|
1161
1180
|
encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
|
|
1162
1181
|
// Issue #51: the transport backend (and, for `openssh`, the executable).
|
|
1163
1182
|
backend: form.backend || 'native', sshBinary: form.sshBinary || '',
|
|
1183
|
+
// Issue #54: the per-machine remote-dsh command for Web UI attach.
|
|
1184
|
+
webAttachCommand: form.webAttachCommand || '',
|
|
1164
1185
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
1165
1186
|
}).then((r) => {
|
|
1166
1187
|
refresh(); clearForm()
|
|
@@ -1354,6 +1375,10 @@ window.__ModuleLoader__.load({
|
|
|
1354
1375
|
React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: 'Jump user', style: { ...inputS, minWidth: 140 } }),
|
|
1355
1376
|
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: 'Jump password', style: { ...inputS, minWidth: 140 } })), 'pu'),
|
|
1356
1377
|
row(tr('settings.proxyKey'), React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: 'Jump private key path (optional)', style: inputS }), 'pk'),
|
|
1378
|
+
// Issue #54: where "远程 DSH 界面" gets its remote dsh. The README
|
|
1379
|
+
// pointed at `webAttachCommand` and there was no field for it.
|
|
1380
|
+
row(tr('settings.webAttachCmd'), React.createElement('input', { value: form.webAttachCommand || '', onChange: setF('webAttachCommand'), placeholder: tr('settings.webAttachCmdPlaceholder'), style: inputS }), 'wac'),
|
|
1381
|
+
React.createElement('div', { style: { paddingLeft: LABEL_W + 8, marginBottom: 8, fontSize: 11, lineHeight: 1.5, opacity: 0.7 } }, tr('settings.webAttachCmdHint')),
|
|
1357
1382
|
) : null,
|
|
1358
1383
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 8 } },
|
|
1359
1384
|
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
@@ -3324,6 +3349,17 @@ window.__ModuleLoader__.load({
|
|
|
3324
3349
|
// paths are local filesystem resources. Each address is session-scoped.
|
|
3325
3350
|
const NATIVE_EXPLORER = 'dsh-remote/explorer'
|
|
3326
3351
|
const NATIVE_FILE = 'dsh-remote/file'
|
|
3352
|
+
// Issue #55: the address the CORE emits for a file link, which is NOT the
|
|
3353
|
+
// one this plugin mints for its own explorer. A model-emitted
|
|
3354
|
+
// `[x](src/main.c)` (or a `@src/main.c` chip) is turned into
|
|
3355
|
+
// `dsh-resource://file/session/<sessionId>/<path>` by the chat, and the only
|
|
3356
|
+
// shipped claimant is the built-in text preview, which reads it through the
|
|
3357
|
+
// HOST's ctx.fs. For a remote Session the header cwd is the LOCAL MIRROR, so
|
|
3358
|
+
// the link opens an empty (or stale) local directory and fails. A plugin
|
|
3359
|
+
// cannot re-register protocol `file` (one provider per protocol, and it is
|
|
3360
|
+
// taken), so the supported interception is a tab type that CLAIMS that
|
|
3361
|
+
// address and serves it over SFTP instead.
|
|
3362
|
+
const NATIVE_LINK = 'dsh-remote/remote-link'
|
|
3327
3363
|
function remoteFileAddress(sessionId, path) {
|
|
3328
3364
|
return 'dsh-resource://dsh-remote/' + encodeURIComponent(sessionId) + '/' + encodeURIComponent(path)
|
|
3329
3365
|
}
|
|
@@ -3332,6 +3368,74 @@ window.__ModuleLoader__.load({
|
|
|
3332
3368
|
if (url.protocol !== 'dsh-resource:' || url.hostname !== 'dsh-remote') throw new Error('Invalid remote resource')
|
|
3333
3369
|
return decodeURIComponent(url.pathname.split('/').slice(2).join('/'))
|
|
3334
3370
|
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Parse the core's `dsh-resource://file/session/<id>/<path>` address.
|
|
3373
|
+
*
|
|
3374
|
+
* Mirrors the grammar the core uses (`dsh-util-workspace-path`): the first
|
|
3375
|
+
* segment after the scheme is the scope (`session`), then the id, then the
|
|
3376
|
+
* path. Anything else — including the `absolute/` scope — is not ours: an
|
|
3377
|
+
* absolute remote path cannot be mapped back onto a mirror, so refusing is
|
|
3378
|
+
* correct rather than guessing.
|
|
3379
|
+
*
|
|
3380
|
+
* @param {string} address - the address a link click produced.
|
|
3381
|
+
* @returns {{sessionId: string, path: string}|null} the parts, or null when
|
|
3382
|
+
* this is not a session-scoped core file address.
|
|
3383
|
+
*/
|
|
3384
|
+
function parseCoreFileAddress(address) {
|
|
3385
|
+
let url
|
|
3386
|
+
try { url = new URL(String(address || '')) } catch { return null }
|
|
3387
|
+
if (url.protocol !== 'dsh-resource:' || url.hostname !== 'file') return null
|
|
3388
|
+
const rest = url.pathname.replace(/^\/+/, '')
|
|
3389
|
+
const [scope, ...tail] = rest.split('/')
|
|
3390
|
+
if (scope !== 'session' || tail.length < 2) return null
|
|
3391
|
+
const sessionId = decodeURIComponent(tail[0])
|
|
3392
|
+
if (!sessionId) return null
|
|
3393
|
+
// Dropping the leading empty segment keeps a session-root address ('') a
|
|
3394
|
+
// legitimate empty path instead of turning it into '/'.
|
|
3395
|
+
return { sessionId, path: decodeURIComponent(tail.slice(1).join('/')) }
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* The ABSOLUTE remote path a core file address refers to, or null.
|
|
3399
|
+
*
|
|
3400
|
+
* A relative link is mirror-rooted and an absolute one is already absolute;
|
|
3401
|
+
* either way what the reader must see is the path on the REMOTE host. The
|
|
3402
|
+
* session's own cwd (a mirror directory) identifies the mirror, whose record
|
|
3403
|
+
* carries the remote root the mirror was taken from — the same invariant
|
|
3404
|
+
* `lib/binding.js` uses host-side.
|
|
3405
|
+
*
|
|
3406
|
+
* Separator-aware on purpose (`mirrorForCwd` handles `/a/mirror` vs
|
|
3407
|
+
* `/a/mirror-2`): showing the WRONG remote file would be worse than the
|
|
3408
|
+
* current failure, so an ambiguous address returns null.
|
|
3409
|
+
*
|
|
3410
|
+
* @param {{sessionId: string, path: string}} parsed - a parsed core address.
|
|
3411
|
+
* @param {string} cwd - the session's local cwd (its mirror directory).
|
|
3412
|
+
* @returns {string} an absolute remote path, or null when it cannot be mapped.
|
|
3413
|
+
*/
|
|
3414
|
+
function remotePathForCoreAddress(parsed, cwd) {
|
|
3415
|
+
const mirror = mirrorForCwd(cwd, MIRRORS.list)
|
|
3416
|
+
if (!mirror) return null
|
|
3417
|
+
const remoteRoot = String(mirror.remotePath || '').replace(/[\\/]+$/, '')
|
|
3418
|
+
const raw = String(parsed.path || '')
|
|
3419
|
+
const isWin = raw.includes('\\') || remoteRoot.includes('\\')
|
|
3420
|
+
const sep = isWin ? '\\' : '/'
|
|
3421
|
+
const norm = (s) => String(s).replace(/[\\/]+/g, sep).replace(new RegExp('\\' + sep + '+$'), '')
|
|
3422
|
+
const rel = norm(raw)
|
|
3423
|
+
// The mirror's own directory prefix may be baked into the address when the
|
|
3424
|
+
// link was written against the local path; strip it so the result is
|
|
3425
|
+
// remote-relative rather than duplicated under the remote root.
|
|
3426
|
+
const mirrorDir = norm(mirror.dir || '')
|
|
3427
|
+
const relBody = rel && mirrorDir && (rel === mirrorDir || rel.startsWith(mirrorDir + sep))
|
|
3428
|
+
? rel.slice(mirrorDir.length).replace(new RegExp('^\\' + sep + '+'), '')
|
|
3429
|
+
: rel
|
|
3430
|
+
if (!remoteRoot) return relBody || sep
|
|
3431
|
+
if (!relBody) return remoteRoot
|
|
3432
|
+
// An absolute remote path that is already inside the remote root is used
|
|
3433
|
+
// as-is; anything else absolute is left alone (the reader meant it).
|
|
3434
|
+
if (/^[\\/]/.test(relBody) || /^[A-Za-z]:[\\/]/.test(relBody)) {
|
|
3435
|
+
return relBody
|
|
3436
|
+
}
|
|
3437
|
+
return remoteRoot + sep + relBody
|
|
3438
|
+
}
|
|
3335
3439
|
function NativeExplorer(props) {
|
|
3336
3440
|
const info = props.useTabInfo()
|
|
3337
3441
|
const [expanded, setExpanded] = React.useState([])
|
|
@@ -3347,9 +3451,63 @@ window.__ModuleLoader__.load({
|
|
|
3347
3451
|
const path = remoteFilePath(info.tab.navigation.address)
|
|
3348
3452
|
return React.createElement(RemoteFileTab, { key: path, tab: { path }, scope: { sessionId: props.sessionId } })
|
|
3349
3453
|
}
|
|
3454
|
+
/**
|
|
3455
|
+
* The body for a CORE file link in a remote Session (issue #55).
|
|
3456
|
+
*
|
|
3457
|
+
* It resolves the address to an absolute REMOTE path before rendering the
|
|
3458
|
+
* same editor the plugin's own explorer uses, so a link and the explorer
|
|
3459
|
+
* agree on what a path means. Resolution is attempted synchronously first
|
|
3460
|
+
* (the mirror registry is usually warm) and retried once the registry
|
|
3461
|
+
* arrives, so a click during page load still ends up in the right file.
|
|
3462
|
+
*/
|
|
3463
|
+
function NativeRemoteLink(props) {
|
|
3464
|
+
const info = props.useTabInfo()
|
|
3465
|
+
const address = info.tab.navigation.address
|
|
3466
|
+
const parsed = parseCoreFileAddress(address)
|
|
3467
|
+
const sessionId = (parsed && parsed.sessionId) || props.sessionId
|
|
3468
|
+
const [remotePath, setRemotePath] = React.useState(() => {
|
|
3469
|
+
const p = parsed || parseCoreFileAddress(address)
|
|
3470
|
+
return p ? remotePathForCoreAddress(p, sessionCwdFor(sessionId)) : null
|
|
3471
|
+
})
|
|
3472
|
+
React.useEffect(() => {
|
|
3473
|
+
if (remotePath || !parsed) return undefined
|
|
3474
|
+
let cancelled = false
|
|
3475
|
+
ensureMirrors().then(() => {
|
|
3476
|
+
if (cancelled) return
|
|
3477
|
+
const cwd = sessionCwdFor(sessionId)
|
|
3478
|
+
const resolved = remotePathForCoreAddress(parsed, cwd)
|
|
3479
|
+
if (resolved) setRemotePath(resolved)
|
|
3480
|
+
})
|
|
3481
|
+
return () => { cancelled = true }
|
|
3482
|
+
}, [address, remotePath])
|
|
3483
|
+
if (!remotePath) {
|
|
3484
|
+
return React.createElement('div', { style: { padding: 16, fontSize: 12, opacity: 0.7 } },
|
|
3485
|
+
tr('file.linkUnresolved'))
|
|
3486
|
+
}
|
|
3487
|
+
return React.createElement(RemoteFileTab, { key: remotePath, tab: { path: remotePath }, scope: { sessionId } })
|
|
3488
|
+
}
|
|
3489
|
+
/** The local cwd of a session, from the client's own session store. */
|
|
3490
|
+
function sessionCwdFor(sessionId) {
|
|
3491
|
+
try {
|
|
3492
|
+
const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
|
|
3493
|
+
? SESSIONS.list.getSnapshot()
|
|
3494
|
+
: null
|
|
3495
|
+
const entry = list && list.byId && list.byId[sessionId]
|
|
3496
|
+
return (entry && entry.cwd) ? String(entry.cwd) : ''
|
|
3497
|
+
} catch { return '' }
|
|
3498
|
+
}
|
|
3350
3499
|
function registerNativeSidebar(inner) {
|
|
3351
3500
|
const tabs = inner.get('sidebarRightTabs')
|
|
3352
3501
|
const slots = inner.get('slots')
|
|
3502
|
+
// Whether this build accepted the remote-link type. A local (not module
|
|
3503
|
+
// level) flag, so two plugin lifetimes cannot see each other's state.
|
|
3504
|
+
let linkTypeAccepted = false
|
|
3505
|
+
// Issue #55: `canOpen` is synchronous and runs on every routing decision,
|
|
3506
|
+
// so the mirror registry has to be warm BEFORE the first link click —
|
|
3507
|
+
// otherwise the very first click of a page load falls through to the
|
|
3508
|
+
// built-in local preview. This is a warm-up, not a dependency: a failure
|
|
3509
|
+
// just leaves the conservative behaviour in place.
|
|
3510
|
+
try { ensureMirrors() } catch { /* the registry is best-effort */ }
|
|
3353
3511
|
inner.effect(() => tabs.register({
|
|
3354
3512
|
id: NATIVE_EXPLORER, kind: NATIVE_EXPLORER,
|
|
3355
3513
|
title: () => tr('explorer.tabTitle'),
|
|
@@ -3360,11 +3518,59 @@ window.__ModuleLoader__.load({
|
|
|
3360
3518
|
patterns: ['dsh-resource://dsh-remote/**'],
|
|
3361
3519
|
title: (address) => remoteFilePath(address).split(/[\\/]/).pop() || tr('explorer.tabTitle'),
|
|
3362
3520
|
}), 'dsh-remote.native-file-type')
|
|
3521
|
+
// Issue #55. No `priority` is declared, so this registers in the
|
|
3522
|
+
// `extension` band, which by contract outranks every viewer the product
|
|
3523
|
+
// ships — including the built-in text preview that currently claims
|
|
3524
|
+
// `dsh-resource://file/**` in the `fallback` band and reads locally.
|
|
3525
|
+
// `canOpen` is the whole safety story: it must return true ONLY for a
|
|
3526
|
+
// session whose cwd is PROVEN to be a remote mirror. Local sessions, and
|
|
3527
|
+
// a page load before the mirror registry has arrived, fall through to the
|
|
3528
|
+
// built-in preview and behave exactly as they did before.
|
|
3529
|
+
//
|
|
3530
|
+
// Registration is wrapped because the address-claiming half of this
|
|
3531
|
+
// contract (`patterns` + `canOpen`) is not something every DSH line
|
|
3532
|
+
// necessarily has. The plugin supports both the 0.1.x and 0.2.x lines, and
|
|
3533
|
+
// on a build whose tab registry cannot express an address claim the right
|
|
3534
|
+
// outcome is "file links keep working the old way" — NOT a throw that
|
|
3535
|
+
// would take the explorer and file types down with it.
|
|
3536
|
+
inner.effect(() => {
|
|
3537
|
+
try {
|
|
3538
|
+
const dispose = tabs.register({
|
|
3539
|
+
id: NATIVE_LINK, kind: NATIVE_LINK,
|
|
3540
|
+
patterns: ['dsh-resource://file/**'],
|
|
3541
|
+
canOpen: (address) => {
|
|
3542
|
+
const parsed = parseCoreFileAddress(address)
|
|
3543
|
+
if (!parsed) return false
|
|
3544
|
+
const cwd = sessionCwdFor(parsed.sessionId)
|
|
3545
|
+
// `MIRRORS.loaded` is checked so a cold start NEVER steals a local
|
|
3546
|
+
// link: an empty registry cannot prove a mirror, and guessing would
|
|
3547
|
+
// break the text preview for every local session.
|
|
3548
|
+
if (!cwd || !MIRRORS.loaded) return false
|
|
3549
|
+
return !!mirrorForCwd(cwd, MIRRORS.list)
|
|
3550
|
+
},
|
|
3551
|
+
title: (address) => {
|
|
3552
|
+
const parsed = parseCoreFileAddress(address)
|
|
3553
|
+
return ((parsed && parsed.path) || '').split(/[\\/]/).pop() || tr('explorer.tabTitle')
|
|
3554
|
+
},
|
|
3555
|
+
})
|
|
3556
|
+
linkTypeAccepted = true
|
|
3557
|
+
return () => { linkTypeAccepted = false; if (typeof dispose === 'function') dispose() }
|
|
3558
|
+
} catch (err) {
|
|
3559
|
+
try { console.warn('dsh-remote: remote file links are unavailable on this DSH build', err) } catch { /* no console */ }
|
|
3560
|
+
return undefined
|
|
3561
|
+
}
|
|
3562
|
+
}, 'dsh-remote.native-link-type')
|
|
3363
3563
|
inner.effect(() => slots.inject('sidebar.right.pane.tab', () => {
|
|
3364
3564
|
const disposers = [
|
|
3365
3565
|
slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_EXPLORER }, NativeExplorer),
|
|
3366
3566
|
slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_FILE }, NativeFile),
|
|
3367
3567
|
]
|
|
3568
|
+
// Only register the link body when its type was accepted: a body under a
|
|
3569
|
+
// kind nothing claims would never render, and on a build that rejected
|
|
3570
|
+
// the type the seat may reject the body too.
|
|
3571
|
+
if (linkTypeAccepted) {
|
|
3572
|
+
disposers.push(slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_LINK }, NativeRemoteLink))
|
|
3573
|
+
}
|
|
3368
3574
|
return () => disposers.forEach((dispose) => dispose())
|
|
3369
3575
|
}), 'dsh-remote.native-bodies')
|
|
3370
3576
|
}
|
package/lib/forwards.js
CHANGED
|
@@ -14,11 +14,17 @@
|
|
|
14
14
|
import net from 'node:net'
|
|
15
15
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
+
import { forwardOutChannel } from './pool.js'
|
|
17
18
|
|
|
18
19
|
export class ForwardManager {
|
|
19
|
-
/** @param {object} pool SshPool (needs .connect() returning the ssh2 client).
|
|
20
|
-
|
|
20
|
+
/** @param {object} pool SshPool (needs .connect() returning the ssh2 client).
|
|
21
|
+
* @param {object} [opts] - `file` (persistence) and `net` (a `node:net`
|
|
22
|
+
* compatible module, so the listener's connection handler — the path the
|
|
23
|
+
* issue #54 crash travelled — can be driven from a unit test without
|
|
24
|
+
* binding a real port). */
|
|
25
|
+
constructor(pool, { file, net: netModule } = {}) {
|
|
21
26
|
this.pool = pool
|
|
27
|
+
this.net = netModule || net
|
|
22
28
|
this.file = file || null
|
|
23
29
|
this.defs = this._load()
|
|
24
30
|
this.servers = new Map() // id → { server | remoteListen, sockets:Set, client }
|
|
@@ -134,9 +140,17 @@ export class ForwardManager {
|
|
|
134
140
|
|
|
135
141
|
_startLocal(d, client) {
|
|
136
142
|
return new Promise((resolve, reject) => {
|
|
137
|
-
const server = net.createServer((socket) => {
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
const server = this.net.createServer((socket) => {
|
|
144
|
+
// ssh2's forwardOut reports a dead connection by THROWING
|
|
145
|
+
// (`if (!this._sock || !isWritable(this._sock)) throw new Error('Not
|
|
146
|
+
// connected')`) and never calls the callback, so a callback-only error
|
|
147
|
+
// path cannot guard this call. Uncaught, the throw escapes net.Server's
|
|
148
|
+
// connection handler and kills the whole host process — the exact fatal
|
|
149
|
+
// stack of issue #54, found in WebAttach and reachable here too for a
|
|
150
|
+
// user-defined local forward. `attach()` does register a `close`
|
|
151
|
+
// listener that calls stopAll(), but a request can arrive in the window
|
|
152
|
+
// between the connection dying and that event.
|
|
153
|
+
forwardOutChannel(client, { dstHost: d.targetHost, dstPort: d.targetPort }).then((channel) => {
|
|
140
154
|
socket.pipe(channel)
|
|
141
155
|
channel.pipe(socket)
|
|
142
156
|
const kill = () => { try { socket.destroy() } catch {}; try { channel.close() } catch {} }
|
|
@@ -144,6 +158,9 @@ export class ForwardManager {
|
|
|
144
158
|
channel.on('error', kill)
|
|
145
159
|
socket.on('close', kill)
|
|
146
160
|
channel.on('close', kill)
|
|
161
|
+
}).catch(() => {
|
|
162
|
+
// A dead tunnel is a UI state, not a reason to exit.
|
|
163
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
147
164
|
})
|
|
148
165
|
})
|
|
149
166
|
server.on('error', (e) => reject(new Error(`本地转发 ${d.listenPort} 启动失败: ${e.message}`)))
|
|
@@ -192,7 +209,7 @@ export class ForwardManager {
|
|
|
192
209
|
for (const entry of this.servers.values()) {
|
|
193
210
|
if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
|
|
194
211
|
const channel = accept()
|
|
195
|
-
const socket = net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
|
|
212
|
+
const socket = this.net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
|
|
196
213
|
socket.pipe(channel)
|
|
197
214
|
channel.pipe(socket)
|
|
198
215
|
entry.sockets.add(socket)
|
package/lib/index.js
CHANGED
|
@@ -1558,6 +1558,10 @@ export async function apply(ctx, config) {
|
|
|
1558
1558
|
// handed to ssh verbatim so ~/.ssh/config stays the single source.
|
|
1559
1559
|
if (args.backend) rec.backend = args.backend === 'openssh' ? 'openssh' : 'native'
|
|
1560
1560
|
if (args.sshBinary !== undefined) rec.sshBinary = String(args.sshBinary)
|
|
1561
|
+
// Issue #54: the per-machine `dsh web` command is settable by hand too,
|
|
1562
|
+
// not only by the deploy flow (the README told users to set it and the
|
|
1563
|
+
// settings UI offered nowhere to type it).
|
|
1564
|
+
if (args.webAttachCommand !== undefined) rec.webAttachCommand = String(args.webAttachCommand)
|
|
1561
1565
|
if (useSshConfig) rec.useSshConfig = true
|
|
1562
1566
|
// An alias connection must dial the RESOLVED identity (~/.ssh/config),
|
|
1563
1567
|
// while the registry keeps the alias itself — EXCEPT on the
|
|
@@ -2977,6 +2981,15 @@ export async function apply(ctx, config) {
|
|
|
2977
2981
|
// the default, so an unknown value is not persisted as-is.
|
|
2978
2982
|
backend: body.backend === 'openssh' ? 'openssh' : 'native',
|
|
2979
2983
|
sshBinary: String(body.sshBinary || '').trim(),
|
|
2984
|
+
// Issue #54: the per-machine `dsh web` launch command was only
|
|
2985
|
+
// ever writable by the deploy flow or by hand-editing
|
|
2986
|
+
// machines.json — the README told users to set it and the
|
|
2987
|
+
// settings UI offered nowhere to type it. It is an ordinary
|
|
2988
|
+
// machine field now; empty means "fall back to the global
|
|
2989
|
+
// webAttachCommand".
|
|
2990
|
+
webAttachCommand: body.webAttachCommand === undefined
|
|
2991
|
+
? String((prev && prev.webAttachCommand) || '')
|
|
2992
|
+
: String(body.webAttachCommand || '').trim(),
|
|
2980
2993
|
proxy: body.proxy && body.proxy.host ? {
|
|
2981
2994
|
host: String(body.proxy.host),
|
|
2982
2995
|
port: Number(body.proxy.port) || 22,
|
|
@@ -3452,12 +3465,17 @@ export async function apply(ctx, config) {
|
|
|
3452
3465
|
if (live) { try { await live.close() } catch { /* already torn down */ } }
|
|
3453
3466
|
const machine = machines.find((m) => m.id === machineId)
|
|
3454
3467
|
if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
|
|
3468
|
+
const attachPool = poolForMachine(identityOf(machine))
|
|
3455
3469
|
const attach = new WebAttach({
|
|
3456
3470
|
localPortStart: config.webAttachPortStart,
|
|
3471
|
+
// Issue #54: follow the chosen pool's lifecycle so a dropped SSH
|
|
3472
|
+
// connection degrades to "disconnected" and then self-heals,
|
|
3473
|
+
// instead of leaving a dead client that crashes the host process.
|
|
3474
|
+
trackPool: true,
|
|
3457
3475
|
})
|
|
3458
3476
|
try {
|
|
3459
3477
|
await attach.open({
|
|
3460
|
-
pool:
|
|
3478
|
+
pool: attachPool,
|
|
3461
3479
|
machineId,
|
|
3462
3480
|
command: String(body.command || '').trim() || webAttachCommandFor(machine),
|
|
3463
3481
|
profile: 'web',
|
package/lib/pool.js
CHANGED
|
@@ -26,6 +26,39 @@ export function createPool(config, opts) {
|
|
|
26
26
|
: new SshPool(config, opts)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Run one `forwardOut` and resolve with the resulting channel.
|
|
31
|
+
*
|
|
32
|
+
* ssh2 reports a dead connection by THROWING synchronously —
|
|
33
|
+
* `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
|
|
34
|
+
* (ssh2/lib/client.js:1398-1400) — and never calls the callback, so a
|
|
35
|
+
* callback-only error path cannot guard this call (issue #54). Two further
|
|
36
|
+
* consequences justify a shared helper rather than an inline try/catch:
|
|
37
|
+
*
|
|
38
|
+
* • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
|
|
39
|
+
* an unhandled REJECTION and leaves the promise pending forever;
|
|
40
|
+
* • called from a `net.Server` connection handler the throw escapes to the
|
|
41
|
+
* process top level and kills the host.
|
|
42
|
+
*
|
|
43
|
+
* The system-OpenSSH backend (lib/openssh.js) throws the same way when its
|
|
44
|
+
* connection is already closed, so this covers both transports.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} client - an ssh2 Client or OpenSshConnection.
|
|
47
|
+
* @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
|
|
48
|
+
* @returns {Promise<object>} the forwarded channel / duplex stream.
|
|
49
|
+
*/
|
|
50
|
+
export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
// `reject` is called at most once: a callback that fires after a successful
|
|
53
|
+
// throw-path rejection would be a no-op, which Promise guarantees.
|
|
54
|
+
try {
|
|
55
|
+
client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
|
|
56
|
+
} catch (err) {
|
|
57
|
+
reject(err)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
/**
|
|
30
63
|
* The ACTIVE pool: one long-lived object whose concrete implementation can be
|
|
31
64
|
* swapped when the current machine's transport backend changes.
|
|
@@ -262,9 +295,11 @@ export class SshPool {
|
|
|
262
295
|
}, { knownHostsFile: this._knownHostsFile })
|
|
263
296
|
const pclient = await this.proxyPool.connect()
|
|
264
297
|
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
265
|
-
|
|
266
|
-
pclient
|
|
267
|
-
})
|
|
298
|
+
try {
|
|
299
|
+
sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
|
|
300
|
+
} catch (e) {
|
|
301
|
+
throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
|
|
302
|
+
}
|
|
268
303
|
} catch (err) {
|
|
269
304
|
return fail(err)
|
|
270
305
|
}
|
package/lib/web-attach.js
CHANGED
|
@@ -222,14 +222,30 @@ export class WebAttach {
|
|
|
222
222
|
* @param {object} [opts.net] - a `node:net`-compatible module (tests).
|
|
223
223
|
* @param {number} [opts.localPortStart] - first local port to try.
|
|
224
224
|
* @param {number} [opts.localPortMax] - how many ports to try before failing.
|
|
225
|
+
* @param {boolean} [opts.trackPool] - follow the pool across reconnects so a
|
|
226
|
+
* dropped SSH connection degrades to "disconnected" and then SELF-HEALS
|
|
227
|
+
* instead of being a permanent dead end (issue #54). `true` in production.
|
|
225
228
|
*/
|
|
226
229
|
constructor(opts = {}) {
|
|
227
230
|
this.net = opts.net || net
|
|
228
231
|
this.localPortStart = opts.localPortStart || 3088
|
|
229
232
|
this.localPortMax = opts.localPortMax || 50
|
|
233
|
+
/** Whether the pool's lifecycle hooks are ours to install. */
|
|
234
|
+
this.trackPool = opts.trackPool !== false
|
|
230
235
|
/** @type {import('node:net').Server|null} */ this.server = null
|
|
231
236
|
/** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
|
|
232
237
|
/** @type {object|null} */ this.client = null
|
|
238
|
+
/** The pool this session borrowed its connection from (may be null). */
|
|
239
|
+
this.pool = null
|
|
240
|
+
/** Pool hooks we installed, so close() can hand them back untouched. */
|
|
241
|
+
this.prevOnReady = null
|
|
242
|
+
this.prevOnCloseHook = null
|
|
243
|
+
this.hookedPool = null
|
|
244
|
+
this._installedOnReady = null
|
|
245
|
+
this._installedOnClose = null
|
|
246
|
+
this.attachedClient = null
|
|
247
|
+
/** Bound listener, kept so it can be removed from the client. */
|
|
248
|
+
this.onClientClose = null
|
|
233
249
|
this.localPort = 0
|
|
234
250
|
this.remotePort = 0
|
|
235
251
|
this.token = ''
|
|
@@ -239,6 +255,28 @@ export class WebAttach {
|
|
|
239
255
|
this.machineId = ''
|
|
240
256
|
this.command = ''
|
|
241
257
|
this.startedAt = 0
|
|
258
|
+
/** Launch knobs remembered so a restart after a drop uses the same ones. */
|
|
259
|
+
this.waitSeconds = 0
|
|
260
|
+
this.omittedNoOpen = false
|
|
261
|
+
/** True while a reconnect-triggered re-resolve is in flight. */
|
|
262
|
+
this._resolving = false
|
|
263
|
+
/** True once the borrowed connection has been lost (drives recovery). */
|
|
264
|
+
this.connectionLost = false
|
|
265
|
+
/** undefined = "not asked yet"; false = the remote process is provably gone. */
|
|
266
|
+
this._remoteAlive = undefined
|
|
267
|
+
/** Set by close(): nothing may re-arm the tunnel afterwards. */
|
|
268
|
+
this.closed = false
|
|
269
|
+
/** Set once the borrowed connection has gone away (see _clientLost). */
|
|
270
|
+
this.lastError = ''
|
|
271
|
+
/**
|
|
272
|
+
* A listener that throws EARNS an 'error' event on the emitting object, and
|
|
273
|
+
* an 'error' with no listener is itself fatal — the very crash this module
|
|
274
|
+
* exists to prevent. Nothing here is expected to throw, but a lifecycle
|
|
275
|
+
* callback running on someone else's event loop must never be the reason
|
|
276
|
+
* the host process dies, so both hooks are wrapped.
|
|
277
|
+
*/
|
|
278
|
+
this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
|
|
279
|
+
this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
|
|
242
280
|
}
|
|
243
281
|
|
|
244
282
|
/** Whether the tunnel is currently listening. */
|
|
@@ -268,11 +306,17 @@ export class WebAttach {
|
|
|
268
306
|
if (!pool) throw new Error('web-attach: a machine pool is required')
|
|
269
307
|
this.machineId = String(machineId || '')
|
|
270
308
|
this.command = command
|
|
309
|
+
this.waitSeconds = Number(waitSeconds) || 45
|
|
271
310
|
|
|
272
311
|
const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
|
|
273
312
|
// Connecting first also surfaces credential/host-key problems BEFORE we
|
|
274
|
-
// start a remote process we would then have to clean up.
|
|
275
|
-
|
|
313
|
+
// start a remote process we would then have to clean up. `_attach` (rather
|
|
314
|
+
// than a bare assignment) is what subscribes to the connection's 'close',
|
|
315
|
+
// so the loss is noticed the moment it happens instead of on the next
|
|
316
|
+
// request — which is when the old code let ssh2 throw 'Not connected' from
|
|
317
|
+
// inside a socket callback and take the host process with it (issue #54).
|
|
318
|
+
this._attach(await pool.connect())
|
|
319
|
+
this._followPool(pool)
|
|
276
320
|
|
|
277
321
|
if (existingUrl) {
|
|
278
322
|
const parsed = parseWebStart(`dsh web: ${existingUrl}`)
|
|
@@ -398,24 +442,334 @@ export class WebAttach {
|
|
|
398
442
|
})
|
|
399
443
|
}
|
|
400
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Borrow the given connection AND subscribe to its lifecycle.
|
|
447
|
+
*
|
|
448
|
+
* Idempotent for the same client (open() calls it once, the pool's `onReady`
|
|
449
|
+
* calls it again on every reconnect), and it always drops the previous
|
|
450
|
+
* client's listener first, so one session never accumulates listeners.
|
|
451
|
+
*
|
|
452
|
+
* @param {object|null} client - the live ssh2 client / OpenSshConnection.
|
|
453
|
+
*/
|
|
454
|
+
_attach(client) {
|
|
455
|
+
if (!client) return
|
|
456
|
+
if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
|
|
457
|
+
// A different client replaced the one we borrowed (pool reconnect). Stop
|
|
458
|
+
// listening to the old one; its own 'close' has either fired already or
|
|
459
|
+
// belongs to a connection nobody uses any more.
|
|
460
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
461
|
+
}
|
|
462
|
+
this.client = client
|
|
463
|
+
if (this.attachedClient === client) return
|
|
464
|
+
this.attachedClient = client
|
|
465
|
+
this.onClientClose = () => { this._clientLost('ssh connection closed') }
|
|
466
|
+
// Without this, the object keeps handing out a connection that is already
|
|
467
|
+
// closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
|
|
468
|
+
try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The borrowed connection is gone: keep the session, drop the dead handle. */
|
|
472
|
+
_clientLost(reason) {
|
|
473
|
+
this.client = null
|
|
474
|
+
this.connectionLost = true
|
|
475
|
+
this.lastError = String(reason || 'ssh connection closed')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Register lifecycle hooks on the chosen pool so this tunnel follows it.
|
|
480
|
+
*
|
|
481
|
+
* The pool is the one place that both NOTICES a dead connection and later
|
|
482
|
+
* obtains a fresh one, and it exposes exactly two hooks for that:
|
|
483
|
+
* • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
|
|
484
|
+
* the "channel open failed, the pooled connection died server-side"
|
|
485
|
+
* self-heal path, which is the likeliest trigger);
|
|
486
|
+
* • `onReady` — fires after a successful (re)connect.
|
|
487
|
+
*
|
|
488
|
+
* Both are SINGLE-SLOT properties that the plugin already uses for
|
|
489
|
+
* `ForwardManager` (index.js), so this session CHAINS them rather than
|
|
490
|
+
* clobbering the forwards manager, and `_detachPool()` restores exactly what
|
|
491
|
+
* was there — the plugin may legitimately have none, one, or both.
|
|
492
|
+
*
|
|
493
|
+
* A real reconnect is the only way the remote port can move, so once the pool
|
|
494
|
+
* reports a new connection we must re-resolve (token, port) from the remote
|
|
495
|
+
* log. `onReady` itself cannot await: the pool fires it with the client in
|
|
496
|
+
* hand, so the re-resolve is kicked off and its failure is recorded rather
|
|
497
|
+
* than thrown into the pool's callback.
|
|
498
|
+
*
|
|
499
|
+
* @param {object} pool - the pool this session attached through.
|
|
500
|
+
*/
|
|
501
|
+
_followPool(pool) {
|
|
502
|
+
if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
|
|
503
|
+
this._detachPool()
|
|
504
|
+
this.pool = pool
|
|
505
|
+
this.hookedPool = pool
|
|
506
|
+
const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
|
|
507
|
+
const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
|
|
508
|
+
this.prevOnReady = prevReady
|
|
509
|
+
this.prevOnCloseHook = prevClose
|
|
510
|
+
const installedReady = (client) => {
|
|
511
|
+
this._attach(client)
|
|
512
|
+
if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
|
|
513
|
+
// A reconnect lands on a NEW remote process only if the old one died with
|
|
514
|
+
// the machine; re-read the log to find out which. The re-resolve runs off
|
|
515
|
+
// the event loop because the pool cannot await it — a hook that throws
|
|
516
|
+
// would earn an 'error' event on the pool, and an unhandled rejection
|
|
517
|
+
// would be fatal.
|
|
518
|
+
if (!this.closed && this.remotePort && !this._resolving) {
|
|
519
|
+
this._resolving = true
|
|
520
|
+
Promise.resolve()
|
|
521
|
+
.then(() => this._refreshRemotePort())
|
|
522
|
+
.catch((err) => { this.lastError = String((err && err.message) || err) })
|
|
523
|
+
.then(() => { this._resolving = false })
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const installedClose = () => {
|
|
527
|
+
this._clientLost('ssh connection closed')
|
|
528
|
+
if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
|
|
529
|
+
}
|
|
530
|
+
this._installedOnReady = installedReady
|
|
531
|
+
this._installedOnClose = installedClose
|
|
532
|
+
pool.onReady = installedReady
|
|
533
|
+
pool.onCloseHook = installedClose
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Hand the pool's hooks back.
|
|
538
|
+
*
|
|
539
|
+
* Each slot is restored ONLY when it still holds the wrapper this session
|
|
540
|
+
* installed. The pool's hooks are single-slot, so if something else has taken
|
|
541
|
+
* the slot over in the meantime, putting a stale callback back would be worse
|
|
542
|
+
* than leaving it alone — it would silently disconnect a newer owner.
|
|
543
|
+
*/
|
|
544
|
+
_detachPool() {
|
|
545
|
+
const pool = this.hookedPool
|
|
546
|
+
const mineReady = this._installedOnReady
|
|
547
|
+
const mineClose = this._installedOnClose
|
|
548
|
+
this.hookedPool = null
|
|
549
|
+
this._installedOnReady = null
|
|
550
|
+
this._installedOnClose = null
|
|
551
|
+
if (!pool) return
|
|
552
|
+
if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
|
|
553
|
+
if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
|
|
554
|
+
this.prevOnReady = null
|
|
555
|
+
this.prevOnCloseHook = null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* True when the borrowed connection was LOST and this is a session we can
|
|
560
|
+
* recover: we launched the remote process ourselves (so we hold its PID and
|
|
561
|
+
* log) and the tunnel is listening (so clients can actually be served).
|
|
562
|
+
*
|
|
563
|
+
* The `connectionLost` flag is deliberately separate from `this.client`: the
|
|
564
|
+
* pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
|
|
565
|
+
* check on `!this.client` would be false by then and recovery would never
|
|
566
|
+
* happen.
|
|
567
|
+
*/
|
|
568
|
+
get _needsReResolve() {
|
|
569
|
+
return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
|
|
574
|
+
* when it did not survive.
|
|
575
|
+
*
|
|
576
|
+
* Reaching a remote process needs a live SSH connection, so this reconnects
|
|
577
|
+
* through the pool first — the pool reports `onReady` for that same client in
|
|
578
|
+
* production, and in a test double the callback simply never fires.
|
|
579
|
+
*/
|
|
580
|
+
async _refreshRemotePort() {
|
|
581
|
+
if (!this._needsReResolve) return
|
|
582
|
+
const pool = this.pool
|
|
583
|
+
if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
|
|
584
|
+
const exec = (cmd, opts) => (typeof pool.exec === 'function'
|
|
585
|
+
? pool.exec(cmd, opts)
|
|
586
|
+
: Promise.reject(new Error('web-attach: the pool cannot run commands')))
|
|
587
|
+
if (!this.client) await pool.connect()
|
|
588
|
+
const match = await this._resolveRemotePort(exec)
|
|
589
|
+
this.remotePort = match.port
|
|
590
|
+
if (match.token) this.token = match.token
|
|
591
|
+
this.connectionLost = false
|
|
592
|
+
this.lastError = ''
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Read this session's remote log and decide which port is live.
|
|
597
|
+
*
|
|
598
|
+
* The startup line is the ONLY source of the port (the launch deliberately
|
|
599
|
+
* uses `--port 0`), so after the remote process died and was restarted the log
|
|
600
|
+
* holds BOTH lines — the dead one and the new one — and neither position
|
|
601
|
+
* identifies the live one. Evidence decides:
|
|
602
|
+
* • a process that is still alive on the remote is probed first, and when
|
|
603
|
+
* the port it is LISTENING on is one of the logged ports, that is the
|
|
604
|
+
* answer (the `dsh web: …` line names its own port, so this is a
|
|
605
|
+
* confirmation rather than a second guess);
|
|
606
|
+
* • otherwise the newest logged port wins, which is right in every
|
|
607
|
+
* single-process case and is the best available answer while the remote is
|
|
608
|
+
* still booting (the next reconnect retries).
|
|
609
|
+
*
|
|
610
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
611
|
+
* @returns {Promise<{port: number, token: string, restarted: boolean}>}
|
|
612
|
+
*/
|
|
613
|
+
async _resolveRemotePort(exec) {
|
|
614
|
+
this._remoteAlive = await this._remoteProcessAlive(exec)
|
|
615
|
+
if (!this._remoteAlive) return this._restartRemote(exec)
|
|
616
|
+
|
|
617
|
+
let out = ''
|
|
618
|
+
try {
|
|
619
|
+
const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
|
|
620
|
+
out = String((res && res.stdout) || '')
|
|
621
|
+
} catch { /* an unreadable log: the listening probe below is the fallback */ }
|
|
622
|
+
const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
623
|
+
.map((m) => parseWebStart(`dsh web: ${m[1]}`))
|
|
624
|
+
.filter(Boolean)
|
|
625
|
+
|
|
626
|
+
// What the recorded process actually listens on beats any guess from the
|
|
627
|
+
// log, because a killed-and-restarted remote leaves BOTH startup lines in
|
|
628
|
+
// the log and neither position says which one is live.
|
|
629
|
+
const listening = await this._listeningRemotePort(exec)
|
|
630
|
+
if (listening) {
|
|
631
|
+
const match = lines.find((l) => l.port === listening)
|
|
632
|
+
return { port: listening, token: (match && match.token) || this.token, restarted: false }
|
|
633
|
+
}
|
|
634
|
+
if (lines.length) {
|
|
635
|
+
const newest = lines[lines.length - 1]
|
|
636
|
+
return { port: newest.port, token: newest.token, restarted: false }
|
|
637
|
+
}
|
|
638
|
+
// Alive but silent and not listening yet: the remote is booting. Keep the
|
|
639
|
+
// last known port and let the next reconnect re-resolve.
|
|
640
|
+
return { port: this.remotePort, token: this.token, restarted: false }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Whether the remote process THIS session started still exists.
|
|
645
|
+
*
|
|
646
|
+
* Verified through `/proc`, which is Linux-only; a host that cannot answer (or
|
|
647
|
+
* a failed probe) is treated as ALIVE on purpose. Restarting a server that was
|
|
648
|
+
* never dead would spawn a second one and leak a port, whereas assuming life
|
|
649
|
+
* merely retries a port that turns out to be stale.
|
|
650
|
+
*
|
|
651
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
652
|
+
* @returns {Promise<boolean>}
|
|
653
|
+
*/
|
|
654
|
+
async _remoteProcessAlive(exec) {
|
|
655
|
+
const pid = Number(this.remotePid) || 0
|
|
656
|
+
if (pid < 1) return true
|
|
657
|
+
try {
|
|
658
|
+
const res = await exec(
|
|
659
|
+
`if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
|
|
660
|
+
{ timeoutMs: 15000 },
|
|
661
|
+
)
|
|
662
|
+
return !/GONE/.test(String((res && res.stdout) || ''))
|
|
663
|
+
} catch { return true }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Ask the remote which loopback port the recorded PID listens on (0 = none).
|
|
668
|
+
*
|
|
669
|
+
* Two `/proc` reads are needed because a TCP listener appears in a socket
|
|
670
|
+
* table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
|
|
671
|
+
* symlinks give the socket INODE, and the table maps that inode to a port.
|
|
672
|
+
* Matching on the inode rather than the port is what makes this an
|
|
673
|
+
* observation instead of a second guess.
|
|
674
|
+
*/
|
|
675
|
+
async _listeningRemotePort(exec) {
|
|
676
|
+
const pid = Number(this.remotePid) || 0
|
|
677
|
+
if (pid < 1) return 0
|
|
678
|
+
let inodes = ''
|
|
679
|
+
try {
|
|
680
|
+
// `ls -l` on a process's own fds needs no privileges for the same uid.
|
|
681
|
+
const res = await exec(
|
|
682
|
+
`ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
|
|
683
|
+
{ timeoutMs: 15000 },
|
|
684
|
+
)
|
|
685
|
+
inodes = String((res && res.stdout) || '').trim()
|
|
686
|
+
} catch { return 0 }
|
|
687
|
+
if (!inodes) return 0
|
|
688
|
+
const want = new Set(inodes.split(/\s+/).filter(Boolean))
|
|
689
|
+
// `/proc/net/tcp` addresses are little-endian hex, so loopback is
|
|
690
|
+
// `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
|
|
691
|
+
const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
|
|
692
|
+
for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
693
|
+
let text = ''
|
|
694
|
+
try {
|
|
695
|
+
const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
|
|
696
|
+
text = String((res && res.stdout) || '')
|
|
697
|
+
} catch { continue }
|
|
698
|
+
for (const line of text.split('\n').slice(1)) {
|
|
699
|
+
const cols = line.trim().split(/\s+/)
|
|
700
|
+
// sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
|
|
701
|
+
if (cols.length < 10) continue
|
|
702
|
+
if (cols[3] !== '0A') continue // TCP_LISTEN
|
|
703
|
+
if (!want.has(cols[9])) continue
|
|
704
|
+
const [addr, portHex] = String(cols[1]).split(':')
|
|
705
|
+
if (!LOOPBACK.has(String(addr).toUpperCase())) continue
|
|
706
|
+
const port = parseInt(portHex, 16)
|
|
707
|
+
if (Number.isInteger(port) && port > 0 && port < 65536) return port
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return 0
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Relaunch the remote `dsh web` because the previous process is gone.
|
|
715
|
+
*
|
|
716
|
+
* A restart CANNOT change the listening port locally, and a caller holding a
|
|
717
|
+
* stale port silently talks to the wrong place at worst and hangs at best, so
|
|
718
|
+
* the two facts the caller needs are checked: this is a session that launched
|
|
719
|
+
* its own process (an attach onto someone else's DSH stays hands-off and can
|
|
720
|
+
* never restart it), and recovery is possible at all (`_refreshRemotePort`
|
|
721
|
+
* has already returned early when it is not).
|
|
722
|
+
*/
|
|
723
|
+
async _restartRemote(exec) {
|
|
724
|
+
const command = this.command || 'dsh'
|
|
725
|
+
const waitSeconds = this.waitSeconds || 45
|
|
726
|
+
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
727
|
+
let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
|
|
728
|
+
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
729
|
+
out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
|
|
730
|
+
this.omittedNoOpen = true
|
|
731
|
+
}
|
|
732
|
+
this.remotePid = parseRemotePid(out) || this.remotePid
|
|
733
|
+
const parsed = parseWebStart(out)
|
|
734
|
+
if (!parsed) {
|
|
735
|
+
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
736
|
+
throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
|
|
737
|
+
+ ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
|
|
738
|
+
}
|
|
739
|
+
return { port: parsed.port, token: parsed.token, restarted: true }
|
|
740
|
+
}
|
|
741
|
+
|
|
401
742
|
/** Carry one local socket to the remote web port over SSH. */
|
|
402
743
|
_pipe(socket) {
|
|
403
744
|
if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
745
|
+
// ssh2's forwardOut does NOT report a dead connection through its callback:
|
|
746
|
+
// when the socket is gone it THROWS 'Not connected' synchronously
|
|
747
|
+
// (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
|
|
748
|
+
// and the callback below is never reached — so `if (err)` cannot guard this
|
|
749
|
+
// path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
|
|
750
|
+
// the whole host process (issue #54: every page refresh after the pooled
|
|
751
|
+
// connection dropped took the session, the browser and the remote orphan
|
|
752
|
+
// down with it). A dead tunnel is a UI state, not a reason to exit.
|
|
753
|
+
try {
|
|
754
|
+
this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
|
|
755
|
+
if (err) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
756
|
+
this.sockets.add(socket)
|
|
757
|
+
socket.pipe(channel)
|
|
758
|
+
channel.pipe(socket)
|
|
759
|
+
const kill = () => {
|
|
760
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
761
|
+
try { channel.close() } catch { /* already closed */ }
|
|
762
|
+
this.sockets.delete(socket)
|
|
763
|
+
}
|
|
764
|
+
socket.on('error', kill)
|
|
765
|
+
channel.on('error', kill)
|
|
766
|
+
socket.on('close', kill)
|
|
767
|
+
channel.on('close', kill)
|
|
768
|
+
})
|
|
769
|
+
} catch (err) {
|
|
770
|
+
this._clientLost(String((err && err.message) || err))
|
|
771
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
772
|
+
}
|
|
419
773
|
}
|
|
420
774
|
|
|
421
775
|
/**
|
|
@@ -462,6 +816,15 @@ export class WebAttach {
|
|
|
462
816
|
* @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
|
|
463
817
|
*/
|
|
464
818
|
async close({ stopRemote = false, exec } = {}) {
|
|
819
|
+
// Stop following the pool BEFORE any await: a reconnect landing mid-teardown
|
|
820
|
+
// must not re-arm the tunnel this call is in the middle of closing.
|
|
821
|
+
this._detachPool()
|
|
822
|
+
if (this.attachedClient && this.onClientClose) {
|
|
823
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
824
|
+
}
|
|
825
|
+
this.attachedClient = null
|
|
826
|
+
this.onClientClose = null
|
|
827
|
+
this.pool = null
|
|
465
828
|
for (const socket of [...this.sockets]) {
|
|
466
829
|
try { socket.destroy() } catch { /* already gone */ }
|
|
467
830
|
}
|
|
@@ -475,5 +838,9 @@ export class WebAttach {
|
|
|
475
838
|
await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
|
|
476
839
|
}
|
|
477
840
|
this.client = null
|
|
841
|
+
// The recorded PID and log path are FACTS about this session (the UI shows
|
|
842
|
+
// them, and `stopRemote` addresses them), so they survive; `closed` is what
|
|
843
|
+
// stops recovery work from being re-armed after teardown.
|
|
844
|
+
this.closed = true
|
|
478
845
|
}
|
|
479
846
|
}
|
package/lib/web-deploy.js
CHANGED
|
@@ -351,6 +351,31 @@ export function versionAtLeast(a, b) {
|
|
|
351
351
|
/** The first dsh release whose node-pty ships Linux prebuilds (measured). */
|
|
352
352
|
export const PTY_FIXED_VERSION = '0.1.5-rc.2'
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Minimum Node the remote must run, and the reason it is exactly this number.
|
|
356
|
+
*
|
|
357
|
+
* DSH's boot path imports `parseEnv` from `node:util`, and that export only
|
|
358
|
+
* exists from **20.12.0** (Node 21.7.0 and later have it too). Measured on a
|
|
359
|
+
* real host with two nodes installed:
|
|
360
|
+
*
|
|
361
|
+
* /usr/bin/node (v18.19.1) → parseEnv: undefined
|
|
362
|
+
* ~/.nvm/versions/node/v24.22.0/bin/node (v24.22.0) → parseEnv: function
|
|
363
|
+
*
|
|
364
|
+
* The failure mode is what makes this worth a BLOCKER rather than a warning: npm
|
|
365
|
+
* only prints EBADENGINE (it does not refuse), so all ~535 packages install
|
|
366
|
+
* cleanly and the crash arrives afterwards as
|
|
367
|
+
*
|
|
368
|
+
* SyntaxError: The requested module 'node:util' does not provide an export
|
|
369
|
+
* named 'parseEnv' at .../@deepseek-ai/dsh-app-boot/lib/index.js:4
|
|
370
|
+
*
|
|
371
|
+
* which does not read as "your node is too old". Checking the version BEFORE the
|
|
372
|
+
* install is the only way to turn that into an actionable message.
|
|
373
|
+
*
|
|
374
|
+
* Reference: Node.js 20.12.0 release notes, "Loading and parsing environment
|
|
375
|
+
* variables" — https://nodejs.org/en/blog/release/v20.12.0
|
|
376
|
+
*/
|
|
377
|
+
export const MIN_NODE_VERSION = '20.12.0'
|
|
378
|
+
|
|
354
379
|
/**
|
|
355
380
|
* Turn facts into a verdict: what is wrong, why, and what to do.
|
|
356
381
|
*
|
|
@@ -394,6 +419,17 @@ export function judgeProbe(facts, opts = {}) {
|
|
|
394
419
|
add('NO_NODE', 'blocker', '远端没有 node',
|
|
395
420
|
'自动部署需要 node 与 npm。',
|
|
396
421
|
'先在该机器安装 Node.js(nvm 或系统包管理器),再回来重试。')
|
|
422
|
+
} else if (f.nodeVersion && !versionAtLeast(f.nodeVersion, MIN_NODE_VERSION)) {
|
|
423
|
+
// The install itself SUCCEEDS on this node — npm only warns (EBADENGINE) —
|
|
424
|
+
// and the failure surfaces later as a parseEnv import error that does not
|
|
425
|
+
// read as a version problem. So the check has to happen before the install.
|
|
426
|
+
add('NODE_TOO_OLD', 'blocker',
|
|
427
|
+
`远端 node ${f.nodeVersion} 版本过低(需要 ≥ ${MIN_NODE_VERSION})`,
|
|
428
|
+
`dsh 启动时要 import node:util 的 parseEnv,该导出从 Node ${MIN_NODE_VERSION} 起才有。`
|
|
429
|
+
+ '在这个版本上 npm 只会给 EBADENGINE 警告、仍然把包装完,'
|
|
430
|
+
+ '直到启动时才崩:SyntaxError: The requested module \'node:util\' does not provide an export named \'parseEnv\'。',
|
|
431
|
+
`升级该机器的 node 到 ${MIN_NODE_VERSION} 以上(例如 nvm install 22),`
|
|
432
|
+
+ '并把 `webAttachCommand` 指向新 node 的绝对路径(如 ~/.nvm/versions/node/v22.x/bin/node <前缀>/node_modules/@deepseek-ai/dsh/lib/bin.js)。')
|
|
397
433
|
}
|
|
398
434
|
if (!f.npm) {
|
|
399
435
|
add('NO_NPM', 'blocker', '远端没有 npm',
|
|
@@ -474,7 +510,13 @@ export function judgeProbe(facts, opts = {}) {
|
|
|
474
510
|
const blockers = findings.filter((x) => x.severity === 'blocker')
|
|
475
511
|
// A Windows remote with Git Bash can be installed into: pool.exec pipes every
|
|
476
512
|
// command through `bash -s`, so the POSIX plan works there.
|
|
477
|
-
|
|
513
|
+
//
|
|
514
|
+
// A node older than MIN_NODE_VERSION makes an install POINTLESS as well as
|
|
515
|
+
// failing: the packages land, then the binary cannot boot. Promising
|
|
516
|
+
// "can auto-install: yes" there is what sent the reporter of issue #54 through
|
|
517
|
+
// a 535-package install that ended in a parseEnv SyntaxError.
|
|
518
|
+
const nodeUsable = !!f.node && (!f.nodeVersion || versionAtLeast(f.nodeVersion, MIN_NODE_VERSION))
|
|
519
|
+
const canAutoInstall = nodeUsable && !!f.npm && (!f.windows || !!f.gitBash)
|
|
478
520
|
|
|
479
521
|
// Which command should the attach flow use? A verified deployment wins, then a
|
|
480
522
|
// usable dsh already on PATH.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.45",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|