@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 +56 -0
- package/README.md +1 -1
- package/README_EN.md +67 -0
- package/lib/client.js +33 -0
- package/lib/index.js +160 -49
- package/package.json +3 -1
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.
|
|
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
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
this.
|
|
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
|
-
|
|
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.
|
|
242
|
-
//
|
|
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 + "\
|
|
310
|
+
toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\n" + exitMark + "\n";
|
|
245
311
|
} else {
|
|
246
|
-
//
|
|
247
|
-
toWrite = "{ " + item.cmd + " ; } 2>&1\
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
],
|