@zhangfengshun/dsh-remote-ssh 2.3.9 → 2.4.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.1] — 设置页「远程连接」选项卡改用真实 SVG 显示器图标
6
+ ### 变更
7
+ - **去掉标签文本里的 🖥️ emoji**:`settings.nav` 标签改为纯文本「远程连接 / Remote Connections」,不再以表情文字充当图标。
8
+ - **替换为真实 SVG 图标**:壳层对未知 `settings.section` 一律渲染齿轮兜底,且插槽契约暂不接受自定义图标(壳层注释:Desktop-owned display glyph kept local until the upstream slot accepts icons)。沿既有 DOM 方案把兜底齿轮原位替换为**桌面显示器轮廓图标**(圆角屏幕 + 支架 + 底座,16×16,`currentColor` 跟随主题,与 DSH 原语线稿风格一致)——与 🖥️ 语义最接近的真实图标。
9
+ - `replaceChild` 后 React 仅对已脱离文档的旧齿轮节点做属性更新,无对账冲突;设置面板按需挂载、语言切换、React 重渲染由 MutationObserver + 1s 幂等复检(`data-rssh-monitor` 判据)兜底。
10
+
11
+ ## [2.4.0] — 命令级超时 + `remote_ssh_kill` 兜底恢复(issue #5)
12
+ ### 新增
13
+ - **命令级超时**:一条挂起的远端命令(网络卡顿 / 远端进程僵死 / 等待 stdin 的 `cat`)此前会永久占用池化会话并阻塞其后所有命令,工具调用永不返回。现在所有 SSH 命令默认 **120 秒**超时:
14
+ - 环境变量 `DSH_REMOTE_SSH_CMD_TIMEOUT_MS` 覆盖全局默认,`0` 禁用;
15
+ - `remote_ssh_exec` 新增可选参数 `timeoutMs`(长时构建/训练显式放宽预算,`0` 禁用);
16
+ - 池化会话超时即**整体丢弃并自动重建**(挂起的命令仍占着共享 bash 进程,唯一可靠恢复是终止 SSH 重建),后续命令不受影响;
17
+ - 一次性连接(`runRemote`)超时即 `handle.terminate()`,返回带 `isTimeout` 标记的错误;
18
+ - 超时命令**刻意不做回退重试**(重试一条挂起的命令只会再次挂起)。
19
+ - **新增工具 `remote_ssh_kill`**:强制关闭某个连接的池化会话,或 `all: true` 关闭全部会话,返回 `{ ok, killed, active, message }`——agent 从此拥有挂起命令的兜底恢复手段,不再只能重启 DSH。
20
+ - 工具输出(exec 系列)新增 `isTimeout` 字段,模型可直接识别超时结果并决定恢复策略。
21
+
22
+ ### 兼容性验证
23
+ - 逐点验证 **dsh-better-sidebar 0.19.0**(`fs.tree/read/write/search` 端点、`/sidebar/api` 挂载、`config.shell` 终端覆盖、client `betterSidebar` 服务与 `registerTab`、`/sidebar/upload` 上传拦截)与 **DSH 0.1.5-rc.1**(主机服务、`defineTool`/`settings.register`、UI slot、`workspaceRegistry`)全部咬合,无需代码改动。
24
+
5
25
  ## [2.3.9] — 修复 git-bash 启动导致的密钥认证失败(ssh 解析钉定到系统 OpenSSH)
6
26
  ### 修复
7
27
  - **Windows 下 ssh 可执行文件优先解析为系统自带 OpenSSH 的绝对路径**(`%SystemRoot%\System32\OpenSSH\ssh.exe`,存在才使用,否则回落 PATH):此前从 **git-bash** 启动 `dsh web` 时,子进程 PATH 里 Git 自带的 MSYS2 ssh 排在系统 OpenSSH 之前,插件实际调用 Git 的 ssh——其 HOME/config/agent 语义与系统 OpenSSH 不同,导致"终端能连、测试连接 Permission denied"。ssh 主机侧忽略用户 authorized_keys 的 HPC 集中授权环境同样受此影响。
package/README.md CHANGED
@@ -12,12 +12,12 @@
12
12
  | 📂 远程文件 | 内置「文件」页签直接 SSH 读写远程文件,无需同步 |
13
13
  | 💻 远程终端 | 内置「终端」页签自动检测远程工作区,SSH 交互式终端 |
14
14
  | 🌐 远程工作区 | 选择远程目录创建原生工作区,一键进入远程环境 |
15
- | 🤖 模型工具 | 12 个 `remote_ssh_*` 工具,会话感知免填连接参数 |
15
+ | 🤖 模型工具 | 13 个 `remote_ssh_*` 工具,会话感知免填连接参数;命令级超时 + `remote_ssh_kill` 兜底恢复 |
16
16
  | ⚡ 打开提速 | 单往返合并读 + raw 文本快路径 + 结果缓存(LRU + 5s TTL):首开 ≈**1.31×**,TTL 内重复打开 **0 往返**,过期复验 **≈5×**(真实超算实测);`remote_ssh_exec` 连接复用 **≈15×** |
17
17
 
18
18
  ## 截图
19
19
 
20
- **设置 → 🖥️ 远程连接**:连接配置(密钥 / 密码 / ProxyJump 跳板机)· 连接测试 · 从 `~/.ssh/config` 一键导入
20
+ **设置 → 远程连接**:连接配置(密钥 / 密码 / ProxyJump 跳板机)· 连接测试 · 从 `~/.ssh/config` 一键导入
21
21
 
22
22
  <p align="center"><img src="assets/settings-remote-connections.png" width="420" alt="设置:远程连接"></p>
23
23
 
@@ -32,16 +32,16 @@
32
32
  ## 安装
33
33
 
34
34
  ```bash
35
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
35
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.1
36
36
  ```
37
37
 
38
38
  > 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
39
39
  >
40
- > 内置「文件」页签的 SSH 直读依赖 **dsh-better-sidebar ≥ 0.15** 的文件 API(`/sidebar/api/fs.*` 端点),请勿使用更早版本;当前已逐点验证至 **dsh-better-sidebar 0.18.0**。
40
+ > 内置「文件」页签的 SSH 直读依赖 **dsh-better-sidebar ≥ 0.15** 的文件 API(`/sidebar/api/fs.*` 端点),请勿使用更早版本;当前已逐点验证至 **dsh-better-sidebar 0.19.0** 与 **DSH 0.1.5-rc.1**(主机服务 / settings / tools / slot / 上传下载拦截全部咬合)。
41
41
 
42
42
  ## 使用
43
43
 
44
- 1. **设置 → 🖥️ 远程连接** → 添加连接(主机/端口/用户/密钥)→ 点「测试连接」验证
44
+ 1. **设置 → 远程连接** → 添加连接(主机/端口/用户/密钥)→ 点「测试连接」验证
45
45
  2. **添加工作区** → 选「选择远程目录…」→ 选连接 → 浏览并选择远程目录
46
46
  3. 打开内置「文件」页签 → 直接显示远程文件,编辑保存直接写回远程
47
47
  4. 打开内置「终端」页签 → 自动 SSH 到远程主机(仅密钥认证)
@@ -51,7 +51,8 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
51
51
  | 工具 | 用途 |
52
52
  | --- | --- |
53
53
  | `remote_ssh_profiles` | 列出连接配置 + 当前会话远程工作区上下文 |
54
- | `remote_ssh_exec` | 执行远程命令 |
54
+ | `remote_ssh_exec` | 执行远程命令(默认 120s 命令级超时,`timeoutMs` 可放宽/禁用) |
55
+ | `remote_ssh_kill` | 强制关闭池化 SSH 会话(挂起命令的兜底恢复) |
55
56
  | `remote_ssh_ls` | 列举远程目录 |
56
57
  | `remote_ssh_cat` | 读取远程文件 |
57
58
  | `remote_ssh_write` | 写入远程文件 |
@@ -65,6 +66,15 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
65
66
 
66
67
  远程工作区会话中调用工具可免填 `profileId` 等连接参数;全部文件/命令类工具走持久 SSH 会话池 + 结果缓存,`remote_ssh_exec` 单命令实测 ≈15× 提速。
67
68
 
69
+ ## 命令超时与恢复
70
+
71
+ 所有 SSH 命令默认 **120 秒**超时(issue #5):一条挂起的远端命令(网络卡顿、远端进程僵死、等待 stdin 的 `cat`)不会再永久占用会话、拖死后续命令。
72
+
73
+ - **超时后自动恢复**:池化会话超时即被丢弃并自动重建,后续命令照常执行;一次性连接超时即终止 SSH 进程;
74
+ - **显式放宽**:`remote_ssh_exec` 传 `timeoutMs`(毫秒)覆盖单次预算,`0` 禁用超时(长时构建/训练);环境变量 `DSH_REMOTE_SSH_CMD_TIMEOUT_MS` 覆盖全局默认;
75
+ - **手动兜底**:`remote_ssh_kill`(或 `all: true`)强制关闭某个/全部池化会话,挂起命令随时可清理;
76
+ - 超时命令**不做自动重试**(重试一条挂起的命令只会再次挂起),由模型决定是否改用 `remote_ssh_kill` 或换命令重试。
77
+
68
78
  ## 原理
69
79
 
70
80
  插件注册 4 个 exact 路由(`/sidebar/api/fs.tree`、`fs.read`、`fs.write`、`fs.search`),在 better-sidebar 的 prefix 路由之前拦截。会话 cwd 含 `.remote-ssh.json` 时走 SSH,否则走本地 fs。客户端看到的是本地镜像路径,Host 自动转换为远程路径——对客户端完全透明。
package/README_EN.md CHANGED
@@ -12,12 +12,12 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
12
12
  | 📂 Remote Files | Built-in **Files** tab reads/writes remote files directly via SSH — no sync needed |
13
13
  | 💻 Remote Terminal | Built-in **Terminal** tab auto-detects remote workspaces, opens SSH interactive shell |
14
14
  | 🌐 Remote Workspace | Select a remote directory to create a native workspace, one-click enter |
15
- | 🤖 Model Tools | 12 `remote_ssh_*` tools, session-aware with auto-filled connection params |
15
+ | 🤖 Model Tools | 13 `remote_ssh_*` tools, session-aware with auto-filled connection params; command-level timeout + `remote_ssh_kill` recovery |
16
16
  | ⚡ Faster Opens | Single-roundtrip merged reads + raw text fast path + result cache (LRU + 5s TTL): first open ≈**1.31×**, repeat opens within TTL **0 round-trips**, expired revalidation **≈5×** (measured on a real HPC); `remote_ssh_exec` connection reuse **≈15×** |
17
17
 
18
18
  ## Screenshots
19
19
 
20
- **Settings → 🖥️ Remote SSH**: connection profiles (key / password / ProxyJump bastion) · connection test · one-click import from `~/.ssh/config`
20
+ **Settings → Remote SSH**: connection profiles (key / password / ProxyJump bastion) · connection test · one-click import from `~/.ssh/config`
21
21
 
22
22
  <p align="center"><img src="assets/settings-remote-connections.png" width="420" alt="Settings: Remote Connections"></p>
23
23
 
@@ -32,16 +32,16 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
32
32
  ## Installation
33
33
 
34
34
  ```bash
35
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
35
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.1
36
36
  ```
37
37
 
38
38
  > **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
39
39
  >
40
- > The built-in **Files** tab SSH interception relies on the file API of **dsh-better-sidebar ≥ 0.15** (`/sidebar/api/fs.*` endpoints) — do not use older versions; verified point-by-point against **dsh-better-sidebar 0.18.0**.
40
+ > The built-in **Files** tab SSH interception relies on the file API of **dsh-better-sidebar ≥ 0.15** (`/sidebar/api/fs.*` endpoints) — do not use older versions; verified point-by-point against **dsh-better-sidebar 0.19.0** and **DSH 0.1.5-rc.1** (host services / settings / tools / slots / upload & download interception all compatible).
41
41
 
42
42
  ## Usage
43
43
 
44
- 1. **Settings → 🖥️ Remote SSH** → Add a connection (host/port/user/key) → Click "Test Connection"
44
+ 1. **Settings → Remote SSH** → Add a connection (host/port/user/key) → Click "Test Connection"
45
45
  2. **Add Workspace** → Choose "Select Remote Directory…" → Pick a connection → Browse and select
46
46
  3. Open the built-in **Files** tab → Remote files shown directly, edits save back to remote
47
47
  4. Open the built-in **Terminal** tab → Auto SSH to remote host (key auth only)
@@ -51,7 +51,8 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
51
51
  | Tool | Purpose |
52
52
  | --- | --- |
53
53
  | `remote_ssh_profiles` | List saved connections + current session's remote workspace context |
54
- | `remote_ssh_exec` | Execute remote command |
54
+ | `remote_ssh_exec` | Execute remote command (default 120s command timeout; `timeoutMs` to relax/disable) |
55
+ | `remote_ssh_kill` | Force-close pooled SSH sessions (recovery for hung commands) |
55
56
  | `remote_ssh_ls` | List remote directory |
56
57
  | `remote_ssh_cat` | Read remote file |
57
58
  | `remote_ssh_write` | Write remote file |
@@ -65,6 +66,15 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.5
65
66
 
66
67
  In a remote-workspace session, `profileId` and other connection params can be omitted. All file/command tools run over the persistent SSH session pool + result cache; `remote_ssh_exec` measures ≈15× faster per command.
67
68
 
69
+ ## Command Timeout & Recovery
70
+
71
+ All SSH commands default to a **120-second** timeout (issue #5): a hung remote command (network stall, stuck remote process, `cat` waiting on stdin) can no longer occupy the session forever and block every later command.
72
+
73
+ - **Automatic recovery on timeout**: the pooled session is discarded and rebuilt automatically, so subsequent commands keep working; one-shot connections terminate the SSH process;
74
+ - **Explicit budgets**: `remote_ssh_exec` accepts `timeoutMs` (milliseconds) per call, `0` disables the timeout (long builds/training); the `DSH_REMOTE_SSH_CMD_TIMEOUT_MS` environment variable overrides the global default;
75
+ - **Manual hatch**: `remote_ssh_kill` (or `all: true`) force-closes one or all pooled sessions at any time;
76
+ - Timed-out commands are **never auto-retried** (retrying a hung command just hangs again) — the model decides whether to kill the session or retry differently.
77
+
68
78
  ## How It Works
69
79
 
70
80
  The plugin registers 4 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.write`, `fs.search`) that intercept better-sidebar's prefix route. When the session cwd contains `.remote-ssh.json`, requests go through SSH; otherwise local fs. The client sees local mirror paths — the Host transparently translates them to remote paths.
package/lib/client.js CHANGED
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
62
62
  var zh = {
63
63
  "tabs.files": "远程文件",
64
64
  "tabs.term": "远程终端",
65
- "settings.nav": "🖥️ 远程连接",
65
+ "settings.nav": "远程连接",
66
66
  "settings.desc": "管理 Remote-SSH 的远程连接配置(SSH 密钥或密码认证)。",
67
67
  "settings.empty": "还没有连接配置。请在下方添加。",
68
68
  "settings.add": "添加连接",
@@ -162,7 +162,7 @@ window.__ModuleLoader__.load({
162
162
  var en = {
163
163
  "tabs.files": "Remote Files",
164
164
  "tabs.term": "Remote Terminal",
165
- "settings.nav": "🖥️ Remote Connections",
165
+ "settings.nav": "Remote Connections",
166
166
  "settings.desc": "Manage Remote-SSH connection profiles (SSH key or password authentication).",
167
167
  "settings.empty": "No connection profiles yet. Add one below.",
168
168
  "settings.add": "Add Profile",
@@ -1109,19 +1109,43 @@ window.__ModuleLoader__.load({
1109
1109
  return function () { if (el.parentNode) el.parentNode.removeChild(el); };
1110
1110
  });
1111
1111
 
1112
- // ---- 去掉设置导航「远程连接」条目左侧的默认齿轮图标 ----
1112
+ // ---- 设置导航「远程连接」条目:把壳层默认齿轮换成真实显示器 SVG 图标 ----
1113
1113
  // 壳层对未知 settings.section 一律渲染齿轮图标(IconSettingsOutline16 兜底),
1114
- // 且导航 CSS 类名是构建期哈希(VOzbGW_*、zOa2rq_* 每版都会变),CSS 选择器跨版本
1115
- // 不可靠。这里改为按条目文本(settings.nav)定位设置导航按钮,隐藏其 svg 图标;
1116
- // 设置面板按需挂载、语言会切换,用 MutationObserver + 语言订阅自动重跑。
1114
+ // settings.section 插槽契约暂不接受自定义图标(壳层注释:Desktop-owned display
1115
+ // glyph kept local until the upstream slot accepts icons),导航 CSS 类名是构建期哈希
1116
+ // 每版会变。这里沿既有 DOM 方案:按条目文本(settings.nav)定位设置导航按钮,把
1117
+ // 兜底齿轮 svg 原位替换为显示器图标(与 🖥️ 语义最接近的真实 SVG,非文字表情)。
1118
+ // replaceChild 后 React 只会对已脱离文档的旧齿轮节点做属性更新(无冲突),
1119
+ // 设置面板按需挂载、语言切换、React 重渲染由 MutationObserver + 1s 幂等复检兜底。
1117
1120
  if (slots) {
1121
+ function monitorIconSvg() {
1122
+ var NS = "http://www.w3.org/2000/svg";
1123
+ var svg = document.createElementNS(NS, "svg");
1124
+ svg.setAttribute("viewBox", "0 0 16 16");
1125
+ svg.setAttribute("width", "16");
1126
+ svg.setAttribute("height", "16");
1127
+ svg.setAttribute("fill", "none");
1128
+ svg.setAttribute("data-rssh-monitor", "1");
1129
+ function add(name, attrs) {
1130
+ var n = document.createElementNS(NS, name);
1131
+ for (var k in attrs) n.setAttribute(k, attrs[k]);
1132
+ svg.appendChild(n);
1133
+ }
1134
+ // 桌面显示器:圆角屏幕外框 + 支架 + 底座(轮廓线风格,与 DSH 原语一致)
1135
+ add("rect", { x: "1.5", y: "2.5", width: "13", height: "9", rx: "1", stroke: "currentColor", "stroke-width": "1.3" });
1136
+ add("path", { d: "M8 11.5v3", stroke: "currentColor", "stroke-width": "1.3", "stroke-linecap": "round" });
1137
+ add("path", { d: "M5.5 14.5h5", stroke: "currentColor", "stroke-width": "1.3", "stroke-linecap": "round" });
1138
+ return svg;
1139
+ }
1118
1140
  function checkCell(btn) {
1119
1141
  var label = i18n.t("settings.nav");
1120
1142
  var text = (btn.textContent || "").trim();
1121
1143
  if (text !== label.trim() || text === "") return false;
1122
1144
  var icon = btn.querySelector("svg");
1123
- if (icon) { icon.style.display = "none"; return true; }
1124
- return false;
1145
+ if (!icon) return false;
1146
+ if (icon.getAttribute("data-rssh-monitor") === "1") return true; // 已替换:幂等
1147
+ try { icon.parentNode.replaceChild(monitorIconSvg(), icon); } catch (e) {}
1148
+ return true;
1125
1149
  }
1126
1150
  function checkCellIn(root) {
1127
1151
  try {
@@ -1130,7 +1154,7 @@ window.__ModuleLoader__.load({
1130
1154
  for (var i = 0; i < cells.length; i++) checkCell(cells[i]);
1131
1155
  } catch (e) {}
1132
1156
  }
1133
- function hideRemoteGearIcon() {
1157
+ function applyRemoteNavIcon() {
1134
1158
  try {
1135
1159
  var buttons = document.querySelectorAll("button");
1136
1160
  for (var i = 0; i < buttons.length; i++) checkCell(buttons[i]);
@@ -1225,7 +1249,7 @@ window.__ModuleLoader__.load({
1225
1249
  }
1226
1250
  ctx.effect(function () {
1227
1251
  if (typeof document === "undefined") return;
1228
- hideRemoteGearIcon();
1252
+ applyRemoteNavIcon();
1229
1253
  refreshRemoteTitles();
1230
1254
  var titleTimer = setInterval(refreshRemoteTitles, 30000);
1231
1255
  var recheckTimer = setInterval(recheckWatched, 1000); // 抵消 React 属性还原
@@ -1245,7 +1269,7 @@ window.__ModuleLoader__.load({
1245
1269
  if (roots[i].isConnected) { checkCellIn(roots[i]); swapWorkspaceIconsIn(roots[i]); }
1246
1270
  }
1247
1271
  } else {
1248
- hideRemoteGearIcon(); // 语言切换等无根场景全量扫一次
1272
+ applyRemoteNavIcon(); // 语言切换等无根场景全量扫一次
1249
1273
  swapWorkspaceIconsIn(document.body);
1250
1274
  }
1251
1275
  }, 500);
@@ -1265,7 +1289,7 @@ window.__ModuleLoader__.load({
1265
1289
  clearInterval(titleTimer);
1266
1290
  clearInterval(recheckTimer);
1267
1291
  };
1268
- }, "dsh-remote-ssh: settings gear hide + remote workspace folder-globe icon");
1292
+ }, "dsh-remote-ssh: settings nav monitor icon + remote workspace folder-globe icon");
1269
1293
  }
1270
1294
 
1271
1295
  // better-sidebar 0.15+ 的文件上传 UI 把原始字节流 POST 到 /sidebar/upload。
package/lib/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * (schemastery schema,密码字段 role('secret') 在描述时脱敏)。
7
7
  * 2. 通过 SSH 提供文件列举 / 读取 / 写入 / 执行 / 集成终端(ssh -tt 管道通道)。
8
8
  * 3. 暴露 HTTP JSON API(/remote-ssh/api/*)给 Client 半边。
9
- * 4. 注册 5 个模型工具(remote_ssh_*)。
9
+ * 4. 注册 13 个模型工具(remote_ssh_*),命令级超时 + remote_ssh_kill 兜底恢复。
10
10
  */
11
11
  import z from "schemastery";
12
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -28,6 +28,41 @@ const MAX_BYTES = 4 * 1024 * 1024;
28
28
  const SESSION_MAX_STDOUT = 8 * 1024 * 1024;
29
29
  const API_BASE = "/remote-ssh/api/";
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // 命令级超时(issue #5):一条挂起的远端命令(网络卡顿 / 远端进程僵死 / 等待 stdin)
33
+ // 会永久占用池化会话并阻塞其后所有命令。默认 120 秒超时,可用环境变量
34
+ // DSH_REMOTE_SSH_CMD_TIMEOUT_MS 覆盖全局默认,0 表示禁用(长时任务显式放宽)。
35
+ // ---------------------------------------------------------------------------
36
+ const DEFAULT_CMD_TIMEOUT_MS = (() => {
37
+ const v = parseInt(process.env.DSH_REMOTE_SSH_CMD_TIMEOUT_MS, 10);
38
+ return Number.isFinite(v) && v >= 0 ? v : 120000;
39
+ })();
40
+
41
+ /** 归一化单次调用传入的超时:显式 timeoutMs 优先,其次环境变量默认值。 */
42
+ function resolveTimeoutMs(timeoutMs) {
43
+ if (timeoutMs !== undefined && timeoutMs !== null) {
44
+ const v = parseInt(timeoutMs, 10);
45
+ if (Number.isFinite(v) && v >= 0) return v;
46
+ }
47
+ return DEFAULT_CMD_TIMEOUT_MS;
48
+ }
49
+
50
+ /** 给任意 Promise 加超时:超时时带 isTimeout 标记 reject,并先执行 onTimeout
51
+ * (用于终止进程 / 丢弃会话)。ms<=0 时原样返回(禁用超时)。 */
52
+ function withTimeout(promise, ms, label, onTimeout) {
53
+ if (!ms || ms <= 0) return promise;
54
+ let timer;
55
+ const timed = new Promise(function (_, reject) {
56
+ timer = setTimeout(function () {
57
+ const err = new Error((label || "命令") + " 执行超时(" + ms + "ms),已放弃等待");
58
+ err.isTimeout = true;
59
+ if (onTimeout) { try { onTimeout(); } catch (e) {} }
60
+ reject(err);
61
+ }, ms);
62
+ });
63
+ return Promise.race([promise, timed]).finally(function () { clearTimeout(timer); });
64
+ }
65
+
31
66
  /** 连接配置 schema(存于 settings)。 */
32
67
  const ProfileSchema = z.object({
33
68
  id: z.string(),
@@ -389,8 +424,9 @@ function stripPqBanner(text) {
389
424
  .join("\n");
390
425
  }
391
426
 
392
- async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpts) {
427
+ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpts, timeoutMs) {
393
428
  const max = maxBytes || MAX_BYTES;
429
+ const tms = resolveTimeoutMs(timeoutMs);
394
430
  let handle;
395
431
  try {
396
432
  handle = subprocess.spawn({
@@ -408,8 +444,15 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpt
408
444
  }
409
445
  let outcome;
410
446
  try {
411
- outcome = await handle.done;
447
+ // 命令级超时:挂起的命令会在 tms 后被终止(handle.terminate),不再无限等待。
448
+ // 对超时命令刻意不做重试:重试一条挂起的命令只会再次挂起(issue #5)。
449
+ outcome = await withTimeout(handle.done, tms, "ssh 命令", function () {
450
+ try { handle.terminate(); } catch (e) {}
451
+ });
412
452
  } catch (e) {
453
+ if (e && e.isTimeout) {
454
+ return { ok: false, exitCode: -1, signal: "", stdout: "", stderr: "", truncated: false, isTimeout: true, error: e.message };
455
+ }
413
456
  return { ok: false, error: "执行失败: " + String(e && e.message ? e.message : e) };
414
457
  }
415
458
  const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
@@ -951,7 +994,8 @@ function normExec(r) {
951
994
  stdout: r.stdout || "",
952
995
  stderr: r.stderr || "",
953
996
  error: r.error || "",
954
- truncated: !!r.truncated
997
+ truncated: !!r.truncated,
998
+ isTimeout: !!r.isTimeout
955
999
  };
956
1000
  }
957
1001
 
@@ -1016,7 +1060,8 @@ function execSchema() {
1016
1060
  stdout: { type: "string", required: true },
1017
1061
  stderr: { type: "string", required: true },
1018
1062
  error: { type: "string", required: true },
1019
- truncated: { type: "boolean", required: true }
1063
+ truncated: { type: "boolean", required: true },
1064
+ isTimeout: { type: "boolean", required: true }
1020
1065
  }
1021
1066
  };
1022
1067
  }
@@ -1051,6 +1096,7 @@ function remoteWorkspaceReadme(profileName, remotePath) {
1051
1096
  "- `remote_ssh_sync` — sync remote files to this local mirror / 同步远端文件到本地镜像",
1052
1097
  "- `remote_ssh_push` — push local mirror changes back to remote / 推送本地改动回远端",
1053
1098
  "- `remote_ssh_profiles` — list profiles and the current remote-workspace context / 查看配置与当前上下文",
1099
+ "- `remote_ssh_kill` — force-close pooled SSH sessions (hung-command recovery) / 强制关闭池化 SSH 会话(挂起命令恢复)",
1054
1100
  "",
1055
1101
  "Workspace info / 工作区信息:",
1056
1102
  "",
@@ -1098,8 +1144,11 @@ function apply(ctx, config) {
1098
1144
  }
1099
1145
 
1100
1146
  /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。
1101
- * split=true 时 stderr 与 stdout 分离返回(命令走双哨兵协议)。 */
1102
- async function runPooled(p, cmd, stdinData, maxBytes, split) {
1147
+ * split=true 时 stderr 与 stdout 分离返回(命令走双哨兵协议)。
1148
+ * timeoutMs 为命令级超时(issue #5):超时即丢弃整个池化会话——挂起的远端命令
1149
+ * 仍占着共享 bash 进程,队列后续命令都会被它卡住,唯一可靠恢复是终止 SSH 重建。 */
1150
+ async function runPooled(p, cmd, stdinData, maxBytes, split, timeoutMs) {
1151
+ const tms = resolveTimeoutMs(timeoutMs);
1103
1152
  const dropSession = () => {
1104
1153
  const key = profileKey(p);
1105
1154
  const old = sessions.get(key);
@@ -1107,7 +1156,9 @@ function apply(ctx, config) {
1107
1156
  };
1108
1157
  try {
1109
1158
  const s = getSession(p);
1110
- const r = await s.exec(cmd, stdinData, split);
1159
+ const r = await withTimeout(s.exec(cmd, stdinData, split), tms, "池化命令", function () {
1160
+ dropSession();
1161
+ });
1111
1162
  const stderr = split ? String(r.stderr || "") : "";
1112
1163
  if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: stderr, error: "", truncated: false };
1113
1164
  // exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
@@ -1118,9 +1169,13 @@ function apply(ctx, config) {
1118
1169
  : (errSource.trim().slice(0, 500) || ("ssh 退出码 " + r.exitCode));
1119
1170
  return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: stderr, error: error, truncated: false };
1120
1171
  } catch (e) {
1121
- // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
1122
1172
  dropSession();
1123
- const r2 = await runRemote(subprocess, p, cmd, stdinData, maxBytes);
1173
+ // 超时不走一次性连接回退:重试一条挂起的命令只会再次挂起(issue #5)。
1174
+ if (e && e.isTimeout) {
1175
+ return { ok: false, exitCode: -1, stdout: "", stderr: "", truncated: false, isTimeout: true, error: e.message };
1176
+ }
1177
+ // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
1178
+ const r2 = await runRemote(subprocess, p, cmd, stdinData, maxBytes, undefined, tms);
1124
1179
  if (!r2.ok && r2.exitCode === 255) r2.error = sshErrorHint(r2.stdout || r2.stderr || r2.error);
1125
1180
  return r2;
1126
1181
  }
@@ -1567,12 +1622,53 @@ function apply(ctx, config) {
1567
1622
  }
1568
1623
  });
1569
1624
 
1625
+ register({
1626
+ name: "remote_ssh_kill",
1627
+ description: "强制关闭 Remote-SSH 插件的池化 SSH 会话(issue #5 的兜底恢复手段)。当某条远端命令挂起、或想强制断开并重新建立连接时使用:关闭某个连接配置的持久会话,或关闭全部会话。仅影响插件内部的命令会话与连接缓存,不影响交互式终端与已保存的连接配置;下次调用工具时会自动重建会话。Force-close pooled SSH sessions of the Remote-SSH plugin (recovery hatch for hung commands).",
1628
+ parameters: Object.assign({}, CONN_PARAMS, {
1629
+ all: { type: "boolean", description: "关闭全部 profile 的池化会话(此时忽略 profileId/host 等连接参数)。" }
1630
+ }),
1631
+ output: {
1632
+ schema: {
1633
+ type: "object", additionalProperties: false, properties: {
1634
+ ok: { type: "boolean", required: true },
1635
+ killed: { type: "number", required: true },
1636
+ active: { type: "number", required: true },
1637
+ message: { type: "string", required: true }
1638
+ }
1639
+ },
1640
+ render: textRender(function (a, v) { return v.message || (v.ok ? "ok" : "failed"); })
1641
+ },
1642
+ execute: async function (args, exec) {
1643
+ if (args && args.all) {
1644
+ const n = sessions.size;
1645
+ sessions.forEach(function (s) { try { s.close(); } catch (e) {} });
1646
+ sessions.clear();
1647
+ return { ok: true, killed: n, active: 0, message: "已关闭全部 " + n + " 个池化会话(下次调用工具时按需重建)。" };
1648
+ }
1649
+ const tc = toolContext(args, exec);
1650
+ if (!tc.profile) {
1651
+ return { ok: false, killed: 0, active: sessions.size, message: "需要 profileId 或 host+user(当前会话非远程工作区时必填),或传 all: true 关闭全部会话。" };
1652
+ }
1653
+ const key = profileKey(tc.profile);
1654
+ const s = sessions.get(key);
1655
+ if (s) { s.close(); sessions.delete(key); }
1656
+ return {
1657
+ ok: true,
1658
+ killed: s ? 1 : 0,
1659
+ active: sessions.size,
1660
+ message: s ? "已关闭该连接的池化会话(" + key + ")。" : "该连接当前没有活动会话。"
1661
+ };
1662
+ }
1663
+ });
1664
+
1570
1665
  register({
1571
1666
  name: "remote_ssh_exec",
1572
- description: "通过 SSH 在远程主机(超算/服务器)上执行一条命令。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,自动用该工作区的连接并在其远程目录下执行。返回 stdout/stderr/exitCodeRun a command on a remote host (HPC/server) over SSH; returns stdout/stderr/exitCode.",
1667
+ description: "通过 SSH 在远程主机(超算/服务器)上执行一条命令。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,自动用该工作区的连接并在其远程目录下执行。返回 stdout/stderr/exitCode。命令默认 120 秒超时(环境变量 DSH_REMOTE_SSH_CMD_TIMEOUT_MS 可覆盖),超时后相关池化会话被自动丢弃重建,不影响后续命令;长时间任务(构建/训练)用 timeoutMs 显式放宽或置 0 禁用。Run a command on a remote host (HPC/server) over SSH; returns stdout/stderr/exitCode.",
1573
1668
  parameters: Object.assign({}, CONN_PARAMS, {
1574
1669
  command: { type: "string", required: true, description: "要执行的远程命令。" },
1575
- stdin: { type: "string", description: "可选,写入远程命令的标准输入。" }
1670
+ stdin: { type: "string", description: "可选,写入远程命令的标准输入。" },
1671
+ timeoutMs: { type: "number", description: "命令执行超时(毫秒),默认 120000;0 表示禁用超时(长时任务显式放宽)。超时后该连接的池化会话会被丢弃并自动重建。" }
1576
1672
  }),
1577
1673
  output: {
1578
1674
  schema: execSchema(),
@@ -1586,9 +1682,10 @@ function apply(ctx, config) {
1586
1682
  if (tc.remotePath) cmd = "cd " + shellQuotePath(tc.remotePath) + " 2>/dev/null; " + cmd;
1587
1683
  // 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
1588
1684
  // 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
1589
- // 会话异常时 runPooled 自动重建并降级一次性连接。
1685
+ // 会话异常时 runPooled 自动重建并降级一次性连接。命令级超时(issue #5):
1686
+ // 挂起的命令到点被丢弃(连带重置该池化会话),不会无限阻塞队列。
1590
1687
  // 远程命令可能改任意文件(插件不可见)→ 整代失效读/列举缓存(findings §4.3a)。
1591
- const r = await runPooled(tc.profile, cmd, args.stdin, undefined, true);
1688
+ const r = await runPooled(tc.profile, cmd, args.stdin, undefined, true, args.timeoutMs);
1592
1689
  bumpCacheEpoch(tc.profile);
1593
1690
  return normExec(r);
1594
1691
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.3.9",
3
+ "version": "2.4.1",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",