@zhangfengshun/dsh-remote-ssh 2.3.8 → 2.4.0

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.0] — 命令级超时 + `remote_ssh_kill` 兜底恢复(issue #5)
6
+ ### 新增
7
+ - **命令级超时**:一条挂起的远端命令(网络卡顿 / 远端进程僵死 / 等待 stdin 的 `cat`)此前会永久占用池化会话并阻塞其后所有命令,工具调用永不返回。现在所有 SSH 命令默认 **120 秒**超时:
8
+ - 环境变量 `DSH_REMOTE_SSH_CMD_TIMEOUT_MS` 覆盖全局默认,`0` 禁用;
9
+ - `remote_ssh_exec` 新增可选参数 `timeoutMs`(长时构建/训练显式放宽预算,`0` 禁用);
10
+ - 池化会话超时即**整体丢弃并自动重建**(挂起的命令仍占着共享 bash 进程,唯一可靠恢复是终止 SSH 重建),后续命令不受影响;
11
+ - 一次性连接(`runRemote`)超时即 `handle.terminate()`,返回带 `isTimeout` 标记的错误;
12
+ - 超时命令**刻意不做回退重试**(重试一条挂起的命令只会再次挂起)。
13
+ - **新增工具 `remote_ssh_kill`**:强制关闭某个连接的池化会话,或 `all: true` 关闭全部会话,返回 `{ ok, killed, active, message }`——agent 从此拥有挂起命令的兜底恢复手段,不再只能重启 DSH。
14
+ - 工具输出(exec 系列)新增 `isTimeout` 字段,模型可直接识别超时结果并决定恢复策略。
15
+
16
+ ### 兼容性验证
17
+ - 逐点验证 **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`)全部咬合,无需代码改动。
18
+
19
+ ## [2.3.9] — 修复 git-bash 启动导致的密钥认证失败(ssh 解析钉定到系统 OpenSSH)
20
+ ### 修复
21
+ - **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 集中授权环境同样受此影响。
22
+ - **终端 shell wrapper 同步修复**:生成的 `dsh-remote-shell.js` 同样优先解析系统 OpenSSH 绝对路径。
23
+ - 非 Windows 平台、未安装系统 OpenSSH 的环境:行为与之前完全一致(回落 PATH),零破坏。
24
+
5
25
  ## [2.3.8] — `/remote-ssh/api` CSRF 加固(合并 PR #4 + 服务端强制校验)
6
26
  ### 安全
7
27
  - **合并 PR #4**(感谢 @anupamme / OrbisAI Security):客户端 API 调用(`/remote-ssh/api/*`)统一携带 `x-requested-with: XMLHttpRequest` 头。
package/README.md CHANGED
@@ -12,7 +12,7 @@
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
  ## 截图
@@ -32,12 +32,12 @@
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.0
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
 
@@ -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,7 +12,7 @@ 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
@@ -32,12 +32,12 @@ 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.0
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
 
@@ -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/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(),
@@ -336,8 +371,22 @@ class CommandSession {
336
371
  }
337
372
  }
338
373
 
374
+ /** Windows 下优先解析系统自带 OpenSSH 的绝对路径:从 git-bash 启动 dsh web 时,
375
+ * 子进程 PATH 会把 Git 自带的 MSYS2 ssh 排在系统 OpenSSH 之前(HOME/config/agent
376
+ * 语义不同,导致密钥认证失败——issue:git-bash 启动场景)。System32\OpenSSH 是
377
+ * Win10 1809+ 的标准安装位置,不存在则回落 PATH(维持旧行为)。结果模块级缓存。 */
378
+ let cachedSshPath;
379
+ function sshExecutablePath() {
380
+ if (cachedSshPath !== undefined) return cachedSshPath;
381
+ if (process.platform === "win32") {
382
+ const sysSsh = join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", "ssh.exe");
383
+ if (existsSync(sysSsh)) return (cachedSshPath = sysSsh);
384
+ }
385
+ return (cachedSshPath = "ssh");
386
+ }
387
+
339
388
  function sshArgv(p, remoteCmd, tty, extraOpts) {
340
- const opts = ["ssh"];
389
+ const opts = [sshExecutablePath()];
341
390
  if (tty) opts.push("-tt");
342
391
  opts.push("-p", String(p.port || 22));
343
392
  opts.push("-o", "StrictHostKeyChecking=accept-new");
@@ -375,8 +424,9 @@ function stripPqBanner(text) {
375
424
  .join("\n");
376
425
  }
377
426
 
378
- async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpts) {
427
+ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpts, timeoutMs) {
379
428
  const max = maxBytes || MAX_BYTES;
429
+ const tms = resolveTimeoutMs(timeoutMs);
380
430
  let handle;
381
431
  try {
382
432
  handle = subprocess.spawn({
@@ -394,8 +444,15 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpt
394
444
  }
395
445
  let outcome;
396
446
  try {
397
- 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
+ });
398
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
+ }
399
456
  return { ok: false, error: "执行失败: " + String(e && e.message ? e.message : e) };
400
457
  }
401
458
  const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
@@ -937,7 +994,8 @@ function normExec(r) {
937
994
  stdout: r.stdout || "",
938
995
  stderr: r.stderr || "",
939
996
  error: r.error || "",
940
- truncated: !!r.truncated
997
+ truncated: !!r.truncated,
998
+ isTimeout: !!r.isTimeout
941
999
  };
942
1000
  }
943
1001
 
@@ -1002,7 +1060,8 @@ function execSchema() {
1002
1060
  stdout: { type: "string", required: true },
1003
1061
  stderr: { type: "string", required: true },
1004
1062
  error: { type: "string", required: true },
1005
- truncated: { type: "boolean", required: true }
1063
+ truncated: { type: "boolean", required: true },
1064
+ isTimeout: { type: "boolean", required: true }
1006
1065
  }
1007
1066
  };
1008
1067
  }
@@ -1037,6 +1096,7 @@ function remoteWorkspaceReadme(profileName, remotePath) {
1037
1096
  "- `remote_ssh_sync` — sync remote files to this local mirror / 同步远端文件到本地镜像",
1038
1097
  "- `remote_ssh_push` — push local mirror changes back to remote / 推送本地改动回远端",
1039
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 会话(挂起命令恢复)",
1040
1100
  "",
1041
1101
  "Workspace info / 工作区信息:",
1042
1102
  "",
@@ -1084,8 +1144,11 @@ function apply(ctx, config) {
1084
1144
  }
1085
1145
 
1086
1146
  /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。
1087
- * split=true 时 stderr 与 stdout 分离返回(命令走双哨兵协议)。 */
1088
- 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);
1089
1152
  const dropSession = () => {
1090
1153
  const key = profileKey(p);
1091
1154
  const old = sessions.get(key);
@@ -1093,7 +1156,9 @@ function apply(ctx, config) {
1093
1156
  };
1094
1157
  try {
1095
1158
  const s = getSession(p);
1096
- const r = await s.exec(cmd, stdinData, split);
1159
+ const r = await withTimeout(s.exec(cmd, stdinData, split), tms, "池化命令", function () {
1160
+ dropSession();
1161
+ });
1097
1162
  const stderr = split ? String(r.stderr || "") : "";
1098
1163
  if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: stderr, error: "", truncated: false };
1099
1164
  // exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
@@ -1104,9 +1169,13 @@ function apply(ctx, config) {
1104
1169
  : (errSource.trim().slice(0, 500) || ("ssh 退出码 " + r.exitCode));
1105
1170
  return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: stderr, error: error, truncated: false };
1106
1171
  } catch (e) {
1107
- // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
1108
1172
  dropSession();
1109
- 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);
1110
1179
  if (!r2.ok && r2.exitCode === 255) r2.error = sshErrorHint(r2.stdout || r2.stderr || r2.error);
1111
1180
  return r2;
1112
1181
  }
@@ -1553,12 +1622,53 @@ function apply(ctx, config) {
1553
1622
  }
1554
1623
  });
1555
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
+
1556
1665
  register({
1557
1666
  name: "remote_ssh_exec",
1558
- 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.",
1559
1668
  parameters: Object.assign({}, CONN_PARAMS, {
1560
1669
  command: { type: "string", required: true, description: "要执行的远程命令。" },
1561
- stdin: { type: "string", description: "可选,写入远程命令的标准输入。" }
1670
+ stdin: { type: "string", description: "可选,写入远程命令的标准输入。" },
1671
+ timeoutMs: { type: "number", description: "命令执行超时(毫秒),默认 120000;0 表示禁用超时(长时任务显式放宽)。超时后该连接的池化会话会被丢弃并自动重建。" }
1562
1672
  }),
1563
1673
  output: {
1564
1674
  schema: execSchema(),
@@ -1572,9 +1682,10 @@ function apply(ctx, config) {
1572
1682
  if (tc.remotePath) cmd = "cd " + shellQuotePath(tc.remotePath) + " 2>/dev/null; " + cmd;
1573
1683
  // 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
1574
1684
  // 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
1575
- // 会话异常时 runPooled 自动重建并降级一次性连接。
1685
+ // 会话异常时 runPooled 自动重建并降级一次性连接。命令级超时(issue #5):
1686
+ // 挂起的命令到点被丢弃(连带重置该池化会话),不会无限阻塞队列。
1576
1687
  // 远程命令可能改任意文件(插件不可见)→ 整代失效读/列举缓存(findings §4.3a)。
1577
- 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);
1578
1689
  bumpCacheEpoch(tc.profile);
1579
1690
  return normExec(r);
1580
1691
  }
@@ -1854,10 +1965,13 @@ function apply(ctx, config) {
1854
1965
  " const j = JSON.parse(fs.readFileSync(info, 'utf8'));",
1855
1966
  " if (j.keyPath && j.keyPath !== '' && j.host && j.user) {",
1856
1967
  " const port = j.port || 22;",
1968
+ " // Windows 下优先解析系统自带 OpenSSH 绝对路径(避免 git-bash 启动时解析到 MSYS2 ssh)",
1969
+ " const sysSsh = path.join(process.env.SystemRoot || 'C:\\\\Windows', 'System32', 'OpenSSH', 'ssh.exe');",
1970
+ " const sshBin = fs.existsSync(sysSsh) ? sysSsh : 'ssh';",
1857
1971
  " // ExitOnForwardFailure=no:ssh config 里的 RemoteForward 端口被占时终端仍能打开",
1858
1972
  " const args = ['-tt', '-o', 'StrictHostKeyChecking=no', '-o', 'ExitOnForwardFailure=no', '-p', String(port), '-i', j.keyPath, j.user + '@' + j.host];",
1859
1973
  " if (j.proxyJump) { args.splice(2, 0, '-J', j.proxyJump); }",
1860
- " const ssh = spawn('ssh', args, { stdio: 'inherit' });",
1974
+ " const ssh = spawn(sshBin, args, { stdio: 'inherit' });",
1861
1975
  " ssh.on('exit', function(c) { process.exit(c == null ? 0 : c); });",
1862
1976
  " return;",
1863
1977
  " }",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.3.8",
3
+ "version": "2.4.0",
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",