@bingzi-233/ssh-mcp 1.0.0 → 1.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/README.md +10 -33
- package/dist/config.js +65 -10
- package/dist/index.js +89 -5
- package/dist/ssh.js +49 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `list_servers` | 列出所有已配置的服务器(name / 描述 / 地址 / 用户,**不含密码私钥**) |
|
|
10
10
|
| `run_command` | 在指定 `name` 的服务器上执行一条命令,返回 stdout / stderr / 退出码 |
|
|
11
|
+
| `open_session` | 建立到服务器的长连接会话,返回会话 id 供 `run_command` 复用 |
|
|
12
|
+
| `close_session` | 关闭长连接会话 |
|
|
13
|
+
| `list_sessions` | 列出当前所有活跃的长连接会话 |
|
|
11
14
|
| `upload_file` | 上传本机文件到远程(后台任务,支持断点续传) |
|
|
12
15
|
| `download_file` | 从远程下载文件到本机(后台任务,支持断点续传) |
|
|
13
16
|
| `transfer_status` | 查询传输进度(已传字节、百分比、速度、ETA、状态) |
|
|
14
17
|
| `cancel_transfer` | 取消一个进行中的传输(已传部分保留,可续传) |
|
|
15
18
|
|
|
19
|
+
> - **长连接**:用 `open_session` 建立持久连接,`run_command` 传入 `session` 参数复用,省去重复握手和认证开销。即便复用了连接,每条命令仍在独立 channel 中执行,工作目录和环境变量不保留。
|
|
20
|
+
> - **安全策略**:内置拦截 `rm -rf /`、`dd` 写块设备、`mkfs`、fork 炸弹等高危命令。可在 `servers.json` 的 `security.blocked_patterns` 中追加自定义正则。传入 `force=true` 可绕过。
|
|
16
21
|
> - 命令在独立会话中执行,**命令之间不保留工作目录和环境变量**。需要保持上下文时自行串接,例如 `cd /var/www && git pull`。
|
|
17
22
|
> - 大文件传输是**后台任务**:`upload_file`/`download_file` 立即返回一个传输 id,用 `transfer_status` 轮询进度,不阻塞。
|
|
18
23
|
> - **断点续传**基于目标文件的实际大小:对同一对路径再次发起传输会自动从已传字节处继续,进程重启后依然有效。
|
|
@@ -54,10 +59,13 @@ claude mcp add ssh -- node /绝对路径/ssh-mcp/dist/index.js
|
|
|
54
59
|
|
|
55
60
|
## 配置服务器
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
默认从**运行目录下的 `./servers.json`** 读取(即 Claude 启动 MCP 服务器时所在的工作目录,通常是你当前的项目根目录)。也可用环境变量 `SSH_MCP_CONFIG` 指定任意路径。参考 `servers.example.json`:
|
|
58
63
|
|
|
59
64
|
```json
|
|
60
65
|
{
|
|
66
|
+
"security": {
|
|
67
|
+
"blocked_patterns": []
|
|
68
|
+
},
|
|
61
69
|
"servers": [
|
|
62
70
|
{
|
|
63
71
|
"name": "prod-web",
|
|
@@ -88,37 +96,6 @@ claude mcp add ssh -- node /绝对路径/ssh-mcp/dist/index.js
|
|
|
88
96
|
|
|
89
97
|
---
|
|
90
98
|
|
|
91
|
-
## 发布到 npm(维护者)
|
|
92
|
-
|
|
93
|
-
仓库已配置 GitHub Actions,推送 `vX.Y.Z` 形式的 tag 即自动发布到 npm。
|
|
94
|
-
|
|
95
|
-
**一次性准备**:
|
|
96
|
-
|
|
97
|
-
1. 在 npmjs.com 创建一个 **Automation** 类型的 Access Token。
|
|
98
|
-
2. 把它加到仓库 Secrets:
|
|
99
|
-
```bash
|
|
100
|
-
gh secret set NPM_TOKEN
|
|
101
|
-
```
|
|
102
|
-
(或在 GitHub 网页 Settings → Secrets and variables → Actions 添加。)
|
|
103
|
-
|
|
104
|
-
> 包名 scope `@bingzi-233` 必须是你拥有的 npm 用户名或组织。若不一致,改 `package.json` 的 `name` 与插件 `.mcp.json` 里的 `args`。
|
|
105
|
-
|
|
106
|
-
**每次发版**:
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
# 1. 同步版本号(两处都改:npm 包与插件)
|
|
110
|
-
# - package.json 的 "version"
|
|
111
|
-
# - plugins/ssh-mcp/.claude-plugin/plugin.json 的 "version"
|
|
112
|
-
# 2. 提交并打 tag
|
|
113
|
-
git commit -am "release: v1.0.1"
|
|
114
|
-
git tag v1.0.1
|
|
115
|
-
git push && git push --tags
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Actions 会自动 `npm ci && npm run build && npm publish`(带 provenance 来源证明)。
|
|
119
|
-
|
|
120
|
-
---
|
|
121
|
-
|
|
122
99
|
## ⚠️ 安全提示
|
|
123
100
|
|
|
124
101
|
这个工具允许模型在你的服务器上执行**任意命令**、读写文件。请仅指向你拥有/授权的机器:
|
|
@@ -126,7 +103,7 @@ Actions 会自动 `npm ci && npm run build && npm publish`(带 provenance 来
|
|
|
126
103
|
- `servers.json` 含明文凭据,已在 `.gitignore` 中排除,**切勿提交到仓库**。
|
|
127
104
|
- 优先用私钥或 `ssh-agent`,尽量不在配置里写明文密码。
|
|
128
105
|
- 给 SSH 账号最小权限;高危机器考虑用受限账号。
|
|
129
|
-
- `
|
|
106
|
+
- 内置命令安全策略会拦截 `rm -rf /`、`dd` 写块设备、`mkfs`、fork 炸弹等高危模式。可在 `security.blocked_patterns` 中追加自定义正则(JSON 字符串,需双重转义,如 `"rm\\\\s.*-rf?\\\\s+/\\\\*"`)。传 `force=true` 可绕过。
|
|
130
107
|
|
|
131
108
|
## License
|
|
132
109
|
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
/** 把开头的 ~ 展开成用户主目录。 */
|
|
5
5
|
export function expandHome(p) {
|
|
6
6
|
if (p === "~")
|
|
@@ -9,25 +9,28 @@ export function expandHome(p) {
|
|
|
9
9
|
return join(homedir(), p.slice(2));
|
|
10
10
|
return p;
|
|
11
11
|
}
|
|
12
|
-
/** 配置文件路径:优先环境变量 SSH_MCP_CONFIG
|
|
12
|
+
/** 配置文件路径:优先环境变量 SSH_MCP_CONFIG,否则取运行目录下的 ./servers.json。 */
|
|
13
13
|
export function configPath() {
|
|
14
14
|
const fromEnv = process.env.SSH_MCP_CONFIG;
|
|
15
|
-
return fromEnv ? expandHome(fromEnv) :
|
|
15
|
+
return fromEnv ? expandHome(fromEnv) : resolve(process.cwd(), "servers.json");
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
* 每次调用都从磁盘重新读取配置——配置文件就是唯一事实来源。
|
|
19
|
-
* 这样新增服务器后无需重启 MCP 服务器。读取或校验失败时抛错,由调用方处理。
|
|
20
|
-
*/
|
|
21
|
-
export function loadServers() {
|
|
17
|
+
function readConfig() {
|
|
22
18
|
const path = configPath();
|
|
23
19
|
const raw = readFileSync(path, "utf8");
|
|
24
|
-
let parsed;
|
|
25
20
|
try {
|
|
26
|
-
|
|
21
|
+
return JSON.parse(raw);
|
|
27
22
|
}
|
|
28
23
|
catch (e) {
|
|
29
24
|
throw new Error(`配置文件 ${path} 不是合法 JSON:${e.message}`);
|
|
30
25
|
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 每次调用都从磁盘重新读取配置——配置文件就是唯一事实来源。
|
|
29
|
+
* 这样新增服务器后无需重启 MCP 服务器。读取或校验失败时抛错,由调用方处理。
|
|
30
|
+
*/
|
|
31
|
+
export function loadServers() {
|
|
32
|
+
const parsed = readConfig();
|
|
33
|
+
const path = configPath();
|
|
31
34
|
const list = parsed.servers;
|
|
32
35
|
if (!Array.isArray(list)) {
|
|
33
36
|
throw new Error(`配置文件 ${path} 缺少顶层 "servers" 数组`);
|
|
@@ -44,3 +47,55 @@ export function loadServers() {
|
|
|
44
47
|
}
|
|
45
48
|
return map;
|
|
46
49
|
}
|
|
50
|
+
/** 读取安全策略配置(如有)。 */
|
|
51
|
+
export function loadSecurity() {
|
|
52
|
+
const parsed = readConfig();
|
|
53
|
+
return parsed.security ?? {};
|
|
54
|
+
}
|
|
55
|
+
const BUILTIN_RULES = [
|
|
56
|
+
{
|
|
57
|
+
// rm -rf / rm -rf /* rm -r -f / rm -fr / sudo rm -rf /
|
|
58
|
+
regex: /\brm\b\s+(?:-[a-z]*[rR][^\s]*f[^\s]*|-[a-z]*f[^\s]*[rR][^\s]*|-r\s+-f|-R\s+-f)(?:\s+\S+)*\s+(?:\/\s*\*?\s*$|['"]\/['"]\s*$)/,
|
|
59
|
+
desc: "rm 递归强制删除根目录",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
// dd if=... of=/dev/sda
|
|
63
|
+
regex: /\bdd\b\s+.*\bof=\/dev\/(?:sd|hd|nvme|md|vd|xvd|mmcblk|loop|ram)/,
|
|
64
|
+
desc: "dd 覆盖块设备",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// mkfs.ext4 /dev/sda1
|
|
68
|
+
regex: /\bmkfs\.\S+\s+\/dev\/(?:sd|hd|nvme|md|vd|xvd|mmcblk|loop)/,
|
|
69
|
+
desc: "在块设备上创建文件系统",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
// > /dev/sda (redirect overwrite)
|
|
73
|
+
regex: /(?<![|&])\s*>\s*\/dev\/(?:sd|hd|nvme|md|vd|xvd|mmcblk|loop)\b/,
|
|
74
|
+
desc: "重定向覆盖块设备",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
// fork bomb: :(){ :|:& };:
|
|
78
|
+
regex: /:\s*\(\s*\)\s*\{/,
|
|
79
|
+
desc: "疑似 fork 炸弹",
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* 校验命令是否命中安全拦截规则。
|
|
84
|
+
* 返回 null 表示通过;返回字符串则为拦截原因。
|
|
85
|
+
*/
|
|
86
|
+
export function validateCommand(command, extraPatterns = []) {
|
|
87
|
+
for (const { regex, desc } of BUILTIN_RULES) {
|
|
88
|
+
if (regex.test(command))
|
|
89
|
+
return `内置策略拦截:${desc}`;
|
|
90
|
+
}
|
|
91
|
+
for (const p of extraPatterns) {
|
|
92
|
+
try {
|
|
93
|
+
if (new RegExp(p).test(command))
|
|
94
|
+
return `自定义策略拦截(匹配模式: ${p})`;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// 用户提供的正则无效,跳过不报错。
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { configPath, loadServers } from "./config.js";
|
|
6
|
-
import { runCommand } from "./ssh.js";
|
|
5
|
+
import { configPath, loadSecurity, loadServers, validateCommand } from "./config.js";
|
|
6
|
+
import { closeSession, listSessions, openSession, runCommand, } from "./ssh.js";
|
|
7
7
|
import { cancelTransfer, getTransfer, listTransfers, startTransfer, } from "./transfer.js";
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
9
9
|
function humanBytes(n) {
|
|
@@ -89,7 +89,12 @@ server.registerTool("run_command", {
|
|
|
89
89
|
title: "在远程服务器上执行命令",
|
|
90
90
|
description: "通过 SSH 在指定的远程服务器上执行一条 shell 命令,返回 stdout、stderr 和退出码。" +
|
|
91
91
|
"用 server 参数指定目标服务器的 name(可先用 list_servers 查看)。" +
|
|
92
|
-
"
|
|
92
|
+
"可选传入 session(长连接会话 id)复用已有 TCP 连接,省去重复握手和认证;" +
|
|
93
|
+
"不传则每次新建连接、执行完即断开(短连接)。" +
|
|
94
|
+
"内置安全策略会拦截 rm -rf /、dd 写块设备、mkfs、fork 炸弹等高危命令," +
|
|
95
|
+
"可在 servers.json 的 security.blocked_patterns 中追加自定义正则。" +
|
|
96
|
+
"传入 force=true 可绕过安全检查(需明确知道自己在做什么)。" +
|
|
97
|
+
"注意:即便是长连接,每条命令仍在独立 channel 中执行,命令之间不保留工作目录或环境变量;" +
|
|
93
98
|
"需要保持上下文时请自行串接,例如 `cd /var/www && git pull`。",
|
|
94
99
|
inputSchema: {
|
|
95
100
|
server: z
|
|
@@ -102,9 +107,17 @@ server.registerTool("run_command", {
|
|
|
102
107
|
.positive()
|
|
103
108
|
.optional()
|
|
104
109
|
.describe(`命令超时时间(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
|
|
110
|
+
session: z
|
|
111
|
+
.string()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("长连接会话 id(由 open_session 返回)。不填则使用短连接。"),
|
|
114
|
+
force: z
|
|
115
|
+
.boolean()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("传入 true 跳过安全策略检查。请谨慎使用。"),
|
|
105
118
|
},
|
|
106
119
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
107
|
-
}, async ({ server: serverName, command, timeout_ms }) => {
|
|
120
|
+
}, async ({ server: serverName, command, timeout_ms, session, force }) => {
|
|
108
121
|
let servers;
|
|
109
122
|
try {
|
|
110
123
|
servers = loadServers();
|
|
@@ -125,8 +138,18 @@ server.registerTool("run_command", {
|
|
|
125
138
|
isError: true,
|
|
126
139
|
};
|
|
127
140
|
}
|
|
141
|
+
if (!force) {
|
|
142
|
+
const security = loadSecurity();
|
|
143
|
+
const blocked = validateCommand(command, security.blocked_patterns ?? []);
|
|
144
|
+
if (blocked) {
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text: `${blocked}\n使用 force=true 可跳过安全检查。` }],
|
|
147
|
+
isError: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
128
151
|
try {
|
|
129
|
-
const r = await runCommand(cfg, command, timeout_ms ?? DEFAULT_TIMEOUT_MS);
|
|
152
|
+
const r = await runCommand(cfg, command, timeout_ms ?? DEFAULT_TIMEOUT_MS, session);
|
|
130
153
|
const parts = [
|
|
131
154
|
`服务器: ${cfg.name} (${cfg.username}@${cfg.host}:${cfg.port ?? 22})`,
|
|
132
155
|
`退出码: ${r.code ?? "null"}${r.signal ? ` 信号: ${r.signal}` : ""}`,
|
|
@@ -146,6 +169,67 @@ server.registerTool("run_command", {
|
|
|
146
169
|
};
|
|
147
170
|
}
|
|
148
171
|
});
|
|
172
|
+
server.registerTool("open_session", {
|
|
173
|
+
title: "打开到远程服务器的长连接会话",
|
|
174
|
+
description: "与指定服务器建立一条持久的 SSH 连接并返回会话 id。该会话可被后续的 run_command " +
|
|
175
|
+
"通过 session 参数复用,省去重复的 TCP 握手和 SSH 认证开销。" +
|
|
176
|
+
"注意:即使使用长连接,每次 exec 仍在独立 channel 中执行,命令之间不保留工作目录或环境变量。",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
server: z.string().describe("目标服务器的 name(见 list_servers)"),
|
|
179
|
+
},
|
|
180
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
181
|
+
}, async ({ server: serverName }) => {
|
|
182
|
+
try {
|
|
183
|
+
const servers = loadServers();
|
|
184
|
+
const cfg = servers.get(serverName);
|
|
185
|
+
if (!cfg) {
|
|
186
|
+
const names = [...servers.keys()].join(", ") || "(无)";
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }],
|
|
189
|
+
isError: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const s = await openSession(cfg, 20_000);
|
|
193
|
+
return { content: [{ type: "text", text: `长连接会话已建立\n id: ${s.id}\n 服务器: ${s.server}\n 创建时间: ${new Date(s.createdAt).toISOString()}` }] };
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }],
|
|
198
|
+
isError: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
server.registerTool("close_session", {
|
|
203
|
+
title: "关闭长连接会话",
|
|
204
|
+
description: "关闭由 open_session 建立的长连接会话,释放底层 SSH 连接。",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
session: z.string().describe("要关闭的会话 id(由 open_session 返回)"),
|
|
207
|
+
},
|
|
208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
209
|
+
}, async ({ session }) => {
|
|
210
|
+
const ok = closeSession(session);
|
|
211
|
+
if (!ok) {
|
|
212
|
+
return {
|
|
213
|
+
content: [{ type: "text", text: `会话 ${session} 不存在或已断开。` }],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { content: [{ type: "text", text: `会话 ${session} 已关闭。` }] };
|
|
218
|
+
});
|
|
219
|
+
server.registerTool("list_sessions", {
|
|
220
|
+
title: "列出当前所有长连接会话",
|
|
221
|
+
description: "列出当前已打开的所有长连接会话的 id、关联服务器和创建时间。",
|
|
222
|
+
inputSchema: {},
|
|
223
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
224
|
+
}, async () => {
|
|
225
|
+
const all = listSessions();
|
|
226
|
+
const text = all.length > 0
|
|
227
|
+
? all
|
|
228
|
+
.map((s) => ` ${s.id} → ${s.server}(${new Date(s.createdAt).toISOString()})`)
|
|
229
|
+
.join("\n")
|
|
230
|
+
: "当前没有长连接会话。";
|
|
231
|
+
return { content: [{ type: "text", text }] };
|
|
232
|
+
});
|
|
149
233
|
function loadServersOrError() {
|
|
150
234
|
try {
|
|
151
235
|
return { servers: loadServers(), error: null };
|
package/dist/ssh.js
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { Client } from "ssh2";
|
|
3
3
|
import { expandHome } from "./config.js";
|
|
4
|
+
const sessions = new Map();
|
|
5
|
+
let sessionCounter = 0;
|
|
6
|
+
export async function openSession(server, timeoutMs) {
|
|
7
|
+
const conn = await createConnection(server, timeoutMs);
|
|
8
|
+
const id = `s${++sessionCounter}`;
|
|
9
|
+
const session = { id, server: server.name, conn, createdAt: Date.now() };
|
|
10
|
+
sessions.set(id, session);
|
|
11
|
+
conn.on("close", () => sessions.delete(id));
|
|
12
|
+
conn.on("error", () => sessions.delete(id));
|
|
13
|
+
return { id: session.id, server: session.server, createdAt: session.createdAt };
|
|
14
|
+
}
|
|
15
|
+
export function getSession(id) {
|
|
16
|
+
return sessions.get(id);
|
|
17
|
+
}
|
|
18
|
+
export function closeSession(id) {
|
|
19
|
+
const session = sessions.get(id);
|
|
20
|
+
if (!session)
|
|
21
|
+
return false;
|
|
22
|
+
session.conn.end();
|
|
23
|
+
sessions.delete(id);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
export function listSessions() {
|
|
27
|
+
return [...sessions.values()].map((s) => ({
|
|
28
|
+
id: s.id,
|
|
29
|
+
server: s.server,
|
|
30
|
+
createdAt: s.createdAt,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
4
33
|
/** 根据服务器配置组装 ssh2 的连接参数,挑选鉴权方式。 */
|
|
5
34
|
export function buildConnectConfig(server, readyTimeoutMs) {
|
|
6
35
|
const conf = {
|
|
@@ -57,11 +86,25 @@ export function createConnection(server, readyTimeoutMs) {
|
|
|
57
86
|
/**
|
|
58
87
|
* 在指定服务器上执行一条命令,收集 stdout/stderr/退出码后返回。
|
|
59
88
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
89
|
+
* 短连接模式(默认):每次调用建立一条独立连接,执行完即断开。
|
|
90
|
+
* 长连接模式(传入 sessionId):复用已有 TCP 连接,省去重复握手和认证,
|
|
91
|
+
* 但注意每次 exec 仍在独立 channel 中执行,命令之间不保留工作目录或环境变量——
|
|
92
|
+
* 需要时请用 `cd x && cmd` 自行串接。
|
|
62
93
|
*/
|
|
63
|
-
export async function runCommand(server, command, timeoutMs) {
|
|
64
|
-
|
|
94
|
+
export async function runCommand(server, command, timeoutMs, sessionId) {
|
|
95
|
+
let conn;
|
|
96
|
+
let shouldEnd;
|
|
97
|
+
if (sessionId) {
|
|
98
|
+
const session = sessions.get(sessionId);
|
|
99
|
+
if (!session)
|
|
100
|
+
throw new Error(`长连接会话 ${sessionId} 不存在或已断开`);
|
|
101
|
+
conn = session.conn;
|
|
102
|
+
shouldEnd = false;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
conn = await createConnection(server, timeoutMs);
|
|
106
|
+
shouldEnd = true;
|
|
107
|
+
}
|
|
65
108
|
return new Promise((resolve, reject) => {
|
|
66
109
|
let settled = false;
|
|
67
110
|
const finish = (fn) => {
|
|
@@ -70,7 +113,8 @@ export async function runCommand(server, command, timeoutMs) {
|
|
|
70
113
|
settled = true;
|
|
71
114
|
clearTimeout(timer);
|
|
72
115
|
fn();
|
|
73
|
-
|
|
116
|
+
if (shouldEnd)
|
|
117
|
+
conn.end();
|
|
74
118
|
};
|
|
75
119
|
const timer = setTimeout(() => {
|
|
76
120
|
finish(() => reject(new Error(`命令执行超时(${timeoutMs}ms)`)));
|