dsh-remote 0.8.44 → 0.8.46

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 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,88 @@ 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
+ // The REMOTE host's separator is INDEPENDENT of this machine's, and taking
3419
+ // it from the address was wrong: a Windows client mirroring a Linux host
3420
+ // is the ordinary case here, and reading the separator off the local path
3421
+ // then glued a POSIX root to a Windows join ("/home/u/app" + "\" + "x.c").
3422
+ // Decide from the remote root alone.
3423
+ const remoteIsWin = /^[A-Za-z]:/.test(remoteRoot) || (remoteRoot.includes('\\') && !remoteRoot.includes('/'))
3424
+ const remoteSep = remoteIsWin ? '\\' : '/'
3425
+ const toRemote = (s) => String(s).replace(/[\\/]+/g, remoteSep)
3426
+ // Comparison is separator-INSENSITIVE: `@` completion may insert either
3427
+ // style, and the two sides describe the same directory. The local side
3428
+ // folds case when it looks like a Windows path (Windows paths do).
3429
+ const canon = (s) => String(s).replace(/[\\/]+/g, '/').replace(/\/+$/, '')
3430
+ const localDir = String(mirror.dir || '')
3431
+ const localIsWin = /^[A-Za-z]:/.test(localDir) || localDir.includes('\\')
3432
+ const fold = (s) => (localIsWin ? s.toLowerCase() : s)
3433
+ const rel = canon(parsed.path || '')
3434
+ const mirrorDir = canon(localDir)
3435
+ // The mirror's own directory prefix may be baked into the address when the
3436
+ // link was written against the LOCAL path (that is what `@` completion
3437
+ // inserts); strip it so the result is remote-relative rather than
3438
+ // duplicated under the remote root.
3439
+ const stripped = rel && mirrorDir && (fold(rel) === fold(mirrorDir) || fold(rel).startsWith(fold(mirrorDir) + '/'))
3440
+ ? rel.slice(mirrorDir.length).replace(/^\/+/, '')
3441
+ : rel
3442
+ const relBody = canon(stripped)
3443
+ if (!remoteRoot) return relBody ? toRemote(relBody) : remoteSep
3444
+ if (!relBody) return remoteRoot
3445
+ // A drive-letter absolute path on a POSIX remote is a LOCAL path that fell
3446
+ // outside the mirror: nothing on the remote can name it, so refuse rather
3447
+ // than invent "/home/u/app/D:/x.c".
3448
+ if (!remoteIsWin && /^[A-Za-z]:/.test(relBody)) return null
3449
+ // Genuinely absolute: the reader meant that path on the remote.
3450
+ if (/^[A-Za-z]:/.test(relBody) || /^\//.test(relBody)) return toRemote(relBody)
3451
+ return remoteRoot + remoteSep + toRemote(relBody)
3452
+ }
3335
3453
  function NativeExplorer(props) {
3336
3454
  const info = props.useTabInfo()
3337
3455
  const [expanded, setExpanded] = React.useState([])
@@ -3347,9 +3465,63 @@ window.__ModuleLoader__.load({
3347
3465
  const path = remoteFilePath(info.tab.navigation.address)
3348
3466
  return React.createElement(RemoteFileTab, { key: path, tab: { path }, scope: { sessionId: props.sessionId } })
3349
3467
  }
3468
+ /**
3469
+ * The body for a CORE file link in a remote Session (issue #55).
3470
+ *
3471
+ * It resolves the address to an absolute REMOTE path before rendering the
3472
+ * same editor the plugin's own explorer uses, so a link and the explorer
3473
+ * agree on what a path means. Resolution is attempted synchronously first
3474
+ * (the mirror registry is usually warm) and retried once the registry
3475
+ * arrives, so a click during page load still ends up in the right file.
3476
+ */
3477
+ function NativeRemoteLink(props) {
3478
+ const info = props.useTabInfo()
3479
+ const address = info.tab.navigation.address
3480
+ const parsed = parseCoreFileAddress(address)
3481
+ const sessionId = (parsed && parsed.sessionId) || props.sessionId
3482
+ const [remotePath, setRemotePath] = React.useState(() => {
3483
+ const p = parsed || parseCoreFileAddress(address)
3484
+ return p ? remotePathForCoreAddress(p, sessionCwdFor(sessionId)) : null
3485
+ })
3486
+ React.useEffect(() => {
3487
+ if (remotePath || !parsed) return undefined
3488
+ let cancelled = false
3489
+ ensureMirrors().then(() => {
3490
+ if (cancelled) return
3491
+ const cwd = sessionCwdFor(sessionId)
3492
+ const resolved = remotePathForCoreAddress(parsed, cwd)
3493
+ if (resolved) setRemotePath(resolved)
3494
+ })
3495
+ return () => { cancelled = true }
3496
+ }, [address, remotePath])
3497
+ if (!remotePath) {
3498
+ return React.createElement('div', { style: { padding: 16, fontSize: 12, opacity: 0.7 } },
3499
+ tr('file.linkUnresolved'))
3500
+ }
3501
+ return React.createElement(RemoteFileTab, { key: remotePath, tab: { path: remotePath }, scope: { sessionId } })
3502
+ }
3503
+ /** The local cwd of a session, from the client's own session store. */
3504
+ function sessionCwdFor(sessionId) {
3505
+ try {
3506
+ const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
3507
+ ? SESSIONS.list.getSnapshot()
3508
+ : null
3509
+ const entry = list && list.byId && list.byId[sessionId]
3510
+ return (entry && entry.cwd) ? String(entry.cwd) : ''
3511
+ } catch { return '' }
3512
+ }
3350
3513
  function registerNativeSidebar(inner) {
3351
3514
  const tabs = inner.get('sidebarRightTabs')
3352
3515
  const slots = inner.get('slots')
3516
+ // Whether this build accepted the remote-link type. A local (not module
3517
+ // level) flag, so two plugin lifetimes cannot see each other's state.
3518
+ let linkTypeAccepted = false
3519
+ // Issue #55: `canOpen` is synchronous and runs on every routing decision,
3520
+ // so the mirror registry has to be warm BEFORE the first link click —
3521
+ // otherwise the very first click of a page load falls through to the
3522
+ // built-in local preview. This is a warm-up, not a dependency: a failure
3523
+ // just leaves the conservative behaviour in place.
3524
+ try { ensureMirrors() } catch { /* the registry is best-effort */ }
3353
3525
  inner.effect(() => tabs.register({
3354
3526
  id: NATIVE_EXPLORER, kind: NATIVE_EXPLORER,
3355
3527
  title: () => tr('explorer.tabTitle'),
@@ -3360,11 +3532,59 @@ window.__ModuleLoader__.load({
3360
3532
  patterns: ['dsh-resource://dsh-remote/**'],
3361
3533
  title: (address) => remoteFilePath(address).split(/[\\/]/).pop() || tr('explorer.tabTitle'),
3362
3534
  }), 'dsh-remote.native-file-type')
3535
+ // Issue #55. No `priority` is declared, so this registers in the
3536
+ // `extension` band, which by contract outranks every viewer the product
3537
+ // ships — including the built-in text preview that currently claims
3538
+ // `dsh-resource://file/**` in the `fallback` band and reads locally.
3539
+ // `canOpen` is the whole safety story: it must return true ONLY for a
3540
+ // session whose cwd is PROVEN to be a remote mirror. Local sessions, and
3541
+ // a page load before the mirror registry has arrived, fall through to the
3542
+ // built-in preview and behave exactly as they did before.
3543
+ //
3544
+ // Registration is wrapped because the address-claiming half of this
3545
+ // contract (`patterns` + `canOpen`) is not something every DSH line
3546
+ // necessarily has. The plugin supports both the 0.1.x and 0.2.x lines, and
3547
+ // on a build whose tab registry cannot express an address claim the right
3548
+ // outcome is "file links keep working the old way" — NOT a throw that
3549
+ // would take the explorer and file types down with it.
3550
+ inner.effect(() => {
3551
+ try {
3552
+ const dispose = tabs.register({
3553
+ id: NATIVE_LINK, kind: NATIVE_LINK,
3554
+ patterns: ['dsh-resource://file/**'],
3555
+ canOpen: (address) => {
3556
+ const parsed = parseCoreFileAddress(address)
3557
+ if (!parsed) return false
3558
+ const cwd = sessionCwdFor(parsed.sessionId)
3559
+ // `MIRRORS.loaded` is checked so a cold start NEVER steals a local
3560
+ // link: an empty registry cannot prove a mirror, and guessing would
3561
+ // break the text preview for every local session.
3562
+ if (!cwd || !MIRRORS.loaded) return false
3563
+ return !!mirrorForCwd(cwd, MIRRORS.list)
3564
+ },
3565
+ title: (address) => {
3566
+ const parsed = parseCoreFileAddress(address)
3567
+ return ((parsed && parsed.path) || '').split(/[\\/]/).pop() || tr('explorer.tabTitle')
3568
+ },
3569
+ })
3570
+ linkTypeAccepted = true
3571
+ return () => { linkTypeAccepted = false; if (typeof dispose === 'function') dispose() }
3572
+ } catch (err) {
3573
+ try { console.warn('dsh-remote: remote file links are unavailable on this DSH build', err) } catch { /* no console */ }
3574
+ return undefined
3575
+ }
3576
+ }, 'dsh-remote.native-link-type')
3363
3577
  inner.effect(() => slots.inject('sidebar.right.pane.tab', () => {
3364
3578
  const disposers = [
3365
3579
  slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_EXPLORER }, NativeExplorer),
3366
3580
  slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_FILE }, NativeFile),
3367
3581
  ]
3582
+ // Only register the link body when its type was accepted: a body under a
3583
+ // kind nothing claims would never render, and on a build that rejected
3584
+ // the type the seat may reject the body too.
3585
+ if (linkTypeAccepted) {
3586
+ disposers.push(slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_LINK }, NativeRemoteLink))
3587
+ }
3368
3588
  return () => disposers.forEach((dispose) => dispose())
3369
3589
  }), 'dsh-remote.native-bodies')
3370
3590
  }
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
- constructor(pool, { file } = {}) {
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
- client.forwardOut('127.0.0.1', 0, d.targetHost, d.targetPort, (err, channel) => {
139
- if (err) { socket.destroy(); return }
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: poolForMachine(identityOf(machine)),
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,42 @@ 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 >= 1.16, `Client.forwardOut`) — 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
+ * This guards the CALLER's side of the contract rather than assuming one
44
+ * backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
45
+ * already converts its own spawn failure into a callback error, so for it the
46
+ * try/catch below is dead code — but a backend is free to throw, and the one
47
+ * that does must not be able to take the process down.
48
+ *
49
+ * @param {object} client - an ssh2 Client or OpenSshConnection.
50
+ * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
51
+ * @returns {Promise<object>} the forwarded channel / duplex stream.
52
+ */
53
+ export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
54
+ return new Promise((resolve, reject) => {
55
+ // `reject` is called at most once: a callback that fires after a successful
56
+ // throw-path rejection would be a no-op, which Promise guarantees.
57
+ try {
58
+ client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
59
+ } catch (err) {
60
+ reject(err)
61
+ }
62
+ })
63
+ }
64
+
29
65
  /**
30
66
  * The ACTIVE pool: one long-lived object whose concrete implementation can be
31
67
  * swapped when the current machine's transport backend changes.
@@ -262,9 +298,11 @@ export class SshPool {
262
298
  }, { knownHostsFile: this._knownHostsFile })
263
299
  const pclient = await this.proxyPool.connect()
264
300
  if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
265
- sock = await new Promise((res, rej) => {
266
- pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
267
- })
301
+ try {
302
+ sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
303
+ } catch (e) {
304
+ throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
305
+ }
268
306
  } catch (err) {
269
307
  return fail(err)
270
308
  }
package/lib/web-attach.js CHANGED
@@ -222,14 +222,34 @@ 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
- this.localPortStart = opts.localPortStart || 3088
229
- this.localPortMax = opts.localPortMax || 50
231
+ // `??`, not `||`: port 0 is a MEANINGFUL value here (let the OS pick), and
232
+ // `0 || 3088` silently turned "let the OS choose" into a bind on a fixed
233
+ // port. An explicit 0 now survives, which is also what lets a test ask for a
234
+ // port without risking a collision with something really listening.
235
+ this.localPortStart = opts.localPortStart ?? 3088
236
+ this.localPortMax = opts.localPortMax ?? 50
237
+ /** Whether the pool's lifecycle hooks are ours to install. */
238
+ this.trackPool = opts.trackPool !== false
230
239
  /** @type {import('node:net').Server|null} */ this.server = null
231
240
  /** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
232
241
  /** @type {object|null} */ this.client = null
242
+ /** The pool this session borrowed its connection from (may be null). */
243
+ this.pool = null
244
+ /** Pool hooks we installed, so close() can hand them back untouched. */
245
+ this.prevOnReady = null
246
+ this.prevOnCloseHook = null
247
+ this.hookedPool = null
248
+ this._installedOnReady = null
249
+ this._installedOnClose = null
250
+ this.attachedClient = null
251
+ /** Bound listener, kept so it can be removed from the client. */
252
+ this.onClientClose = null
233
253
  this.localPort = 0
234
254
  this.remotePort = 0
235
255
  this.token = ''
@@ -239,6 +259,28 @@ export class WebAttach {
239
259
  this.machineId = ''
240
260
  this.command = ''
241
261
  this.startedAt = 0
262
+ /** Launch knobs remembered so a restart after a drop uses the same ones. */
263
+ this.waitSeconds = 0
264
+ this.omittedNoOpen = false
265
+ /** True while a reconnect-triggered re-resolve is in flight. */
266
+ this._resolving = false
267
+ /** True once the borrowed connection has been lost (drives recovery). */
268
+ this.connectionLost = false
269
+ /** undefined = "not asked yet"; false = the remote process is provably gone. */
270
+ this._remoteAlive = undefined
271
+ /** Set by close(): nothing may re-arm the tunnel afterwards. */
272
+ this.closed = false
273
+ /** Set once the borrowed connection has gone away (see _clientLost). */
274
+ this.lastError = ''
275
+ /**
276
+ * A listener that throws EARNS an 'error' event on the emitting object, and
277
+ * an 'error' with no listener is itself fatal — the very crash this module
278
+ * exists to prevent. Nothing here is expected to throw, but a lifecycle
279
+ * callback running on someone else's event loop must never be the reason
280
+ * the host process dies, so both hooks are wrapped.
281
+ */
282
+ this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
283
+ this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
242
284
  }
243
285
 
244
286
  /** Whether the tunnel is currently listening. */
@@ -268,11 +310,17 @@ export class WebAttach {
268
310
  if (!pool) throw new Error('web-attach: a machine pool is required')
269
311
  this.machineId = String(machineId || '')
270
312
  this.command = command
313
+ this.waitSeconds = Number(waitSeconds) || 45
271
314
 
272
315
  const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
273
316
  // Connecting first also surfaces credential/host-key problems BEFORE we
274
- // start a remote process we would then have to clean up.
275
- this.client = await pool.connect()
317
+ // start a remote process we would then have to clean up. `_attach` (rather
318
+ // than a bare assignment) is what subscribes to the connection's 'close',
319
+ // so the loss is noticed the moment it happens instead of on the next
320
+ // request — which is when the old code let ssh2 throw 'Not connected' from
321
+ // inside a socket callback and take the host process with it (issue #54).
322
+ this._attach(await pool.connect())
323
+ this._followPool(pool)
276
324
 
277
325
  if (existingUrl) {
278
326
  const parsed = parseWebStart(`dsh web: ${existingUrl}`)
@@ -398,24 +446,334 @@ export class WebAttach {
398
446
  })
399
447
  }
400
448
 
449
+ /**
450
+ * Borrow the given connection AND subscribe to its lifecycle.
451
+ *
452
+ * Idempotent for the same client (open() calls it once, the pool's `onReady`
453
+ * calls it again on every reconnect), and it always drops the previous
454
+ * client's listener first, so one session never accumulates listeners.
455
+ *
456
+ * @param {object|null} client - the live ssh2 client / OpenSshConnection.
457
+ */
458
+ _attach(client) {
459
+ if (!client) return
460
+ if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
461
+ // A different client replaced the one we borrowed (pool reconnect). Stop
462
+ // listening to the old one; its own 'close' has either fired already or
463
+ // belongs to a connection nobody uses any more.
464
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
465
+ }
466
+ this.client = client
467
+ if (this.attachedClient === client) return
468
+ this.attachedClient = client
469
+ this.onClientClose = () => { this._clientLost('ssh connection closed') }
470
+ // Without this, the object keeps handing out a connection that is already
471
+ // closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
472
+ try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
473
+ }
474
+
475
+ /** The borrowed connection is gone: keep the session, drop the dead handle. */
476
+ _clientLost(reason) {
477
+ this.client = null
478
+ this.connectionLost = true
479
+ this.lastError = String(reason || 'ssh connection closed')
480
+ }
481
+
482
+ /**
483
+ * Register lifecycle hooks on the chosen pool so this tunnel follows it.
484
+ *
485
+ * The pool is the one place that both NOTICES a dead connection and later
486
+ * obtains a fresh one, and it exposes exactly two hooks for that:
487
+ * • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
488
+ * the "channel open failed, the pooled connection died server-side"
489
+ * self-heal path, which is the likeliest trigger);
490
+ * • `onReady` — fires after a successful (re)connect.
491
+ *
492
+ * Both are SINGLE-SLOT properties that the plugin already uses for
493
+ * `ForwardManager` (index.js), so this session CHAINS them rather than
494
+ * clobbering the forwards manager, and `_detachPool()` restores exactly what
495
+ * was there — the plugin may legitimately have none, one, or both.
496
+ *
497
+ * A real reconnect is the only way the remote port can move, so once the pool
498
+ * reports a new connection we must re-resolve (token, port) from the remote
499
+ * log. `onReady` itself cannot await: the pool fires it with the client in
500
+ * hand, so the re-resolve is kicked off and its failure is recorded rather
501
+ * than thrown into the pool's callback.
502
+ *
503
+ * @param {object} pool - the pool this session attached through.
504
+ */
505
+ _followPool(pool) {
506
+ if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
507
+ this._detachPool()
508
+ this.pool = pool
509
+ this.hookedPool = pool
510
+ const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
511
+ const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
512
+ this.prevOnReady = prevReady
513
+ this.prevOnCloseHook = prevClose
514
+ const installedReady = (client) => {
515
+ this._attach(client)
516
+ if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
517
+ // A reconnect lands on a NEW remote process only if the old one died with
518
+ // the machine; re-read the log to find out which. The re-resolve runs off
519
+ // the event loop because the pool cannot await it — a hook that throws
520
+ // would earn an 'error' event on the pool, and an unhandled rejection
521
+ // would be fatal.
522
+ if (!this.closed && this.remotePort && !this._resolving) {
523
+ this._resolving = true
524
+ Promise.resolve()
525
+ .then(() => this._refreshRemotePort())
526
+ .catch((err) => { this.lastError = String((err && err.message) || err) })
527
+ .then(() => { this._resolving = false })
528
+ }
529
+ }
530
+ const installedClose = () => {
531
+ this._clientLost('ssh connection closed')
532
+ if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
533
+ }
534
+ this._installedOnReady = installedReady
535
+ this._installedOnClose = installedClose
536
+ pool.onReady = installedReady
537
+ pool.onCloseHook = installedClose
538
+ }
539
+
540
+ /**
541
+ * Hand the pool's hooks back.
542
+ *
543
+ * Each slot is restored ONLY when it still holds the wrapper this session
544
+ * installed. The pool's hooks are single-slot, so if something else has taken
545
+ * the slot over in the meantime, putting a stale callback back would be worse
546
+ * than leaving it alone — it would silently disconnect a newer owner.
547
+ */
548
+ _detachPool() {
549
+ const pool = this.hookedPool
550
+ const mineReady = this._installedOnReady
551
+ const mineClose = this._installedOnClose
552
+ this.hookedPool = null
553
+ this._installedOnReady = null
554
+ this._installedOnClose = null
555
+ if (!pool) return
556
+ if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
557
+ if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
558
+ this.prevOnReady = null
559
+ this.prevOnCloseHook = null
560
+ }
561
+
562
+ /**
563
+ * True when the borrowed connection was LOST and this is a session we can
564
+ * recover: we launched the remote process ourselves (so we hold its PID and
565
+ * log) and the tunnel is listening (so clients can actually be served).
566
+ *
567
+ * The `connectionLost` flag is deliberately separate from `this.client`: the
568
+ * pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
569
+ * check on `!this.client` would be false by then and recovery would never
570
+ * happen.
571
+ */
572
+ get _needsReResolve() {
573
+ return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
574
+ }
575
+
576
+ /**
577
+ * Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
578
+ * when it did not survive.
579
+ *
580
+ * Reaching a remote process needs a live SSH connection, so this reconnects
581
+ * through the pool first — the pool reports `onReady` for that same client in
582
+ * production, and in a test double the callback simply never fires.
583
+ */
584
+ async _refreshRemotePort() {
585
+ if (!this._needsReResolve) return
586
+ const pool = this.pool
587
+ if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
588
+ const exec = (cmd, opts) => (typeof pool.exec === 'function'
589
+ ? pool.exec(cmd, opts)
590
+ : Promise.reject(new Error('web-attach: the pool cannot run commands')))
591
+ if (!this.client) await pool.connect()
592
+ const match = await this._resolveRemotePort(exec)
593
+ this.remotePort = match.port
594
+ if (match.token) this.token = match.token
595
+ this.connectionLost = false
596
+ this.lastError = ''
597
+ }
598
+
599
+ /**
600
+ * Read this session's remote log and decide which port is live.
601
+ *
602
+ * The startup line is the ONLY source of the port (the launch deliberately
603
+ * uses `--port 0`), so after the remote process died and was restarted the log
604
+ * holds BOTH lines — the dead one and the new one — and neither position
605
+ * identifies the live one. Evidence decides:
606
+ * • a process that is still alive on the remote is probed first, and when
607
+ * the port it is LISTENING on is one of the logged ports, that is the
608
+ * answer (the `dsh web: …` line names its own port, so this is a
609
+ * confirmation rather than a second guess);
610
+ * • otherwise the newest logged port wins, which is right in every
611
+ * single-process case and is the best available answer while the remote is
612
+ * still booting (the next reconnect retries).
613
+ *
614
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
615
+ * @returns {Promise<{port: number, token: string, restarted: boolean}>}
616
+ */
617
+ async _resolveRemotePort(exec) {
618
+ this._remoteAlive = await this._remoteProcessAlive(exec)
619
+ if (!this._remoteAlive) return this._restartRemote(exec)
620
+
621
+ let out = ''
622
+ try {
623
+ const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
624
+ out = String((res && res.stdout) || '')
625
+ } catch { /* an unreadable log: the listening probe below is the fallback */ }
626
+ const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
627
+ .map((m) => parseWebStart(`dsh web: ${m[1]}`))
628
+ .filter(Boolean)
629
+
630
+ // What the recorded process actually listens on beats any guess from the
631
+ // log, because a killed-and-restarted remote leaves BOTH startup lines in
632
+ // the log and neither position says which one is live.
633
+ const listening = await this._listeningRemotePort(exec)
634
+ if (listening) {
635
+ const match = lines.find((l) => l.port === listening)
636
+ return { port: listening, token: (match && match.token) || this.token, restarted: false }
637
+ }
638
+ if (lines.length) {
639
+ const newest = lines[lines.length - 1]
640
+ return { port: newest.port, token: newest.token, restarted: false }
641
+ }
642
+ // Alive but silent and not listening yet: the remote is booting. Keep the
643
+ // last known port and let the next reconnect re-resolve.
644
+ return { port: this.remotePort, token: this.token, restarted: false }
645
+ }
646
+
647
+ /**
648
+ * Whether the remote process THIS session started still exists.
649
+ *
650
+ * Verified through `/proc`, which is Linux-only; a host that cannot answer (or
651
+ * a failed probe) is treated as ALIVE on purpose. Restarting a server that was
652
+ * never dead would spawn a second one and leak a port, whereas assuming life
653
+ * merely retries a port that turns out to be stale.
654
+ *
655
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
656
+ * @returns {Promise<boolean>}
657
+ */
658
+ async _remoteProcessAlive(exec) {
659
+ const pid = Number(this.remotePid) || 0
660
+ if (pid < 1) return true
661
+ try {
662
+ const res = await exec(
663
+ `if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
664
+ { timeoutMs: 15000 },
665
+ )
666
+ return !/GONE/.test(String((res && res.stdout) || ''))
667
+ } catch { return true }
668
+ }
669
+
670
+ /**
671
+ * Ask the remote which loopback port the recorded PID listens on (0 = none).
672
+ *
673
+ * Two `/proc` reads are needed because a TCP listener appears in a socket
674
+ * table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
675
+ * symlinks give the socket INODE, and the table maps that inode to a port.
676
+ * Matching on the inode rather than the port is what makes this an
677
+ * observation instead of a second guess.
678
+ */
679
+ async _listeningRemotePort(exec) {
680
+ const pid = Number(this.remotePid) || 0
681
+ if (pid < 1) return 0
682
+ let inodes = ''
683
+ try {
684
+ // `ls -l` on a process's own fds needs no privileges for the same uid.
685
+ const res = await exec(
686
+ `ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
687
+ { timeoutMs: 15000 },
688
+ )
689
+ inodes = String((res && res.stdout) || '').trim()
690
+ } catch { return 0 }
691
+ if (!inodes) return 0
692
+ const want = new Set(inodes.split(/\s+/).filter(Boolean))
693
+ // `/proc/net/tcp` addresses are little-endian hex, so loopback is
694
+ // `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
695
+ const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
696
+ for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
697
+ let text = ''
698
+ try {
699
+ const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
700
+ text = String((res && res.stdout) || '')
701
+ } catch { continue }
702
+ for (const line of text.split('\n').slice(1)) {
703
+ const cols = line.trim().split(/\s+/)
704
+ // sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
705
+ if (cols.length < 10) continue
706
+ if (cols[3] !== '0A') continue // TCP_LISTEN
707
+ if (!want.has(cols[9])) continue
708
+ const [addr, portHex] = String(cols[1]).split(':')
709
+ if (!LOOPBACK.has(String(addr).toUpperCase())) continue
710
+ const port = parseInt(portHex, 16)
711
+ if (Number.isInteger(port) && port > 0 && port < 65536) return port
712
+ }
713
+ }
714
+ return 0
715
+ }
716
+
717
+ /**
718
+ * Relaunch the remote `dsh web` because the previous process is gone.
719
+ *
720
+ * A restart CANNOT change the listening port locally, and a caller holding a
721
+ * stale port silently talks to the wrong place at worst and hangs at best, so
722
+ * the two facts the caller needs are checked: this is a session that launched
723
+ * its own process (an attach onto someone else's DSH stays hands-off and can
724
+ * never restart it), and recovery is possible at all (`_refreshRemotePort`
725
+ * has already returned early when it is not).
726
+ */
727
+ async _restartRemote(exec) {
728
+ const command = this.command || 'dsh'
729
+ const waitSeconds = this.waitSeconds || 45
730
+ const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
731
+ let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
732
+ if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
733
+ out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
734
+ this.omittedNoOpen = true
735
+ }
736
+ this.remotePid = parseRemotePid(out) || this.remotePid
737
+ const parsed = parseWebStart(out)
738
+ if (!parsed) {
739
+ const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
740
+ throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
741
+ + ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
742
+ }
743
+ return { port: parsed.port, token: parsed.token, restarted: true }
744
+ }
745
+
401
746
  /** Carry one local socket to the remote web port over SSH. */
402
747
  _pipe(socket) {
403
748
  if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
404
- this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
405
- if (err) { try { socket.destroy() } catch { /* already gone */ } return }
406
- this.sockets.add(socket)
407
- socket.pipe(channel)
408
- channel.pipe(socket)
409
- const kill = () => {
410
- try { socket.destroy() } catch { /* already gone */ }
411
- try { channel.close() } catch { /* already closed */ }
412
- this.sockets.delete(socket)
413
- }
414
- socket.on('error', kill)
415
- channel.on('error', kill)
416
- socket.on('close', kill)
417
- channel.on('close', kill)
418
- })
749
+ // ssh2's forwardOut does NOT report a dead connection through its callback:
750
+ // when the socket is gone it THROWS 'Not connected' synchronously
751
+ // (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
752
+ // and the callback below is never reached — so `if (err)` cannot guard this
753
+ // path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
754
+ // the whole host process (issue #54: every page refresh after the pooled
755
+ // connection dropped took the session, the browser and the remote orphan
756
+ // down with it). A dead tunnel is a UI state, not a reason to exit.
757
+ try {
758
+ this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
759
+ if (err) { try { socket.destroy() } catch { /* already gone */ } return }
760
+ this.sockets.add(socket)
761
+ socket.pipe(channel)
762
+ channel.pipe(socket)
763
+ const kill = () => {
764
+ try { socket.destroy() } catch { /* already gone */ }
765
+ try { channel.close() } catch { /* already closed */ }
766
+ this.sockets.delete(socket)
767
+ }
768
+ socket.on('error', kill)
769
+ channel.on('error', kill)
770
+ socket.on('close', kill)
771
+ channel.on('close', kill)
772
+ })
773
+ } catch (err) {
774
+ this._clientLost(String((err && err.message) || err))
775
+ try { socket.destroy() } catch { /* already gone */ }
776
+ }
419
777
  }
420
778
 
421
779
  /**
@@ -462,6 +820,15 @@ export class WebAttach {
462
820
  * @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
463
821
  */
464
822
  async close({ stopRemote = false, exec } = {}) {
823
+ // Stop following the pool BEFORE any await: a reconnect landing mid-teardown
824
+ // must not re-arm the tunnel this call is in the middle of closing.
825
+ this._detachPool()
826
+ if (this.attachedClient && this.onClientClose) {
827
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
828
+ }
829
+ this.attachedClient = null
830
+ this.onClientClose = null
831
+ this.pool = null
465
832
  for (const socket of [...this.sockets]) {
466
833
  try { socket.destroy() } catch { /* already gone */ }
467
834
  }
@@ -475,5 +842,9 @@ export class WebAttach {
475
842
  await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
476
843
  }
477
844
  this.client = null
845
+ // The recorded PID and log path are FACTS about this session (the UI shows
846
+ // them, and `stopRemote` addresses them), so they survive; `closed` is what
847
+ // stops recovery work from being re-armed after teardown.
848
+ this.closed = true
478
849
  }
479
850
  }
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
- const canAutoInstall = !!f.node && !!f.npm && (!f.windows || !!f.gitBash)
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.44",
3
+ "version": "0.8.46",
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>",
@@ -84,6 +84,6 @@
84
84
  "semver": "^7.8.5"
85
85
  },
86
86
  "scripts": {
87
- "test": "node --import ./test/setup-telemetry-off.mjs --test"
87
+ "test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=20000 --test-force-exit"
88
88
  }
89
89
  }