@linxin666/dsh-ssh 0.1.11 → 0.1.13

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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # node scripts/verify-docs.mjs --write <dir>
5
+ README.md: ab9bd6b8b9dfe08a1be7f7dbb3bcd41d086659e0
6
+ README.zh.md: 3972324c8541d87d0603de64be7817f32d3e98ad
package/README.md CHANGED
@@ -1,46 +1,41 @@
1
- # dsh-ssh — 远程 SSH 运维插件(DSH 版 ssh-skill)
1
+ # dsh-ssh — Remote SSH operations plugin for DSH
2
2
 
3
- 基于 [badseal/ssh-skill](https://github.com/badseal/ssh-skill) 的能力清单,为
4
- DeepSeek Harness(DSH)定制的远程 SSH 插件:Host 进程内的持久连接池 + Web GUI
5
- 主机管理面板 + Web 终端 + Agent 工具,全部通过官方 NPM SDK 实现,不修改 DSH
6
- 源码。
3
+ English | [中文](README.zh.md)
7
4
 
8
- ## 能力
5
+ Built on the capability list of [badseal/ssh-skill](https://github.com/badseal/ssh-skill), a remote SSH plugin tailored for DeepSeek Harness (DSH): a persistent connection pool inside the Host process + a Web GUI host-management panel + a Web terminal + Agent tools, all implemented through the official NPM SDK without modifying DSH source.
9
6
 
10
- | 能力 | 说明 |
7
+ ## Capabilities
8
+
9
+ | Capability | Description |
11
10
  | --- | --- |
12
- | 主机管理 | 增删改查、搜索、连接测试;配置存 `~/.dsh/dsh-ssh.json`;支持密钥 / 密码认证、passphrase 密钥、ProxyJump 跳板机(多级) |
13
- | 配置导入 | 一键解析标准 `~/.ssh/config`(Host/HostName/User/Port/IdentityFile/ProxyJump 等),已有别名自动跳过 |
14
- | 持久连接池 | 每台主机复用长连接(对应 ssh-skill 的守护进程),空闲 30 分钟自动断开,断线自动重连(最多 3 次) |
15
- | 命令执行 | exec 带超时(默认 60s),stdout/stderr 分离,输出截断保护(2MB) |
16
- | Web 终端 | xterm.js + WebSocket PTY 终端,自适应尺寸,实时输出 |
17
- | 文件传输 | SFTP 上传(浏览器选文件,NDJSON 进度流)、下载(进度条 + 浏览器保存);远程目录浏览 |
18
- | 端口转发 | 本地端口转发隧道(仅监听 127.0.0.1),访问远程数据库 / 内网服务,支持列表 / 停止 |
19
- | 集群执行 | 一条命令并发跑多台主机(按别名 / 环境 / 标签过滤,默认并发 8) |
20
- | Agent 工具 | `ssh_list` / `ssh_exec` / `ssh_upload` / `ssh_download` / `ssh_tunnel` / `ssh_cluster`,GUI 与 Agent 共享同一份主机配置 |
21
-
22
- ## 安全模型
23
-
24
- - 所有 `/api/dsh-ssh/*` 路由仅限 loopback 访问(含同源校验)——对远程服务器执行
25
- 命令的接口不会暴露给局域网。
26
- - 密码 / 密钥口令以明文保存在 `~/.dsh/dsh-ssh.json`,文件权限 0600、目录 0700
27
- (与 ssh-skill 把密码写进 ssh-config 注释同一信任模型)。
28
- - 隧道只监听 `127.0.0.1`。
29
- - Agent 使用工具前,主机需先在 GUI 中配置(或从 ~/.ssh/config 导入)。
30
- - `ssh_upload` / `ssh_download` 以宿主进程权限直接读写本机任意路径(不经 bash
31
- 沙箱)——与 ssh-skill 的宿主本地路径语义一致,注意该权限面。
32
- - exec / cluster 的远程输出原样返回(不脱敏),命令如 `env` 可能把远端环境中的
33
- 密钥带回对话记录。
34
-
35
- ## 安装
36
-
37
- 推荐直接安装全家桶聚合包 `@linxin666/dsh-web-ui-all`(一个包装齐全部功能插件与皮肤),或单独安装本插件:
11
+ | Host management | CRUD, search, connection test; config stored in `~/.dsh/dsh-ssh.json`; supports key / password auth, passphrase keys, ProxyJump jump hosts (multi-level) |
12
+ | Config import | One-click parse of a standard `~/.ssh/config` (Host/HostName/User/Port/IdentityFile/ProxyJump etc.); existing aliases are skipped |
13
+ | Persistent connection pool | Reuses a long-lived connection per host (opposite of the ssh-skill daemon), automatically disconnects after 30 minutes idle, auto-reconnects on disconnect (up to 3 times) |
14
+ | Command execution | exec with a timeout (default 60s), stdout/stderr separated, output truncation guard (2MB) |
15
+ | Web terminal | xterm.js + WebSocket PTY terminal, auto-sizing, real-time output |
16
+ | File transfer | SFTP upload (browser file picker, NDJSON progress stream), download (progress bar + browser save); remote directory browsing |
17
+ | Port forwarding | Local port-forward tunnel (listens on 127.0.0.1 only) to reach a remote database / intranet service; list / stop supported |
18
+ | Cluster execution | One command run concurrently across many hosts (filter by alias / environment / tag, default concurrency 8) |
19
+ | Agent tools | `ssh_list` / `ssh_exec` / `ssh_upload` / `ssh_download` / `ssh_tunnel` / `ssh_cluster`; GUI and Agent share the same host config |
20
+
21
+ ## Security model
22
+
23
+ - All `/api/dsh-ssh/*` routes are loopback-only (with same-origin checks) — the interfaces that execute commands against remote servers are not exposed to the LAN.
24
+ - Passwords / key passphrases are stored in plain text in `~/.dsh/dsh-ssh.json`, file mode 0600, directory 0700 (the same trust model as ssh-skill writing passwords into ssh-config comments).
25
+ - Tunnels only listen on `127.0.0.1`.
26
+ - Before the Agent uses a tool, the host must first be configured in the GUI (or imported from ~/.ssh/config).
27
+ - `ssh_upload` / `ssh_download` read/write arbitrary local paths on this machine with host-process privileges (not through the bash sandbox) — same host-local-path semantics as ssh-skill, be aware of that permission surface.
28
+ - The remote output of exec / cluster is returned verbatim (not sanitized); a command like `env` may bring secrets from the remote environment back into the conversation log.
29
+
30
+ ## Install
31
+
32
+ Install the family aggregate package `@linxin666/dsh-web-ui-all` (all plugins and skins in one) or this plugin alone:
38
33
 
39
34
  ```sh
40
- # 推荐:直接从 npm 安装
35
+ ### 从 npm 安装(推荐)
41
36
  dsh plugin --profile web add @linxin666/dsh-ssh
42
37
 
43
- # 或从仓库安装(开发调试)
38
+ ### 从仓库安装(开发调试)
44
39
  git clone https://github.com/zhu1090093659/dsh-web-ui.git
45
40
  cd dsh-web-ui
46
41
  pnpm install && pnpm -r build
@@ -48,31 +43,30 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-ssh
48
43
 
49
44
  ```
50
45
 
51
- 安装后**重启 `dsh web`**:侧边栏出现「SSH」入口;Agent 提示词中自动出现插件说明。
46
+ After installing, **restart `dsh web`**: a "SSH" entry appears in the sidebar; the plugin description is injected into the Agent prompt automatically.
52
47
 
53
- ## 配置
48
+ ## Configuration
54
49
 
55
- 设置面板(插件配置)可开关 `announceToAgent`(是否向 Agent 宣告插件)与
56
- `enabled`(总开关)。
50
+ The settings panel (plugin config) toggles `announceToAgent` (whether to announce the plugin to the Agent) and `enabled` (master switch).
57
51
 
58
- ## 数据
52
+ ## Data
59
53
 
60
- - 主机配置:`~/.dsh/dsh-ssh.json`(版本化 JSON,原子写入)
61
- - 传输暂存:`os.tmpdir()/dsh-ssh-uploads/`
54
+ - Host config: `~/.dsh/dsh-ssh.json` (versioned JSON, atomic write)
55
+ - Transfer staging: `os.tmpdir()/dsh-ssh-uploads/`
62
56
 
63
- ## 开发
57
+ ## Development
64
58
 
65
59
  ```sh
66
60
  pnpm install --filter @linxin666/dsh-ssh...
67
- pnpm --filter @linxin666/dsh-ssh test # 单测:store + 引擎(内嵌 ssh2 Server + 真实 sshd)
68
- pnpm --filter @linxin666/dsh-ssh build # tsc 类型 + tsdown 双半区产物
61
+ pnpm --filter @linxin666/dsh-ssh test # unit tests: store + engine (embedded ssh2 Server + real sshd)
62
+ pnpm --filter @linxin666/dsh-ssh build # tsc types + tsdown dual-half artifacts
69
63
  ```
70
64
 
71
- ## 已知限制
65
+ ## Known limitations
72
66
 
73
- - 上传的远程目标路径必须是绝对路径(相对路径会被拒绝)。
74
- - 下载暂不支持整个目录(逐文件下载);上传支持目录递归(walk 本地目录逐文件传)。
75
- - exec 断线自动重连(最多 3 次)可能重复执行非幂等命令——长命令注意副作用。
76
- - 跳板机 ProxyJump 的每一跳必须是本插件已配置的主机别名。
77
- - 断点续传(resume)暂未实现。
78
- - Agent 工具的传输为宿主机器本地路径(与 ssh-skill 相同的语义)。
67
+ - The remote target path of an upload must be an absolute path (relative paths are rejected).
68
+ - Download does not support a whole directory yet (download files individually); upload supports recursive directories (walks the local directory and transfers file by file).
69
+ - exec auto-reconnect on disconnect (up to 3 times) may re-execute non-idempotent commands — watch out for side effects on long commands.
70
+ - Every hop of a ProxyJump jump host must be a host alias already configured in this plugin.
71
+ - Resume (broken-transfer continuation) is not implemented yet.
72
+ - The transfer of Agent tools is a host-machine local path (same semantics as ssh-skill).
package/README.zh.md ADDED
@@ -0,0 +1,72 @@
1
+ # dsh-ssh — 远程 SSH 运维插件(DSH 版 ssh-skill)
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 基于 [badseal/ssh-skill](https://github.com/badseal/ssh-skill) 的能力清单,为 DeepSeek Harness(DSH)定制的远程 SSH 插件:Host 进程内的持久连接池 + Web GUI 主机管理面板 + Web 终端 + Agent 工具,全部通过官方 NPM SDK 实现,不修改 DSH 源码。
6
+
7
+ ## 能力
8
+
9
+ | 能力 | 说明 |
10
+ | --- | --- |
11
+ | 主机管理 | 增删改查、搜索、连接测试;配置存 `~/.dsh/dsh-ssh.json`;支持密钥 / 密码认证、passphrase 密钥、ProxyJump 跳板机(多级) |
12
+ | 配置导入 | 一键解析标准 `~/.ssh/config`(Host/HostName/User/Port/IdentityFile/ProxyJump 等),已有别名自动跳过 |
13
+ | 持久连接池 | 每台主机复用长连接(对应 ssh-skill 的守护进程),空闲 30 分钟自动断开,断线自动重连(最多 3 次) |
14
+ | 命令执行 | exec 带超时(默认 60s),stdout/stderr 分离,输出截断保护(2MB) |
15
+ | Web 终端 | xterm.js + WebSocket PTY 终端,自适应尺寸,实时输出 |
16
+ | 文件传输 | SFTP 上传(浏览器选文件,NDJSON 进度流)、下载(进度条 + 浏览器保存);远程目录浏览 |
17
+ | 端口转发 | 本地端口转发隧道(仅监听 127.0.0.1),访问远程数据库 / 内网服务,支持列表 / 停止 |
18
+ | 集群执行 | 一条命令并发跑多台主机(按别名 / 环境 / 标签过滤,默认并发 8) |
19
+ | Agent 工具 | `ssh_list` / `ssh_exec` / `ssh_upload` / `ssh_download` / `ssh_tunnel` / `ssh_cluster`,GUI 与 Agent 共享同一份主机配置 |
20
+
21
+ ## 安全模型
22
+
23
+ - 所有 `/api/dsh-ssh/*` 路由仅限 loopback 访问(含同源校验)——对远程服务器执行命令的接口不会暴露给局域网。
24
+ - 密码 / 密钥口令以明文保存在 `~/.dsh/dsh-ssh.json`,文件权限 0600、目录 0700(与 ssh-skill 把密码写进 ssh-config 注释同一信任模型)。
25
+ - 隧道只监听 `127.0.0.1`。
26
+ - Agent 使用工具前,主机需先在 GUI 中配置(或从 ~/.ssh/config 导入)。
27
+ - `ssh_upload` / `ssh_download` 以宿主进程权限直接读写本机任意路径(不经 bash 沙箱)——与 ssh-skill 的宿主本地路径语义一致,注意该权限面。
28
+ - exec / cluster 的远程输出原样返回(不脱敏),命令如 `env` 可能把远端环境中的密钥带回对话记录。
29
+
30
+ ## 安装
31
+
32
+ 推荐直接安装全家桶聚合包 `@linxin666/dsh-web-ui-all`(一个包装齐全部功能插件与皮肤),或单独安装本插件:
33
+
34
+ ```sh
35
+ ### 从 npm 安装(推荐)
36
+ dsh plugin --profile web add @linxin666/dsh-ssh
37
+
38
+ ### 从仓库安装(开发调试)
39
+ git clone https://github.com/zhu1090093659/dsh-web-ui.git
40
+ cd dsh-web-ui
41
+ pnpm install && pnpm -r build
42
+ dsh plugin --profile web add link:$(pwd)/packages/dsh-ssh
43
+
44
+ ```
45
+
46
+ 安装后**重启 `dsh web`**:侧边栏出现「SSH」入口;Agent 提示词中自动出现插件说明。
47
+
48
+ ## 配置
49
+
50
+ 设置面板(插件配置)可开关 `announceToAgent`(是否向 Agent 宣告插件)与 `enabled`(总开关)。
51
+
52
+ ## 数据
53
+
54
+ - 主机配置:`~/.dsh/dsh-ssh.json`(版本化 JSON,原子写入)
55
+ - 传输暂存:`os.tmpdir()/dsh-ssh-uploads/`
56
+
57
+ ## 开发
58
+
59
+ ```sh
60
+ pnpm install --filter @linxin666/dsh-ssh...
61
+ pnpm --filter @linxin666/dsh-ssh test # 单测:store + 引擎(内嵌 ssh2 Server + 真实 sshd)
62
+ pnpm --filter @linxin666/dsh-ssh build # tsc 类型 + tsdown 双半区产物
63
+ ```
64
+
65
+ ## 已知限制
66
+
67
+ - 上传的远程目标路径必须是绝对路径(相对路径会被拒绝)。
68
+ - 下载暂不支持整个目录(逐文件下载);上传支持目录递归(walk 本地目录逐文件传)。
69
+ - exec 断线自动重连(最多 3 次)可能重复执行非幂等命令——长命令注意副作用。
70
+ - 跳板机 ProxyJump 的每一跳必须是本插件已配置的主机别名。
71
+ - 断点续传(resume)暂未实现。
72
+ - Agent 工具的传输为宿主机器本地路径(与 ssh-skill 相同的语义)。
package/lib/client.js CHANGED
@@ -594,8 +594,8 @@ window.__ModuleLoader__.load({
594
594
  return String(error);
595
595
  }
596
596
  //#endregion
597
- //#region \0dsh-css:/home/runner/work/dsh-web-ui/dsh-web-ui/packages/dsh-ssh/src/client/panel/panel.module.css.mjs
598
- const css = "[data-pane=conversation]{position:relative}[data-dsh-ssh-view]{z-index:60;display:none;position:absolute;inset:0}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane=conversation]>:not([data-dsh-ssh-view]){display:none!important}._X8bTW_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}._X8bTW_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}._X8bTW_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}._X8bTW_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}._X8bTW_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] ._X8bTW_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] ._X8bTW_entryLabel{display:none}._X8bTW_view{overflow:hidden}._X8bTW_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}._X8bTW_panelHeader{flex:none;align-items:center;gap:10px;display:flex}._X8bTW_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}._X8bTW_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}._X8bTW_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}._X8bTW_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}._X8bTW_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}._X8bTW_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}._X8bTW_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow-y:auto}._X8bTW_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}._X8bTW_toolbar,._X8bTW_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}._X8bTW_controls ._X8bTW_input{flex:0 260px;min-width:150px}._X8bTW_toolbarSpacer{flex:1}._X8bTW_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}._X8bTW_search::placeholder{color:var(--dsw-alias-label-tertiary)}._X8bTW_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}._X8bTW_table{border-collapse:collapse;width:100%;font-size:12.5px}._X8bTW_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}._X8bTW_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}._X8bTW_table tbody tr:last-child td{border-bottom:none}._X8bTW_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}._X8bTW_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}._X8bTW_cellMuted{color:var(--dsw-alias-label-tertiary)}._X8bTW_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}._X8bTW_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}._X8bTW_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}._X8bTW_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}._X8bTW_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}._X8bTW_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}._X8bTW_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}._X8bTW_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}._X8bTW_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}._X8bTW_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}._X8bTW_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}._X8bTW_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}._X8bTW_primaryButton:disabled{opacity:.5;cursor:default}._X8bTW_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}._X8bTW_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._X8bTW_ghostButton:disabled{opacity:.45;cursor:default}._X8bTW_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}._X8bTW_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}._X8bTW_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}._X8bTW_linkButton:hover:not(:disabled){text-decoration:underline}._X8bTW_linkButton:disabled{opacity:.45;cursor:default}._X8bTW_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}._X8bTW_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite _X8bTW_dshSshSpin;display:inline-block}@keyframes _X8bTW_dshSshSpin{to{transform:rotate(360deg)}}._X8bTW_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}._X8bTW_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}._X8bTW_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}._X8bTW_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}._X8bTW_empty,._X8bTW_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}._X8bTW_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1);justify-content:center;align-items:center;display:flex;position:fixed;inset:0}._X8bTW_modal{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);width:min(560px,100vw - 48px);max-height:calc(100vh - 96px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:14px;flex-direction:column;gap:12px;padding:18px;display:flex;overflow-y:auto}._X8bTW_modalTitle{margin:0;font-size:15px;font-weight:700}._X8bTW_modalFooter{justify-content:flex-end;gap:10px;margin-top:4px;display:flex}._X8bTW_formError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}._X8bTW_hint{color:var(--dsw-alias-label-tertiary);font-size:11.5px}._X8bTW_field{flex-direction:column;gap:5px;display:flex}._X8bTW_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}._X8bTW_input{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);resize:vertical;border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px}._X8bTW_input:focus{border-color:var(--dsw-alias-state-business-primary)}._X8bTW_input::placeholder{color:var(--dsw-alias-label-tertiary)}._X8bTW_input:disabled{opacity:.55}._X8bTW_formRow{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 12px;display:grid}._X8bTW_radioRow{align-items:center;gap:16px;display:flex}._X8bTW_radioLabel{color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:6px;font-size:13px;display:inline-flex}._X8bTW_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}._X8bTW_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}._X8bTW_termContainer{padding:8px 10px;position:absolute;inset:0}._X8bTW_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}._X8bTW_hiddenFile{display:none}._X8bTW_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}._X8bTW_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}._X8bTW_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}._X8bTW_browseList{max-height:240px;overflow-y:auto}._X8bTW_dirRow{text-align:left;width:100%;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}._X8bTW_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}._X8bTW_dirRow[data-type=dir] ._X8bTW_dirName{color:var(--dsw-alias-state-business-primary)}._X8bTW_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}._X8bTW_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}._X8bTW_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}._X8bTW_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}._X8bTW_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}._X8bTW_progressMeta{color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}._X8bTW_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}._X8bTW_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}._X8bTW_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}._X8bTW_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}._X8bTW_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}._X8bTW_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}._X8bTW_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}._X8bTW_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}._X8bTW_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}._X8bTW_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}._X8bTW_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}._X8bTW_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}._X8bTW_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}._X8bTW_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}";
597
+ //#region \0dsh-css:packages/dsh-ssh/src/client/panel/panel.module.css.mjs
598
+ const css = "[data-pane=conversation]{position:relative}[data-dsh-ssh-view]{z-index:60;background:var(--dsw-alias-bg-base);display:none;position:absolute;inset:0}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane=conversation]>:not([data-dsh-ssh-view]),html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*=centerCol]>:not([data-dsh-ssh-view]){display:none!important}.mL8Uca_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}.mL8Uca_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}.mL8Uca_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}.mL8Uca_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entryLabel{display:none}.mL8Uca_view{overflow:hidden}.mL8Uca_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}.mL8Uca_panelHeader{flex:none;align-items:center;gap:10px;display:flex}.mL8Uca_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.mL8Uca_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.mL8Uca_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.mL8Uca_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.mL8Uca_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.mL8Uca_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow-y:auto}.mL8Uca_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.mL8Uca_toolbar,.mL8Uca_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.mL8Uca_controls .mL8Uca_input{flex:0 260px;min-width:150px}.mL8Uca_toolbarSpacer{flex:1}.mL8Uca_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.mL8Uca_search::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.mL8Uca_table{border-collapse:collapse;width:100%;font-size:12.5px}.mL8Uca_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.mL8Uca_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.mL8Uca_table tbody tr:last-child td{border-bottom:none}.mL8Uca_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellMuted{color:var(--dsw-alias-label-tertiary)}.mL8Uca_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.mL8Uca_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.mL8Uca_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.mL8Uca_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.mL8Uca_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.mL8Uca_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.mL8Uca_primaryButton:disabled{opacity:.5;cursor:default}.mL8Uca_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.mL8Uca_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_ghostButton:disabled{opacity:.45;cursor:default}.mL8Uca_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}.mL8Uca_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.mL8Uca_linkButton:hover:not(:disabled){text-decoration:underline}.mL8Uca_linkButton:disabled{opacity:.45;cursor:default}.mL8Uca_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite mL8Uca_dshSshSpin;display:inline-block}@keyframes mL8Uca_dshSshSpin{to{transform:rotate(360deg)}}.mL8Uca_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.mL8Uca_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_empty,.mL8Uca_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.mL8Uca_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1);justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.mL8Uca_modal{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);width:min(560px,100vw - 48px);max-height:calc(100vh - 96px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:14px;flex-direction:column;gap:12px;padding:18px;display:flex;overflow-y:auto}.mL8Uca_modalTitle{margin:0;font-size:15px;font-weight:700}.mL8Uca_modalFooter{justify-content:flex-end;gap:10px;margin-top:4px;display:flex}.mL8Uca_formError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.mL8Uca_hint{color:var(--dsw-alias-label-tertiary);font-size:11.5px}.mL8Uca_field{flex-direction:column;gap:5px;display:flex}.mL8Uca_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}.mL8Uca_input{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);resize:vertical;border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px}.mL8Uca_input:focus{border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_input::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_input:disabled{opacity:.55}.mL8Uca_formRow{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 12px;display:grid}.mL8Uca_radioRow{align-items:center;gap:16px;display:flex}.mL8Uca_radioLabel{color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:6px;font-size:13px;display:inline-flex}.mL8Uca_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.mL8Uca_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.mL8Uca_termContainer{padding:8px 10px;position:absolute;inset:0}.mL8Uca_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.mL8Uca_hiddenFile{display:none}.mL8Uca_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.mL8Uca_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.mL8Uca_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.mL8Uca_browseList{max-height:240px;overflow-y:auto}.mL8Uca_dirRow{text-align:left;width:100%;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.mL8Uca_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_dirRow[data-type=dir] .mL8Uca_dirName{color:var(--dsw-alias-state-business-primary)}.mL8Uca_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.mL8Uca_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.mL8Uca_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.mL8Uca_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.mL8Uca_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.mL8Uca_progressMeta{color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.mL8Uca_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.mL8Uca_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.mL8Uca_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.mL8Uca_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.mL8Uca_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.mL8Uca_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.mL8Uca_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.mL8Uca_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}";
599
599
  const tagId = "@linxin666/dsh-ssh/panel.module.css";
600
600
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
601
601
  const tag = document.createElement("style");
@@ -605,76 +605,76 @@ window.__ModuleLoader__.load({
605
605
  document.head.appendChild(tag);
606
606
  }
607
607
  var panel_module_css_default = {
608
- "actions": "_X8bTW_actions",
609
- "badge": "_X8bTW_badge",
610
- "banner": "_X8bTW_banner",
611
- "browseHeader": "_X8bTW_browseHeader",
612
- "browseList": "_X8bTW_browseList",
613
- "browsePanel": "_X8bTW_browsePanel",
614
- "browsePath": "_X8bTW_browsePath",
615
- "cellDetails": "_X8bTW_cellDetails",
616
- "cellMuted": "_X8bTW_cellMuted",
617
- "cellPre": "_X8bTW_cellPre",
618
- "clusterFilters": "_X8bTW_clusterFilters",
619
- "clusterForm": "_X8bTW_clusterForm",
620
- "commandInput": "_X8bTW_commandInput",
621
- "controls": "_X8bTW_controls",
622
- "dirName": "_X8bTW_dirName",
623
- "dirRow": "_X8bTW_dirRow",
624
- "dirSize": "_X8bTW_dirSize",
625
- "dirType": "_X8bTW_dirType",
626
- "dshSshSpin": "_X8bTW_dshSshSpin",
627
- "empty": "_X8bTW_empty",
628
- "entry": "_X8bTW_entry",
629
- "entryIcon": "_X8bTW_entryIcon",
630
- "entryLabel": "_X8bTW_entryLabel",
631
- "field": "_X8bTW_field",
632
- "fieldLabel": "_X8bTW_fieldLabel",
633
- "fillBody": "_X8bTW_fillBody",
634
- "formCard": "_X8bTW_formCard",
635
- "formError": "_X8bTW_formError",
636
- "formRow": "_X8bTW_formRow",
637
- "ghostButton": "_X8bTW_ghostButton",
638
- "hiddenFile": "_X8bTW_hiddenFile",
639
- "hint": "_X8bTW_hint",
640
- "iconButton": "_X8bTW_iconButton",
641
- "inlineTest": "_X8bTW_inlineTest",
642
- "input": "_X8bTW_input",
643
- "linkButton": "_X8bTW_linkButton",
644
- "loading": "_X8bTW_loading",
645
- "modal": "_X8bTW_modal",
646
- "modalBackdrop": "_X8bTW_modalBackdrop",
647
- "modalFooter": "_X8bTW_modalFooter",
648
- "modalTitle": "_X8bTW_modalTitle",
649
- "mono": "_X8bTW_mono",
650
- "panel": "_X8bTW_panel",
651
- "panelContent": "_X8bTW_panelContent",
652
- "panelHeader": "_X8bTW_panelHeader",
653
- "panelTitle": "_X8bTW_panelTitle",
654
- "primaryButton": "_X8bTW_primaryButton",
655
- "progressBar": "_X8bTW_progressBar",
656
- "progressMeta": "_X8bTW_progressMeta",
657
- "progressTrack": "_X8bTW_progressTrack",
658
- "radioLabel": "_X8bTW_radioLabel",
659
- "radioRow": "_X8bTW_radioRow",
660
- "search": "_X8bTW_search",
661
- "spinner": "_X8bTW_spinner",
662
- "tab": "_X8bTW_tab",
663
- "tabBar": "_X8bTW_tabBar",
664
- "tabBody": "_X8bTW_tabBody",
665
- "table": "_X8bTW_table",
666
- "tableWrap": "_X8bTW_tableWrap",
667
- "termBody": "_X8bTW_termBody",
668
- "termContainer": "_X8bTW_termContainer",
669
- "termPlaceholder": "_X8bTW_termPlaceholder",
670
- "termWrap": "_X8bTW_termWrap",
671
- "toolbar": "_X8bTW_toolbar",
672
- "toolbarSpacer": "_X8bTW_toolbarSpacer",
673
- "transferBlock": "_X8bTW_transferBlock",
674
- "tunnelLabel": "_X8bTW_tunnelLabel",
675
- "tunnelList": "_X8bTW_tunnelList",
676
- "tunnelRow": "_X8bTW_tunnelRow",
677
- "view": "_X8bTW_view"
608
+ "actions": "mL8Uca_actions",
609
+ "badge": "mL8Uca_badge",
610
+ "banner": "mL8Uca_banner",
611
+ "browseHeader": "mL8Uca_browseHeader",
612
+ "browseList": "mL8Uca_browseList",
613
+ "browsePanel": "mL8Uca_browsePanel",
614
+ "browsePath": "mL8Uca_browsePath",
615
+ "cellDetails": "mL8Uca_cellDetails",
616
+ "cellMuted": "mL8Uca_cellMuted",
617
+ "cellPre": "mL8Uca_cellPre",
618
+ "clusterFilters": "mL8Uca_clusterFilters",
619
+ "clusterForm": "mL8Uca_clusterForm",
620
+ "commandInput": "mL8Uca_commandInput",
621
+ "controls": "mL8Uca_controls",
622
+ "dirName": "mL8Uca_dirName",
623
+ "dirRow": "mL8Uca_dirRow",
624
+ "dirSize": "mL8Uca_dirSize",
625
+ "dirType": "mL8Uca_dirType",
626
+ "dshSshSpin": "mL8Uca_dshSshSpin",
627
+ "empty": "mL8Uca_empty",
628
+ "entry": "mL8Uca_entry",
629
+ "entryIcon": "mL8Uca_entryIcon",
630
+ "entryLabel": "mL8Uca_entryLabel",
631
+ "field": "mL8Uca_field",
632
+ "fieldLabel": "mL8Uca_fieldLabel",
633
+ "fillBody": "mL8Uca_fillBody",
634
+ "formCard": "mL8Uca_formCard",
635
+ "formError": "mL8Uca_formError",
636
+ "formRow": "mL8Uca_formRow",
637
+ "ghostButton": "mL8Uca_ghostButton",
638
+ "hiddenFile": "mL8Uca_hiddenFile",
639
+ "hint": "mL8Uca_hint",
640
+ "iconButton": "mL8Uca_iconButton",
641
+ "inlineTest": "mL8Uca_inlineTest",
642
+ "input": "mL8Uca_input",
643
+ "linkButton": "mL8Uca_linkButton",
644
+ "loading": "mL8Uca_loading",
645
+ "modal": "mL8Uca_modal",
646
+ "modalBackdrop": "mL8Uca_modalBackdrop",
647
+ "modalFooter": "mL8Uca_modalFooter",
648
+ "modalTitle": "mL8Uca_modalTitle",
649
+ "mono": "mL8Uca_mono",
650
+ "panel": "mL8Uca_panel",
651
+ "panelContent": "mL8Uca_panelContent",
652
+ "panelHeader": "mL8Uca_panelHeader",
653
+ "panelTitle": "mL8Uca_panelTitle",
654
+ "primaryButton": "mL8Uca_primaryButton",
655
+ "progressBar": "mL8Uca_progressBar",
656
+ "progressMeta": "mL8Uca_progressMeta",
657
+ "progressTrack": "mL8Uca_progressTrack",
658
+ "radioLabel": "mL8Uca_radioLabel",
659
+ "radioRow": "mL8Uca_radioRow",
660
+ "search": "mL8Uca_search",
661
+ "spinner": "mL8Uca_spinner",
662
+ "tab": "mL8Uca_tab",
663
+ "tabBar": "mL8Uca_tabBar",
664
+ "tabBody": "mL8Uca_tabBody",
665
+ "table": "mL8Uca_table",
666
+ "tableWrap": "mL8Uca_tableWrap",
667
+ "termBody": "mL8Uca_termBody",
668
+ "termContainer": "mL8Uca_termContainer",
669
+ "termPlaceholder": "mL8Uca_termPlaceholder",
670
+ "termWrap": "mL8Uca_termWrap",
671
+ "toolbar": "mL8Uca_toolbar",
672
+ "toolbarSpacer": "mL8Uca_toolbarSpacer",
673
+ "transferBlock": "mL8Uca_transferBlock",
674
+ "tunnelLabel": "mL8Uca_tunnelLabel",
675
+ "tunnelList": "mL8Uca_tunnelList",
676
+ "tunnelRow": "mL8Uca_tunnelRow",
677
+ "view": "mL8Uca_view"
678
678
  };
679
679
  //#endregion
680
680
  //#region src/client/panel/ClusterTab.tsx
package/lib/index.js CHANGED
@@ -1741,8 +1741,7 @@ var SshEngine = class {
1741
1741
  if (!remotePath.startsWith("/")) throw new Error(`remotePath must be an absolute path (got '${remotePath}')`);
1742
1742
  const local = resolve(localPath);
1743
1743
  if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`);
1744
- return this.withClient(alias, async (client) => {
1745
- const sftp = await this.sftp(client);
1744
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1746
1745
  const stat = statSync(local);
1747
1746
  let files;
1748
1747
  if (stat.isDirectory()) {
@@ -1765,12 +1764,11 @@ var SshEngine = class {
1765
1764
  bytes,
1766
1765
  files: files.length
1767
1766
  };
1768
- });
1767
+ }));
1769
1768
  }
1770
1769
  /** Download one remote file to a local path. */
1771
1770
  async download(alias, remotePath, localPath, onProgress) {
1772
- return this.withClient(alias, async (client) => {
1773
- const sftp = await this.sftp(client);
1771
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1774
1772
  if ((await new Promise((resolve, reject) => {
1775
1773
  sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
1776
1774
  })).isDirectory()) throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`);
@@ -1778,12 +1776,11 @@ var SshEngine = class {
1778
1776
  if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
1779
1777
  await this.fastGet(sftp, remotePath, local, onProgress);
1780
1778
  return { bytes: statSync(local).size };
1781
- });
1779
+ }));
1782
1780
  }
1783
1781
  /** List a remote directory (file browser). */
1784
1782
  async ls(alias, path) {
1785
- return this.withClient(alias, async (client) => {
1786
- const sftp = await this.sftp(client);
1783
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1787
1784
  return await new Promise((resolve, reject) => {
1788
1785
  sftp.readdir(path, (error, list) => {
1789
1786
  if (error !== void 0) {
@@ -1799,7 +1796,30 @@ var SshEngine = class {
1799
1796
  })));
1800
1797
  });
1801
1798
  });
1802
- });
1799
+ }));
1800
+ }
1801
+ /**
1802
+ * Open one SFTP channel, run the operation, and release the channel exactly
1803
+ * once when the operation settles (success or error). ssh2 keeps each
1804
+ * subsystem channel open until end(); without this, every transfer leaks a
1805
+ * channel until sshd's MaxSessions cap makes all later opens fail.
1806
+ */
1807
+ async withSftp(client, run) {
1808
+ const sftp = await this.sftp(client);
1809
+ let ended = false;
1810
+ const endOnce = () => {
1811
+ if (ended) return;
1812
+ ended = true;
1813
+ try {
1814
+ sftp.end();
1815
+ } catch {}
1816
+ };
1817
+ sftp.once("close", endOnce);
1818
+ try {
1819
+ return await run(sftp);
1820
+ } finally {
1821
+ endOnce();
1822
+ }
1803
1823
  }
1804
1824
  sftp(client) {
1805
1825
  return new Promise((resolve, reject) => {
@@ -111,6 +111,13 @@ export declare class SshEngine {
111
111
  }>;
112
112
  /** List a remote directory (file browser). */
113
113
  ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]>;
114
+ /**
115
+ * Open one SFTP channel, run the operation, and release the channel exactly
116
+ * once when the operation settles (success or error). ssh2 keeps each
117
+ * subsystem channel open until end(); without this, every transfer leaks a
118
+ * channel until sshd's MaxSessions cap makes all later opens fail.
119
+ */
120
+ private withSftp;
114
121
  private sftp;
115
122
  /** Create a remote directory chain (stat-then-mkdir per segment). */
116
123
  private ensureRemoteDir;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.11",
4
+ "version": "0.1.13",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -44,16 +44,6 @@
44
44
  "ws": "^8.18.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
48
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
49
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
51
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
52
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
57
47
  "react": "^18.2.0",
58
48
  "react-dom": "^18.2.0"
59
49
  },
@@ -93,7 +83,7 @@
93
83
  "cordis.patch.yml",
94
84
  "README.md"
95
85
  ],
96
- "license": "BSD-3-Clause",
86
+ "license": "Apache-2.0",
97
87
  "repository": {
98
88
  "type": "git",
99
89
  "url": "https://github.com/zhu1090093659/dsh-web-ui.git"
@@ -22,6 +22,11 @@
22
22
  panel and its modals paint over the input card, mirroring the task board
23
23
  (issue #87). */
24
24
  z-index: 60;
25
+ /* Opaque backdrop: the conversation subtree stays mounted under the panel.
26
+ Even if the hide rule below were defeated by a stronger host inline
27
+ style, this background keeps sticky code-block banners (host z-index 6)
28
+ from showing through the active panel (issue #100). */
29
+ background: var(--dsw-alias-bg-base);
25
30
  }
26
31
 
27
32
  /* The center column is single-occupant; the :not() guards keep the two
@@ -32,8 +37,11 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view] {
32
37
  }
33
38
 
34
39
  /* While the panel is active, the conversation content underneath is hidden
35
- (it stays mounted and stateful). */
36
- html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]) {
40
+ (it stays mounted and stateful). The rule targets both the pane attribute
41
+ and the center-column class so the chat subtree (including the code-block
42
+ copy banner, issue #100) stays hidden whichever host container carries it. */
43
+ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]),
44
+ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol'] > :not([data-dsh-ssh-view]) {
37
45
  display: none !important;
38
46
  }
39
47
 
package/src/engine.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createServer, type Server as NetServer } from 'node:net'
9
9
  import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs'
10
10
  import { dirname, join, relative, resolve as resolvePath } from 'node:path'
11
- import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
11
+ import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from 'ssh2'
12
12
  import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
13
13
  import { expandHome, type HostStore } from './store.ts'
14
14
 
@@ -490,8 +490,7 @@ export class SshEngine {
490
490
  }
491
491
  const local = resolvePath(localPath)
492
492
  if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
493
- return this.withClient(alias, async (client) => {
494
- const sftp = await this.sftp(client)
493
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
495
494
  const stat = statSync(local)
496
495
  let files: string[]
497
496
  if (stat.isDirectory()) {
@@ -512,13 +511,12 @@ export class SshEngine {
512
511
  bytes += statSync(src).size
513
512
  }
514
513
  return { bytes, files: files.length }
515
- })
514
+ }))
516
515
  }
517
516
 
518
517
  /** Download one remote file to a local path. */
519
518
  async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
520
- return this.withClient(alias, async (client) => {
521
- const sftp = await this.sftp(client)
519
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
522
520
  const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
523
521
  sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
524
522
  })
@@ -529,13 +527,12 @@ export class SshEngine {
529
527
  if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
530
528
  await this.fastGet(sftp, remotePath, local, onProgress)
531
529
  return { bytes: statSync(local).size }
532
- })
530
+ }))
533
531
  }
534
532
 
535
533
  /** List a remote directory (file browser). */
536
534
  async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
537
- return this.withClient(alias, async (client) => {
538
- const sftp = await this.sftp(client)
535
+ return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
539
536
  return await new Promise((resolve, reject) => {
540
537
  sftp.readdir(path, (error, list) => {
541
538
  if (error !== undefined) {
@@ -552,17 +549,41 @@ export class SshEngine {
552
549
  resolve(entries)
553
550
  })
554
551
  })
555
- })
552
+ }))
553
+ }
554
+
555
+ /**
556
+ * Open one SFTP channel, run the operation, and release the channel exactly
557
+ * once when the operation settles (success or error). ssh2 keeps each
558
+ * subsystem channel open until end(); without this, every transfer leaks a
559
+ * channel until sshd's MaxSessions cap makes all later opens fail.
560
+ */
561
+ private async withSftp<T>(client: Client, run: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
562
+ const sftp = await this.sftp(client)
563
+ let ended = false
564
+ const endOnce = (): void => {
565
+ if (ended) return
566
+ ended = true
567
+ try { sftp.end() } catch { /* channel already closed */ }
568
+ }
569
+ // The channel can also close underneath us (peer reset, timeout); the
570
+ // guard makes the finally below a no-op instead of ending it twice.
571
+ sftp.once('close', endOnce)
572
+ try {
573
+ return await run(sftp)
574
+ } finally {
575
+ endOnce()
576
+ }
556
577
  }
557
578
 
558
- private sftp(client: Client): Promise<import('ssh2').SFTPWrapper> {
579
+ private sftp(client: Client): Promise<SFTPWrapper> {
559
580
  return new Promise((resolve, reject) => {
560
581
  client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
561
582
  })
562
583
  }
563
584
 
564
585
  /** Create a remote directory chain (stat-then-mkdir per segment). */
565
- private ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
586
+ private ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
566
587
  return new Promise((resolve, reject) => {
567
588
  const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
568
589
  const walk = (index: number): void => {
@@ -591,7 +612,7 @@ export class SshEngine {
591
612
  })
592
613
  }
593
614
 
594
- private fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
615
+ private fastPut(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
595
616
  return new Promise((resolve, reject) => {
596
617
  let last = 0
597
618
  let lastEmit = 0
@@ -625,7 +646,7 @@ export class SshEngine {
625
646
  })
626
647
  }
627
648
 
628
- private fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
649
+ private fastGet(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
629
650
  return new Promise((resolve, reject) => {
630
651
  let last = 0
631
652
  let lastEmit = 0