@zhangfengshun/dsh-remote-ssh 2.1.6 → 2.2.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,62 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.2.0] — 远程命令连接复用(实测 ≈15× 提速)+ 协议/安全加固
6
+ ### 性能
7
+ - **`remote_ssh_exec` 走持久会话池**:不再每次调用做完整 SSH 握手。真实 HPC(ssh.cn-zhongwei-1.paracloud.com,2222 端口)实测:一次性连接单命令平均 **1292ms**,连接复用后单命令平均 **84.3ms**,**约 15.3× 提速**;`stdin` 参数经 heredoc 走同一条复用通道。
8
+ - **stderr/stdout 分离语义保留**:stderr 重定向到远端临时文件、退出码哨兵后 cat 回传并以第二个哨兵收尾(双哨兵协议)。
9
+ - **读路径保持轻量**:`ls`/`cat`/`grep`/`glob` 继续走 `2>&1` 合并协议,热路径避开子 shell 与临时文件开销。
10
+ - **大文件读取先 stat**:超过 4MB 只读前 4MB(`head -c` 截断并标记 truncated),不再让持久会话缓冲无限增长。
11
+ ### 安全 / 健壮性
12
+ - **会话输出硬上限 8MB**:单条命令哨兵到达前缓冲超限即重置会话并给出明确报错,防止远端海量输出撑爆内存。
13
+ - **密钥认证非交互连接加 `-o BatchMode=yes -o PreferredAuthentications=publickey`**:带口令的私钥/意外交互立即失败并返回可读提示(不再挂到超时),同时缩短建连时间;交互式终端(`ssh -tt`)不受影响。
14
+ - **分离协议用双层子 shell**:用户命令中的 `exit`/`cd` 不再弄挂共享会话(内层子 shell 隔离,实测远端 `exit 7` 后会话存活且退出码正确传回)。
15
+ - **写入上限 4MB**:超限直接拒绝并给出清晰报错;写入失败现在能拿回远端 stderr(此前错误信息丢失)。
16
+ - 配合 2.1.7 纳入的修复(`--include` 转义、stderr 排空、删除配置级联清理、keyPath `~` 展开、会话池 key 含认证信息),完成一轮兼容性/安全性全面检查。
17
+
18
+ ## [2.1.7] — 兼容 DSH Desktop ≥ 2.0.4:polyfill __DSH_MODULES__ + 可靠性/安全修复
19
+ ### 修复
20
+ - **__DSH_MODULES__ polyfill**:better-sidebar 0.15+ 的懒加载 chunk(编辑器等)依赖 `globalThis.__DSH_MODULES__`,而 DSH Desktop ≥ 2.0.4 的 shell 不再挂载该全局,导致侧边栏面板打不开("chunk ... client module system unavailable")。Client 半边启动时用内核 `modules` 服务补挂该全局(better-sidebar 已注入则保持原值),并带定期兜底重试。
21
+ - **持久会话 stderr 排空**:SSH 会话的 stderr 管道此前无人消费,写满后远端 ssh 进程阻塞、会话假死;现在持续排空并保留最近 64KB,会话意外关闭时报错会附带最近 stderr 尾部,便于排查断连原因。
22
+ - **修复 `remote_ssh_grep` 命令注入**:`--include` 参数此前未做 shell 转义,可通过构造参数在远端执行任意命令;现已与其它参数一样经 `shellQuote` 引用。
23
+ - **删除连接配置时级联清理**:删除 profile 时同步删除其远程工作区的原生注册与本地镜像目录(复用统一的 `cleanupWorkspace`),不再留下指向已删配置的孤儿工作区。
24
+ - **密钥路径支持 `~` 展开**:`keyPath` 按 `~`、`~/` 前缀展开为绝对路径,与设置界面占位符(`~/.ssh/id_rsa`)行为一致。
25
+ - **会话池 key 纳入认证信息**:修改连接配置的 keyPath/authMethod 后立即重建 SSH 会话,不再复用旧连接。
26
+ - 清理每次文件请求的 `console.log` 调试输出(改走 `ctx.logger.debug`);`localToRemote` 对镜像目录之外的路径不再静默拼出错误远程路径。
27
+ - 文档:补全 2.1.0 以来缺失的 CHANGELOG 条目;README 安装命令版本号同步为 2.1.7。
28
+
29
+ ## [2.1.6] — P0 打磨:远程下载 / 流式上传完善 / 远程 Git / 重连
30
+ ### 修复 / 改进
31
+ - **远程文件下载与媒体预览**:拦截 better-sidebar 的 `/sidebar/file`(同路径 exact 路由优先),远程工作区中的文件直接从远端 base64 拉回(上限 64MB),本地照旧。
32
+ - **流式上传**:`/remote-ssh/upload` 把请求体直接管道到远端 `cat > target`(二进制安全、恒定内存),请求中断时终止远端进程。
33
+ - **远程 Git 面板**:拦截 `/sidebar/api/git.*`(status/diff/log/branch/commit-diff/show/stage/unstage/commit/checkout/discard/revert/cherry-pick),远程工作区中在远端目录执行同语义 git 命令。
34
+ - **SSH 失败修复建议**:端口转发占用 / 公钥认证失败 / 连接拒绝 / 超时 / 域名解析失败翻译为带修复建议的中文提示。
35
+
36
+ ## [2.1.5] — 同步 better-sidebar 0.15+ 上传到远程工作区
37
+ ### 修复
38
+ - better-sidebar 0.15+ 的上传 UI 直连 `/sidebar/upload`,远程工作区镜像目录会导致文件只落在本地镜像。Client 半边包装 `fetch`:目标目录位于远程工作区镜像内时把上传重定向到 `/remote-ssh/upload`,经 SSH 直接写远端。
39
+
40
+ ## [2.1.4] — 修复远程工作区标题卡在镜像目录名
41
+ ### 修复
42
+ - `workspaceRegistry.create` 只对新建记录应用 title,旧工作区(早期版本未传 title)标题卡在镜像目录名(如 `wmt3tev5cdfge`)。创建时显式 `setTitle`,并新增启动自愈 `healWorkspaceTitles`(仅在标题等于镜像目录 basename 时修复,不覆盖用户自定义标题)。
43
+
44
+ ## [2.1.3] — 远程工作区标题默认取远程路径最后一段
45
+ ### 改进
46
+ - 创建远程工作区时的默认标题改为远程路径最后一段目录名(如 `~/run/zfs/codex/DSH_Test → DSH_Test`),取不到有效段时回退为 `连接名:远程路径`;原生工作区标题带 🌐 前缀。
47
+
48
+ ## [2.1.2] — 修复 ssh config RemoteForward 端口占用导致连接失败
49
+ ### 修复
50
+ - 插件内部连接(文件读写 / 工具 / 同步)追加 `ClearAllForwardings=yes`;shell wrapper 追加 `ExitOnForwardFailure=no`。`~/.ssh/config` 里的 `RemoteForward` 端口被上次会话占用且 `ExitOnForwardFailure yes` 时不再导致连接直接失败。
51
+ - 把 SSH 常见失败(端口转发占用 / 公钥认证失败 / 连接拒绝 / 超时 / 域名解析失败)翻译成带修复建议的中文提示。
52
+
53
+ ## [2.1.1] — 新增英文 README
54
+ ### 文档
55
+ - 新增 README_EN.md,中英文版本互链。
56
+
57
+ ## [2.1.0] — README 拆分语言版本
58
+ ### 文档
59
+ - 移除 README 内联双语,拆分为 README.md(中文)与 README_EN.md(英文)。
60
+
5
61
  ## [2.0.1] — 修复远程检测:从 payload.path 也查找 .remote-ssh.json
6
62
  ### 修复
7
63
  - **sessionCwd 为空时无法检测远程工作区**:客户端请求有时只带 `payload.path` 不带 `cwd`,而 `ctx.sessions.get(sessionId)` 可能返回 undefined(session 未创建或 ID 不匹配)。现改为:先尝试 `sessionCwd`,再尝试 `payload.path`,两者都会检查 `.remote-ssh.json`。
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## 安装
18
18
 
19
19
  ```bash
20
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.0
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
21
21
  ```
22
22
 
23
23
  > 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
package/README_EN.md ADDED
@@ -0,0 +1,67 @@
1
+ # @zhangfengshun/dsh-remote-ssh
2
+
3
+ English | [中文](./README.md)
4
+
5
+ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via SSH, and directly operate remote files and terminals within DSH's built-in **Files** and **Terminal** sidebar tabs.
6
+
7
+ ## Features
8
+
9
+ | Feature | Description |
10
+ | --- | --- |
11
+ | 🔌 SSH Connection | Key / password auth, ProxyJump bastion, one-click import from `~/.ssh/config` |
12
+ | 📂 Remote Files | Built-in **Files** tab reads/writes remote files directly via SSH — no sync needed |
13
+ | 💻 Remote Terminal | Built-in **Terminal** tab auto-detects remote workspaces, opens SSH interactive shell |
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 |
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
21
+ ```
22
+
23
+ > **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
24
+
25
+ ## Usage
26
+
27
+ 1. **Settings → 🖥️ Remote SSH** → Add a connection (host/port/user/key) → Click "Test Connection"
28
+ 2. **Add Workspace** → Choose "Select Remote Directory…" → Pick a connection → Browse and select
29
+ 3. Open the built-in **Files** tab → Remote files shown directly, edits save back to remote
30
+ 4. Open the built-in **Terminal** tab → Auto SSH to remote host (key auth only)
31
+
32
+ ## Model Tools
33
+
34
+ | Tool | Purpose |
35
+ | --- | --- |
36
+ | `remote_ssh_profiles` | List saved connections + current session's remote workspace context |
37
+ | `remote_ssh_exec` | Execute remote command |
38
+ | `remote_ssh_ls` | List remote directory |
39
+ | `remote_ssh_cat` | Read remote file |
40
+ | `remote_ssh_write` | Write remote file |
41
+ | `remote_ssh_grep` | Search remote file contents |
42
+ | `remote_ssh_glob` | Find remote files by glob |
43
+ | `remote_ssh_mkdir` | Create remote directory |
44
+ | `remote_ssh_delete` | Delete remote file/directory |
45
+ | `remote_ssh_move` | Move/rename |
46
+ | `remote_ssh_sync` | Sync remote to local mirror |
47
+ | `remote_ssh_push` | Push local mirror back to remote |
48
+
49
+ In a remote-workspace session, `profileId` and other connection params can be omitted.
50
+
51
+ ## How It Works
52
+
53
+ 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.
54
+
55
+ A shell wrapper (`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`) detects the workspace's `.remote-ssh.json` and auto-launches `ssh -tt`, making the built-in **Terminal** tab transparently connect to remote.
56
+
57
+ ## ❤️ Happy Qixi
58
+
59
+ This project is a Qixi Festival gift for **zhangyi**.
60
+
61
+ May it connect us as closely as it connects to distant supercomputers. Happy Qixi ❤️
62
+
63
+ —— August 18, 2026
64
+
65
+ ## License
66
+
67
+ [MIT](./LICENSE)
package/lib/client.js CHANGED
@@ -1063,6 +1063,39 @@ window.__ModuleLoader__.load({
1063
1063
  var slots = ctx.slots;
1064
1064
  var locale = ctx.locale;
1065
1065
 
1066
+ // 兼容 DSH Desktop ≥2.0.4:shell 不再把客户端模块系统挂到
1067
+ // globalThis.__DSH_MODULES__,而 dsh-better-sidebar 0.15+ 的懒加载
1068
+ // chunk(editor 等)会回退到该全局——缺失时侧边栏面板打不开、报
1069
+ // "chunk ... client module system unavailable"。这里用内核提供的
1070
+ // modules 服务补挂该全局;better-sidebar 已注入则保持原值。
1071
+ try {
1072
+ if (typeof globalThis !== "undefined") {
1073
+ var fallbackModules = null;
1074
+ try { if (ctx.modules && typeof ctx.modules.import === "function") fallbackModules = ctx.modules; } catch (e) {}
1075
+ if (!fallbackModules && ctx.get && typeof ctx.get === "function") {
1076
+ try { fallbackModules = ctx.get("modules"); } catch (e) { fallbackModules = null; }
1077
+ }
1078
+ // 定期兜底:better-sidebar 若把本插件应用时间之后的模块系统清掉,也补回。
1079
+ if (globalThis.__DSH_MODULES__ === undefined && fallbackModules === null) {
1080
+ var waitModules = 0;
1081
+ var waitTimer = setInterval(function () {
1082
+ waitModules += 1;
1083
+ try {
1084
+ var ms = (ctx.get && typeof ctx.get === "function") ? ctx.get("modules") : null;
1085
+ if (ms && typeof ms.import === "function") {
1086
+ globalThis.__DSH_MODULES__ = ms;
1087
+ clearInterval(waitTimer);
1088
+ }
1089
+ } catch (e) {}
1090
+ if (globalThis.__DSH_MODULES__ !== undefined || waitModules > 40) clearInterval(waitTimer);
1091
+ }, 250);
1092
+ }
1093
+ if (!globalThis.__DSH_MODULES__ && fallbackModules && typeof fallbackModules.import === "function") {
1094
+ globalThis.__DSH_MODULES__ = fallbackModules;
1095
+ }
1096
+ }
1097
+ } catch (e) {}
1098
+
1066
1099
  // 注册中英文词典并绑定当前语言;locale 切换时 i18n.subscribe 触发组件重渲染。
1067
1100
  if (locale) {
1068
1101
  ctx.effect(function () { return locale.register(NS, { zh: zh, en: en }); }, "dsh-remote-ssh: dictionaries");
package/lib/index.js CHANGED
@@ -25,6 +25,8 @@ const Config = z.object({});
25
25
 
26
26
  const NS = "dsh-remote-ssh";
27
27
  const MAX_BYTES = 4 * 1024 * 1024;
28
+ /** 持久会话单条命令的 stdout 缓冲硬上限(防止大文件/海量输出撑爆内存)。 */
29
+ const SESSION_MAX_STDOUT = 8 * 1024 * 1024;
28
30
  const API_BASE = "/remote-ssh/api/";
29
31
 
30
32
  /** 连接配置 schema(存于 settings)。 */
@@ -161,8 +163,10 @@ async function listSshConfigHosts() {
161
163
  * 避免每次操作都做完整 TCP 握手 + SSH 密钥交换 + 认证(超算/跳板机单次 2-10 秒)。
162
164
  *
163
165
  * 工作原理:向 bash 的 stdin 写入命令 + 哨兵标记 printf,从 stdout 读取直到哨兵出现,
164
- * 哨兵后的数字即退出码。每条命令用 2>&1 合并 stderr(成功时 stderr 为空,不影响解析;
165
- * 失败时错误信息在 stdout 中,退出码非 0)。带 stdin 的写操作用 heredoc。
166
+ * 哨兵后的数字即退出码。split(分离 stderr)模式下,stderr 重定向到远端临时文件,
167
+ * 退出码哨兵之后 cat 出来并用第二个哨兵收尾:保住 stdout/stderr 分离的同时,
168
+ * 让 remote_ssh_exec 这类调用也走连接复用(首调用建连后毫秒级返回)。
169
+ * 输出侧有硬上限 SESSION_MAX_STDOUT:哨兵出现前缓冲超限即重置会话,下次调用自动重建。
166
170
  */
167
171
  class CommandSession {
168
172
  constructor(subprocess, profile) {
@@ -176,6 +180,8 @@ class CommandSession {
176
180
  this.sentinel = "DSHEOF" + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 10) + "DSHEOF";
177
181
  this.lastUsed = Date.now();
178
182
  this.connectPromise = null;
183
+ /** 最近一段 stderr(防背压消费 + 会话关闭诊断)。 */
184
+ this.errBuf = "";
179
185
  }
180
186
 
181
187
  async connect() {
@@ -189,6 +195,7 @@ class CommandSession {
189
195
  if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
190
196
  this.alive = false;
191
197
  this.buf = "";
198
+ this.errBuf = "";
192
199
  this.current = null;
193
200
  this.queue = [];
194
201
  const argv = sshArgv(this.profile, "bash", false);
@@ -203,10 +210,16 @@ class CommandSession {
203
210
  this.buf += chunk.toString("utf8");
204
211
  this._check();
205
212
  });
213
+ // stderr 必须消费:不挂监听会让管道缓冲填满、远端 ssh 写 stderr 阻塞(会话假死)。
214
+ // 保留最近 64KB 用于会话关闭时的诊断。
215
+ h.stderr.on("data", (chunk) => {
216
+ this.errBuf = (this.errBuf + chunk.toString("utf8")).slice(-65536);
217
+ });
206
218
  const onEnd = () => {
207
219
  this.alive = false;
208
- if (this.current) { this.current.reject(new Error("ssh 会话已关闭")); this.current = null; }
209
- while (this.queue.length) this.queue.shift().reject(new Error("ssh 会话已关闭"));
220
+ const errMsg = "ssh 会话已关闭" + (this.errBuf.trim() ? ": " + this.errBuf.trim().slice(-300) : "");
221
+ if (this.current) { this.current.reject(new Error(errMsg)); this.current = null; }
222
+ while (this.queue.length) this.queue.shift().reject(new Error(errMsg));
210
223
  };
211
224
  h.stdout.on("end", onEnd);
212
225
  h.stdout.on("error", onEnd);
@@ -217,34 +230,87 @@ class CommandSession {
217
230
 
218
231
  _check() {
219
232
  if (!this.current) return;
220
- const idx = this.buf.indexOf(this.sentinel);
221
- if (idx < 0) return;
222
- const before = this.buf.slice(0, idx);
223
- const after = this.buf.slice(idx + this.sentinel.length);
224
- const nl = after.indexOf("\n");
225
- const codeStr = nl >= 0 ? after.slice(0, nl) : after;
226
- const rest = nl >= 0 ? after.slice(nl + 1) : "";
227
- this.buf = rest;
228
- const exitCode = parseInt(codeStr, 10);
229
- const cur = this.current;
233
+ const item = this.current;
234
+ if (!item.exitParsed) {
235
+ // 找到主哨兵:解析退出码;split 模式还要等第二个哨兵(stderr 内容)
236
+ const idx = this.buf.indexOf(this.sentinel);
237
+ if (idx < 0) { this._enforceCap(); return; }
238
+ const before = this.buf.slice(0, idx);
239
+ const after = this.buf.slice(idx + this.sentinel.length);
240
+ const nl = after.indexOf("\n");
241
+ const codeStr = nl >= 0 ? after.slice(0, nl) : after;
242
+ this.buf = nl >= 0 ? after.slice(nl + 1) : "";
243
+ const exitCode = parseInt(codeStr, 10);
244
+ item.exitCode = isNaN(exitCode) ? -1 : exitCode;
245
+ item.stdout = before;
246
+ item.exitParsed = true;
247
+ if (!item.errSentinel) {
248
+ this.current = null;
249
+ item.resolve({ stdout: item.stdout, stderr: item.stderr, exitCode: item.exitCode });
250
+ this._next();
251
+ return;
252
+ }
253
+ }
254
+ if (item.errSentinel) {
255
+ // split 模式第二段:等 stderr 哨兵(可能已在缓冲里)
256
+ const idx = this.buf.indexOf(item.errSentinel);
257
+ if (idx < 0) { this._enforceCap(); return; }
258
+ const errText = this.buf.slice(0, idx);
259
+ const after = this.buf.slice(idx + item.errSentinel.length);
260
+ const nl = after.indexOf("\n");
261
+ this.buf = nl >= 0 ? after.slice(nl + 1) : "";
262
+ item.stderr = errText.replace(/\n$/, "");
263
+ this.current = null;
264
+ item.resolve({ stdout: item.stdout, stderr: item.stderr, exitCode: item.exitCode });
265
+ this._next();
266
+ }
267
+ }
268
+
269
+ /** 单条命令输出超过硬上限:立即重置会话(下次调用自动重建),拒绝挂起/撑爆内存。 */
270
+ _enforceCap() {
271
+ if (!this.current || this.buf.length < SESSION_MAX_STDOUT) return;
272
+ const item = this.current;
230
273
  this.current = null;
231
- cur.resolve({ stdout: before, exitCode: isNaN(exitCode) ? -1 : exitCode });
232
- this._next();
274
+ item.reject(new Error("单条命令输出超过 " + SESSION_MAX_STDOUT + " 字节上限,会话已重置(若确实需要更大输出请拆分命令)"));
275
+ this.alive = false;
276
+ if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
277
+ while (this.queue.length) this.queue.shift().reject(new Error("ssh 会话已重置"));
233
278
  }
234
279
 
235
280
  _next() {
236
281
  if (this.current || !this.alive || this.queue.length === 0) return;
237
282
  const item = this.queue.shift();
238
- this.current = { resolve: item.resolve, reject: item.reject };
283
+ item.exitParsed = false;
284
+ item.stdout = "";
285
+ item.stderr = "";
286
+ item.exitCode = -1;
287
+ item.errSentinel = item.split
288
+ ? "DSHERR" + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 8)
289
+ : null;
290
+ this.current = item;
239
291
  try {
292
+ const errFile = "${TMPDIR:-/tmp}/.dsh-err-" + Math.random().toString(36).slice(2, 10);
293
+ const exitMark = "printf '\\n" + this.sentinel + "%s\\n' $?";
294
+ const errTail = "cat " + errFile + " 2>/dev/null; rm -f " + errFile + "; printf '\\n" + item.errSentinel + "ok\\n'";
240
295
  let toWrite;
241
- if (item.stdinData !== undefined && item.stdinData !== null) {
242
- // 写操作:heredoc 传内容,不用 2>&1(cat > file 不产生 stdout)
296
+ if (item.split) {
297
+ // 分离 stderr 用双层子 shell:
298
+ // 外层 ( ... ) 保证 printf 链自身不会受用户命令返回值影响;
299
+ // 内层 ( { cmd ; } ) 隔离用户命令里的 exit/cd,绝不弄挂共享 bash 会话
300
+ // (exit 只会退出内层子 shell,后面照常打印哨兵与退出码)。
301
+ if (item.stdinData !== undefined && item.stdinData !== null) {
302
+ const delim = "DSHW" + Math.random().toString(36).slice(2, 14);
303
+ toWrite = "( ( " + item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n) 2>" + errFile + "; " + exitMark + "; " + errTail + " )\n";
304
+ } else {
305
+ toWrite = "( ( " + item.cmd + " ; ) 2>" + errFile + "; " + exitMark + "; " + errTail + " )\n";
306
+ }
307
+ } else if (item.stdinData !== undefined && item.stdinData !== null) {
308
+ // 快速路径写操作:heredoc 传内容,2>&1 合并(成功时 stderr 为空,不影响解析)
243
309
  const delim = "DSHW" + Math.random().toString(36).slice(2, 14);
244
- toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
310
+ toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n" + exitMark + "\n";
245
311
  } else {
246
- // 读/列举/搜索:2>&1 合并 stderr(成功时 stderr 为空)
247
- toWrite = "{ " + item.cmd + " ; } 2>&1\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
312
+ // 读/列举/搜索快速路径:2>&1 合并 stderr(成功时 stderr 为空)
313
+ toWrite = "{ " + item.cmd + " ; } 2>&1\n" + exitMark + "\n";
248
314
  }
249
315
  this.handle.stdin.write(toWrite);
250
316
  } catch (e) {
@@ -254,11 +320,11 @@ class CommandSession {
254
320
  }
255
321
  }
256
322
 
257
- async exec(cmd, stdinData) {
323
+ async exec(cmd, stdinData, split) {
258
324
  await this.connect();
259
325
  this.lastUsed = Date.now();
260
326
  return new Promise((resolve, reject) => {
261
- this.queue.push({ cmd, stdinData, resolve, reject });
327
+ this.queue.push({ cmd, stdinData, split: !!split, resolve, reject });
262
328
  this._next();
263
329
  });
264
330
  }
@@ -283,7 +349,14 @@ function sshArgv(p, remoteCmd, tty) {
283
349
  // 清除 ssh config 里的 RemoteForward/LocalForward,避免端口被占时连接直接失败。
284
350
  opts.push("-o", "ClearAllForwardings=yes");
285
351
  if (p.proxyJump) opts.push("-o", "ProxyJump=" + String(p.proxyJump));
286
- if (p.authMethod === "key" && p.keyPath) opts.push("-i", p.keyPath);
352
+ if (p.authMethod === "key" && p.keyPath) opts.push("-i", expandSshPath(p.keyPath));
353
+ // 密钥认证的非交互连接(会话池 / 工具 / 同步)要求一次性无提示完成:
354
+ // BatchMode 禁用 passphrase/确认交互(有口令的密钥立即报错而不是挂到超时),
355
+ // PreferredAuthentications=publickey 跳过无谓的认证方法协商,缩短建连时间。
356
+ if (!tty && p.authMethod === "key") {
357
+ opts.push("-o", "BatchMode=yes");
358
+ opts.push("-o", "PreferredAuthentications=publickey");
359
+ }
287
360
  const target = String(p.user || "") + "@" + String(p.host || "");
288
361
  const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
289
362
  const argv = head.concat(opts, [target]);
@@ -357,8 +430,23 @@ async function remoteListDir(runner, p, path) {
357
430
 
358
431
  async function remoteReadFile(runner, p, path) {
359
432
  if (!path) return { ok: false, error: "path 为必填项" };
360
- const r = await runner(p, "base64 -w0 " + shellQuotePath(path), undefined, MAX_BYTES);
361
- if (!r.ok) return r;
433
+ // stat 拿大小:超过 MAX_BYTES 的大文件只读前 4MB(head -c 截断),
434
+ // 避免把持久会话的 stdout 缓冲撑爆(会话层另有 8MB 硬上限兜底)。
435
+ // 读路径走 2>&1 合并协议(热路径,省掉一次子 shell 与 stderr 重定向)。
436
+ const st = await runner(p, "stat -c%s " + shellQuotePath(path));
437
+ let size = null;
438
+ if (st.ok) {
439
+ const n = parseInt(String(st.stdout || "").trim(), 10);
440
+ if (!isNaN(n)) size = n;
441
+ }
442
+ let cmd = "base64 -w0 " + shellQuotePath(path);
443
+ let truncated = false;
444
+ if (size !== null && size > MAX_BYTES) {
445
+ cmd = "head -c " + MAX_BYTES + " " + shellQuotePath(path) + " | base64 -w0";
446
+ truncated = true;
447
+ }
448
+ const r = await runner(p, cmd, undefined, MAX_BYTES);
449
+ if (!r.ok) return { ok: false, error: String(r.error || r.stderr || "").trim() || "读取失败" };
362
450
  let text = "";
363
451
  let binary = false;
364
452
  try {
@@ -369,13 +457,16 @@ async function remoteReadFile(runner, p, path) {
369
457
  } catch (e) {
370
458
  return { ok: false, error: "解码失败: " + String(e) };
371
459
  }
372
- return { ok: true, path: path, content: text, binary: binary, truncated: r.truncated };
460
+ return { ok: true, path: path, content: text, binary: binary, truncated: truncated || r.truncated };
373
461
  }
374
462
 
375
463
  async function remoteWriteFile(runner, p, path, content) {
376
464
  if (!path) return { ok: false, error: "path 为必填项" };
377
465
  const c = content !== undefined ? String(content) : "";
378
- return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
466
+ if (c.length > MAX_BYTES) {
467
+ return { ok: false, exitCode: -1, stdout: "", stderr: "", error: "写入内容超过 " + MAX_BYTES + " 字节上限", truncated: false };
468
+ }
469
+ return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES, true);
379
470
  }
380
471
 
381
472
  /**
@@ -388,7 +479,7 @@ async function remoteGrep(runner, p, pattern, path, opts) {
388
479
  const target = path || p.remoteRoot || "~";
389
480
  const parts = ["grep", "-rnIE"];
390
481
  if (opts.ignoreCase) parts.push("-i");
391
- if (opts.include) parts.push("--include=" + String(opts.include));
482
+ if (opts.include) parts.push("--include=" + shellQuote(String(opts.include)));
392
483
  parts.push("--", shellQuote(pattern), shellQuotePath(target));
393
484
  const cmd = parts.join(" ");
394
485
  const r = await runner(p, cmd, undefined, MAX_BYTES);
@@ -653,7 +744,8 @@ function apply(ctx, config) {
653
744
  const sessions = new Map(); // profileKey -> CommandSession
654
745
  const SESSION_IDLE_MS = 10 * 60 * 1000;
655
746
  function profileKey(p) {
656
- return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "");
747
+ // 认证信息变化时应重建会话:修改 keyPath/authMethod 后旧连接不能继续复用。
748
+ return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
657
749
  }
658
750
  function getSession(p) {
659
751
  const key = profileKey(p);
@@ -675,8 +767,9 @@ function apply(ctx, config) {
675
767
  return t.trim().slice(0, 500);
676
768
  }
677
769
 
678
- /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。 */
679
- async function runPooled(p, cmd, stdinData, maxBytes) {
770
+ /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。
771
+ * split=true stderr stdout 分离返回(命令走双哨兵协议)。 */
772
+ async function runPooled(p, cmd, stdinData, maxBytes, split) {
680
773
  const dropSession = () => {
681
774
  const key = profileKey(p);
682
775
  const old = sessions.get(key);
@@ -684,11 +777,16 @@ function apply(ctx, config) {
684
777
  };
685
778
  try {
686
779
  const s = getSession(p);
687
- const r = await s.exec(cmd, stdinData);
688
- if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: "", error: "", truncated: false };
780
+ const r = await s.exec(cmd, stdinData, split);
781
+ const stderr = split ? String(r.stderr || "") : "";
782
+ if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: stderr, error: "", truncated: false };
689
783
  // exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
690
784
  if (r.exitCode === 255) dropSession();
691
- return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: sshErrorHint(r.exitCode === 255 ? r.stdout : String(r.stdout).trim().slice(0, 500)), truncated: false };
785
+ const errSource = String(stderr || r.stdout || "");
786
+ const error = r.exitCode === 255
787
+ ? sshErrorHint(errSource)
788
+ : (errSource.trim().slice(0, 500) || ("ssh 退出码 " + r.exitCode));
789
+ return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: stderr, error: error, truncated: false };
692
790
  } catch (e) {
693
791
  // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
694
792
  dropSession();
@@ -782,6 +880,18 @@ function apply(ctx, config) {
782
880
  return base.replace(/\/+$/, "") + "/" + String(path);
783
881
  }
784
882
 
883
+ /** 清理远程工作区的原生注册与本地镜像目录(删除工作区或级联删除 profile 时调用)。 */
884
+ async function cleanupWorkspace(ws) {
885
+ if (!ws || !ws.mirrorPath) return;
886
+ if (workspaceRegistry) {
887
+ try {
888
+ const entity = await workspaceRegistry.resolveByPath(ws.mirrorPath);
889
+ if (entity) await workspaceRegistry.delete(entity.id);
890
+ } catch (e) {}
891
+ }
892
+ try { await rm(ws.mirrorPath, { recursive: true, force: true }); } catch (e) {}
893
+ }
894
+
785
895
  const api = {
786
896
  listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
787
897
  /** 读取本机 ~/.ssh/config 发现的主机(借鉴 Yan-Zero dsh-remote-ssh)。 */
@@ -816,9 +926,12 @@ function apply(ctx, config) {
816
926
  deleteProfile: async (args) => {
817
927
  const id = args && args.id;
818
928
  const profiles = settingsFace.read().profiles.filter((p) => p.id !== id);
929
+ const orphaned = settingsFace.read().workspaces.filter((w) => w.profileId === id);
819
930
  const workspaces = settingsFace.read().workspaces.filter((w) => w.profileId !== id);
820
931
  await settingsFace.updateProfiles(profiles);
821
932
  await settingsFace.updateWorkspaces(workspaces);
933
+ // 级联清理:相关远程工作区的原生注册与本地镜像目录一并删除,不留孤儿。
934
+ for (const ws of orphaned) await cleanupWorkspace(ws);
822
935
  return { ok: true, profiles: profiles, workspaces: workspaces };
823
936
  },
824
937
  testConnection: async (args) => {
@@ -891,15 +1004,7 @@ function apply(ctx, config) {
891
1004
  const ws = all.find((w) => w.id === id);
892
1005
  const workspaces = all.filter((w) => w.id !== id);
893
1006
  await settingsFace.updateWorkspaces(workspaces);
894
- if (ws && ws.mirrorPath) {
895
- if (workspaceRegistry) {
896
- try {
897
- const entity = await workspaceRegistry.resolveByPath(ws.mirrorPath);
898
- if (entity) await workspaceRegistry.delete(entity.id);
899
- } catch (e) {}
900
- }
901
- try { await rm(ws.mirrorPath, { recursive: true, force: true }); } catch (e) {}
902
- }
1007
+ await cleanupWorkspace(ws);
903
1008
  return { ok: true, workspaces: workspaces };
904
1009
  },
905
1010
  updateWorkspace: async (args) => {
@@ -917,7 +1022,8 @@ function apply(ctx, config) {
917
1022
  const p = resolveProfile(args);
918
1023
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
919
1024
  if (!args || !args.command) return { ok: false, error: "command 为必填项" };
920
- return await runRemote(subprocess, p, args.command, args.stdin, undefined);
1025
+ // 走持久会话池(stderr 分离):首调用建立连接后,后续调用毫秒级返回。
1026
+ return await runPooled(p, args.command, args.stdin, undefined, true);
921
1027
  },
922
1028
  listDir: async (args) => {
923
1029
  const p = resolveProfile(args);
@@ -1113,7 +1219,10 @@ function apply(ctx, config) {
1113
1219
  if (!args.command) return normExec({ ok: false, error: "command 为必填项" });
1114
1220
  let cmd = String(args.command);
1115
1221
  if (tc.remotePath) cmd = "cd " + shellQuotePath(tc.remotePath) + " 2>/dev/null; " + cmd;
1116
- return normExec(await runRemote(subprocess, tc.profile, cmd, args.stdin, undefined));
1222
+ // 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
1223
+ // 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
1224
+ // 会话异常时 runPooled 自动重建并降级一次性连接。
1225
+ return normExec(await runPooled(tc.profile, cmd, args.stdin, undefined, true));
1117
1226
  }
1118
1227
  });
1119
1228
 
@@ -1480,7 +1589,9 @@ function apply(ctx, config) {
1480
1589
  if (rel.toLowerCase().startsWith(mc.toLowerCase())) {
1481
1590
  rel = rel.slice(mc.length);
1482
1591
  } else {
1483
- // 可能是相对于 cwd 的路径
1592
+ // 不在镜像目录下:剥掉可能的前导斜杠与盘符,按相对路径处理(此前会静默拼出错误路径)。
1593
+ rel = rel.replace(/^[A-Za-z]:/, "").replace(/^\/+/, "");
1594
+ try { ctx.logger?.debug("[dsh-remote-ssh] localToRemote: path outside mirror, treated as relative: " + localPath); } catch (e) {}
1484
1595
  }
1485
1596
  if (rel.startsWith("/")) rel = rel.slice(1);
1486
1597
  if (!rel) return remoteBase;
@@ -1634,7 +1745,7 @@ function apply(ctx, config) {
1634
1745
  remoteInfo = readRemoteInfoSync(payload.path);
1635
1746
  remoteBase = payload.path;
1636
1747
  }
1637
- console.log("[dsh-remote-ssh] intercept " + method + ": sessionId=" + sessionId + " cwd=" + sessionCwd + " path=" + (payload && payload.path) + " remote=" + (remoteInfo ? "YES" : "NO"));
1748
+ try { ctx.logger?.debug("[dsh-remote-ssh] intercept " + method + ": sessionId=" + sessionId + " cwd=" + sessionCwd + " path=" + (payload && payload.path) + " remote=" + (remoteInfo ? "YES" : "NO")); } catch (e) {}
1638
1749
  let result;
1639
1750
  if (remoteInfo && remoteInfo.keyPath && remoteInfo.host && remoteInfo.user) {
1640
1751
  // ---- 远程工作区:走 SSH ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.1.6",
3
+ "version": "2.2.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",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "client": {
42
42
  "inject": [
43
+ "@deepseek-ai/dsh-client-modules",
43
44
  "@deepseek-ai/dsh-client-runtime",
44
45
  "@deepseek-ai/dsh-client-locale",
45
46
  "@deepseek-ai/dsh-client-ui-slots",
@@ -66,6 +67,7 @@
66
67
  "lib/client.js",
67
68
  "cordis.patch.yml",
68
69
  "README.md",
70
+ "README_EN.md",
69
71
  "LICENSE",
70
72
  "CHANGELOG.md"
71
73
  ],