@lzm04521/ssh-mcp-server 1.1.5
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 +15 -0
- package/README.md +157 -0
- package/admin-web/dist/assets/index-DXbr5DVR.js +494 -0
- package/admin-web/dist/index.html +6 -0
- package/admin-web/dist/logo.png +0 -0
- package/build/cli/command-line-parser.js +715 -0
- package/build/cli/command-line-parser.js.map +1 -0
- package/build/cli/run-mode.js +44 -0
- package/build/cli/run-mode.js.map +1 -0
- package/build/cli/stdio-proxy.js +304 -0
- package/build/cli/stdio-proxy.js.map +1 -0
- package/build/config/index.js +5 -0
- package/build/config/index.js.map +1 -0
- package/build/config/server.js +16 -0
- package/build/config/server.js.map +1 -0
- package/build/core/mcp-http-server.js +14 -0
- package/build/core/mcp-http-server.js.map +1 -0
- package/build/core/mcp-server.js +95 -0
- package/build/core/mcp-server.js.map +1 -0
- package/build/index.js +98 -0
- package/build/index.js.map +1 -0
- package/build/models/admin-types.js +135 -0
- package/build/models/admin-types.js.map +1 -0
- package/build/models/types.js +2 -0
- package/build/models/types.js.map +1 -0
- package/build/server/index.js +128 -0
- package/build/server/index.js.map +1 -0
- package/build/server/routes/admin.js +929 -0
- package/build/server/routes/admin.js.map +1 -0
- package/build/server/routes/audit.js +9 -0
- package/build/server/routes/audit.js.map +1 -0
- package/build/server/routes/backups.js +11 -0
- package/build/server/routes/backups.js.map +1 -0
- package/build/server/routes/mcp.js +52 -0
- package/build/server/routes/mcp.js.map +1 -0
- package/build/server/routes/settings.js +49 -0
- package/build/server/routes/settings.js.map +1 -0
- package/build/server/routes/system.js +147 -0
- package/build/server/routes/system.js.map +1 -0
- package/build/services/audit-store.js +123 -0
- package/build/services/audit-store.js.map +1 -0
- package/build/services/autostart-service.js +55 -0
- package/build/services/autostart-service.js.map +1 -0
- package/build/services/backup-scheduler.js +90 -0
- package/build/services/backup-scheduler.js.map +1 -0
- package/build/services/backup-service.js +113 -0
- package/build/services/backup-service.js.map +1 -0
- package/build/services/config-store.js +163 -0
- package/build/services/config-store.js.map +1 -0
- package/build/services/defaults.js +35 -0
- package/build/services/defaults.js.map +1 -0
- package/build/services/restart-helper.js +43 -0
- package/build/services/restart-helper.js.map +1 -0
- package/build/services/ssh-connection-manager.js +1775 -0
- package/build/services/ssh-connection-manager.js.map +1 -0
- package/build/services/update-service.js +93 -0
- package/build/services/update-service.js.map +1 -0
- package/build/tools/download.js +41 -0
- package/build/tools/download.js.map +1 -0
- package/build/tools/execute-command.js +53 -0
- package/build/tools/execute-command.js.map +1 -0
- package/build/tools/index.js +17 -0
- package/build/tools/index.js.map +1 -0
- package/build/tools/list-directory.js +57 -0
- package/build/tools/list-directory.js.map +1 -0
- package/build/tools/list-servers.js +51 -0
- package/build/tools/list-servers.js.map +1 -0
- package/build/tools/upload.js +41 -0
- package/build/tools/upload.js.map +1 -0
- package/build/utils/logger.js +28 -0
- package/build/utils/logger.js.map +1 -0
- package/build/utils/ssh-config-parser.js +195 -0
- package/build/utils/ssh-config-parser.js.map +1 -0
- package/build/utils/status-collector.js +226 -0
- package/build/utils/status-collector.js.map +1 -0
- package/build/utils/tool-error.js +20 -0
- package/build/utils/tool-error.js.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,1775 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Logger } from "../utils/logger.js";
|
|
3
|
+
import { collectSystemStatus } from "../utils/status-collector.js";
|
|
4
|
+
import { ToolError } from "../utils/tool-error.js";
|
|
5
|
+
import { getFlatHosts, getFlatHostByName } from "./config-store.js";
|
|
6
|
+
import { globalAuditStore } from "./audit-store.js";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { StringDecoder } from "node:string_decoder";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const ANSI_OSC_PATTERN = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
13
|
+
const ANSI_CSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
14
|
+
// Matches the exit code that `buildShellCommandScript` prints right after the
|
|
15
|
+
// end marker prefix, anchored so it can only be read at the expected offset.
|
|
16
|
+
const SHELL_EXIT_CODE_PATTERN = /^(-?\d+)__(?:\r)?\n/;
|
|
17
|
+
const SHELL_EXIT_CODE_MAX_LENGTH = 32;
|
|
18
|
+
const COMMAND_TEMPLATE_PLACEHOLDER = "<command>";
|
|
19
|
+
const QUOTED_COMMAND_TEMPLATE_PLACEHOLDER = "<quotedCommand>";
|
|
20
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 30000;
|
|
21
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 30000;
|
|
22
|
+
const DEFAULT_KEEPALIVE_INTERVAL_MS = 10000;
|
|
23
|
+
const DEFAULT_KEEPALIVE_COUNT_MAX = 3;
|
|
24
|
+
const DEFAULT_SFTP_TIMEOUT_MS = 300000;
|
|
25
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
26
|
+
// ssh2's SFTP ReadStream/WriteStream keep a single request in flight, so
|
|
27
|
+
// transfer throughput is capped at one chunk per round trip regardless of the
|
|
28
|
+
// available bandwidth. fastGet/fastPut pipeline `concurrency` chunks instead,
|
|
29
|
+
// which is what makes large transfers usable on high latency links.
|
|
30
|
+
const SFTP_FAST_TRANSFER_OPTIONS = {
|
|
31
|
+
concurrency: 64,
|
|
32
|
+
chunkSize: 32 * 1024,
|
|
33
|
+
};
|
|
34
|
+
// Under this size the streaming path already completes in a couple of round
|
|
35
|
+
// trips, so the extra stat fastGet needs would cost more than the concurrency
|
|
36
|
+
// saves. Staying on the streaming path below the threshold also keeps the
|
|
37
|
+
// existing behaviour for files whose reported size is unusable: fastGet/fastPut
|
|
38
|
+
// plan their chunks from that size and treat `size <= 0` as "nothing to
|
|
39
|
+
// transfer", which would silently produce an empty file for pseudo files such
|
|
40
|
+
// as /proc/cpuinfo.
|
|
41
|
+
const SFTP_FAST_TRANSFER_MIN_BYTES = 256 * 1024;
|
|
42
|
+
function applyCommandTemplate(template, command) {
|
|
43
|
+
const quotedCommand = shellQuote(command);
|
|
44
|
+
return template
|
|
45
|
+
.split(QUOTED_COMMAND_TEMPLATE_PLACEHOLDER)
|
|
46
|
+
.join(quotedCommand)
|
|
47
|
+
.split(`'${COMMAND_TEMPLATE_PLACEHOLDER}'`)
|
|
48
|
+
.join(quotedCommand)
|
|
49
|
+
.split(`"${COMMAND_TEMPLATE_PLACEHOLDER}"`)
|
|
50
|
+
.join(quotedCommand)
|
|
51
|
+
.split(COMMAND_TEMPLATE_PLACEHOLDER)
|
|
52
|
+
.join(command);
|
|
53
|
+
}
|
|
54
|
+
function shellQuote(value) {
|
|
55
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The caller cannot see the process working directory or the server's path
|
|
59
|
+
* configuration, so a rejection that only says "must be within an allowed path"
|
|
60
|
+
* leaves it nothing to correct against. Name the roots instead.
|
|
61
|
+
*/
|
|
62
|
+
function describeAllowedRoots(kind, allowedRoots) {
|
|
63
|
+
return `Allowed ${kind} paths for this connection: ${allowedRoots.join(", ")}.`;
|
|
64
|
+
}
|
|
65
|
+
function isPathWithinRoot(candidate, root) {
|
|
66
|
+
const relativePath = path.relative(root, candidate);
|
|
67
|
+
return (relativePath === "" ||
|
|
68
|
+
(relativePath !== "" &&
|
|
69
|
+
!relativePath.startsWith("..") &&
|
|
70
|
+
!path.isAbsolute(relativePath)));
|
|
71
|
+
}
|
|
72
|
+
function redactProxyUrl(proxyUrl) {
|
|
73
|
+
const redactedUrl = new URL(proxyUrl.toString());
|
|
74
|
+
if (redactedUrl.username) {
|
|
75
|
+
redactedUrl.username = "***";
|
|
76
|
+
}
|
|
77
|
+
if (redactedUrl.password) {
|
|
78
|
+
redactedUrl.password = "***";
|
|
79
|
+
}
|
|
80
|
+
return redactedUrl.toString();
|
|
81
|
+
}
|
|
82
|
+
function normalizeUrlHostname(hostname) {
|
|
83
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
84
|
+
return hostname.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
return hostname;
|
|
87
|
+
}
|
|
88
|
+
function formatHostPort(host, port) {
|
|
89
|
+
const formattedHost = host.includes(":") && !host.startsWith("[")
|
|
90
|
+
? `[${host}]`
|
|
91
|
+
: host;
|
|
92
|
+
return `${formattedHost}:${port}`;
|
|
93
|
+
}
|
|
94
|
+
function parseProxyPort(proxyUrl, defaultPort) {
|
|
95
|
+
const port = proxyUrl.port
|
|
96
|
+
? Number.parseInt(proxyUrl.port, 10)
|
|
97
|
+
: defaultPort;
|
|
98
|
+
if (!port || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
99
|
+
throw new Error("Proxy URL must include a valid port");
|
|
100
|
+
}
|
|
101
|
+
return port;
|
|
102
|
+
}
|
|
103
|
+
function isPasswordPrompt(prompt) {
|
|
104
|
+
const promptText = prompt.toLowerCase();
|
|
105
|
+
return promptText.includes("password") || promptText.includes("密码");
|
|
106
|
+
}
|
|
107
|
+
function isAuthMethodAllowedByServer(method, methodsLeft) {
|
|
108
|
+
if (methodsLeft === null) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// ssh-agent uses the SSH publickey protocol method.
|
|
112
|
+
if (method === "agent") {
|
|
113
|
+
return methodsLeft.includes("publickey");
|
|
114
|
+
}
|
|
115
|
+
return methodsLeft.includes(method);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* SSH Connection Manager class
|
|
119
|
+
*/
|
|
120
|
+
export class SSHConnectionManager {
|
|
121
|
+
static instance;
|
|
122
|
+
clients = new Map();
|
|
123
|
+
configs = {};
|
|
124
|
+
connected = new Map();
|
|
125
|
+
statusCache = new Map();
|
|
126
|
+
pendingConnections = new Map();
|
|
127
|
+
pendingStatusCollections = new Map();
|
|
128
|
+
commandWhitelistRegexes = new Map();
|
|
129
|
+
commandBlacklistRegexes = new Map();
|
|
130
|
+
shellStreams = new Map();
|
|
131
|
+
shellReady = new Map();
|
|
132
|
+
shellQueues = new Map();
|
|
133
|
+
shellBuffers = new Map();
|
|
134
|
+
// A multi-byte character can be split across two TCP chunks, so the channel
|
|
135
|
+
// needs one decoder for its whole lifetime rather than a decode per chunk.
|
|
136
|
+
shellDecoders = new Map();
|
|
137
|
+
defaultName = "default";
|
|
138
|
+
currentConfig = null;
|
|
139
|
+
constructor() { }
|
|
140
|
+
/**
|
|
141
|
+
* Get singleton instance
|
|
142
|
+
*/
|
|
143
|
+
static getInstance() {
|
|
144
|
+
if (!SSHConnectionManager.instance) {
|
|
145
|
+
SSHConnectionManager.instance = new SSHConnectionManager();
|
|
146
|
+
}
|
|
147
|
+
return SSHConnectionManager.instance;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Batch set SSH configurations
|
|
151
|
+
*/
|
|
152
|
+
setConfig(cfg, defaultName) {
|
|
153
|
+
this.disconnect();
|
|
154
|
+
this.commandWhitelistRegexes.clear();
|
|
155
|
+
this.commandBlacklistRegexes.clear();
|
|
156
|
+
let configs;
|
|
157
|
+
if (cfg && typeof cfg === "object" && "projects" in cfg) {
|
|
158
|
+
const global = cfg;
|
|
159
|
+
this.currentConfig = global;
|
|
160
|
+
const flat = getFlatHosts(global);
|
|
161
|
+
const sec = global.security || {};
|
|
162
|
+
configs = {};
|
|
163
|
+
for (const [flatName, entry] of flat) {
|
|
164
|
+
// 全局安全兜底:白名单/路径在连接级留空时跟随全局,配置了则以连接级为准;
|
|
165
|
+
// 黑名单取并集——全局高危拦截不能被连接级配置清空
|
|
166
|
+
const c = { ...entry.config };
|
|
167
|
+
if ((!c.commandWhitelist || c.commandWhitelist.length === 0) && Array.isArray(sec.commandWhitelist) && sec.commandWhitelist.length > 0) {
|
|
168
|
+
c.commandWhitelist = sec.commandWhitelist;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(sec.commandBlacklist) && sec.commandBlacklist.length > 0) {
|
|
171
|
+
c.commandBlacklist = [...sec.commandBlacklist, ...(c.commandBlacklist || [])];
|
|
172
|
+
}
|
|
173
|
+
if ((!c.allowedLocalPaths || c.allowedLocalPaths.length === 0) && Array.isArray(sec.allowedLocalPaths) && sec.allowedLocalPaths.length > 0) {
|
|
174
|
+
c.allowedLocalPaths = sec.allowedLocalPaths;
|
|
175
|
+
}
|
|
176
|
+
if ((!c.allowedRemotePaths || c.allowedRemotePaths.length === 0) && Array.isArray(sec.allowedRemotePaths) && sec.allowedRemotePaths.length > 0) {
|
|
177
|
+
c.allowedRemotePaths = sec.allowedRemotePaths;
|
|
178
|
+
}
|
|
179
|
+
configs[flatName] = c;
|
|
180
|
+
}
|
|
181
|
+
globalAuditStore.retentionDays = global.audit?.retentionDays;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.currentConfig = null;
|
|
185
|
+
configs = cfg;
|
|
186
|
+
}
|
|
187
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
188
|
+
this.commandWhitelistRegexes.set(name, this.compilePatterns(config.commandWhitelist, name, "whitelist"));
|
|
189
|
+
this.commandBlacklistRegexes.set(name, this.compilePatterns(config.commandBlacklist, name, "blacklist"));
|
|
190
|
+
}
|
|
191
|
+
this.configs = configs;
|
|
192
|
+
if (defaultName && configs[defaultName]) {
|
|
193
|
+
this.defaultName = defaultName;
|
|
194
|
+
}
|
|
195
|
+
else if (Object.keys(configs).length > 0) {
|
|
196
|
+
this.defaultName = Object.keys(configs)[0];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** 审计埋点:enabled=false 全关;logResults=false 只记失败。存储异常静默,不影响命令执行 */
|
|
200
|
+
auditLog(connection, tool, ok, detail) {
|
|
201
|
+
const audit = this.currentConfig?.audit;
|
|
202
|
+
if (audit?.enabled === false)
|
|
203
|
+
return;
|
|
204
|
+
if (ok && audit?.logResults === false)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
globalAuditStore.log({ connection, tool, status: ok ? "ok" : "fail", sql: detail });
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
auditName(name) {
|
|
212
|
+
return name || this.defaultName;
|
|
213
|
+
}
|
|
214
|
+
resolveFlatName(name) {
|
|
215
|
+
if (!name) {
|
|
216
|
+
if (Object.keys(this.configs).length === 0) {
|
|
217
|
+
throw new ToolError("HOST_NOT_FOUND", "SSH configuration not set: No hosts configured", false);
|
|
218
|
+
}
|
|
219
|
+
if (this.configs[this.defaultName]) {
|
|
220
|
+
return this.defaultName;
|
|
221
|
+
}
|
|
222
|
+
return Object.keys(this.configs)[0];
|
|
223
|
+
}
|
|
224
|
+
if (this.configs[name]) {
|
|
225
|
+
return name;
|
|
226
|
+
}
|
|
227
|
+
if (this.currentConfig) {
|
|
228
|
+
const result = getFlatHostByName(name, this.currentConfig);
|
|
229
|
+
if (result) {
|
|
230
|
+
if (result.ambiguous) {
|
|
231
|
+
throw new ToolError("AMBIGUOUS_HOST", `Ambiguous host '${name}': candidates ${result.candidates.join(", ")}`, false);
|
|
232
|
+
}
|
|
233
|
+
return result.flatName;
|
|
234
|
+
}
|
|
235
|
+
throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${name}' not set`, false);
|
|
236
|
+
}
|
|
237
|
+
throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${name}' not set`, false);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Get specified connection configuration
|
|
241
|
+
*/
|
|
242
|
+
getConfig(name) {
|
|
243
|
+
const key = this.resolveFlatName(name);
|
|
244
|
+
const cfg = this.configs[key];
|
|
245
|
+
if (!cfg) {
|
|
246
|
+
throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${key}' not set`, false);
|
|
247
|
+
}
|
|
248
|
+
return cfg;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Batch connect all configured SSH connections
|
|
252
|
+
*/
|
|
253
|
+
async connectAll() {
|
|
254
|
+
const names = Object.keys(this.configs);
|
|
255
|
+
const results = await Promise.allSettled(names.map((name) => this.connect(name)));
|
|
256
|
+
const failures = results
|
|
257
|
+
.map((result, index) => ({ result, name: names[index] }))
|
|
258
|
+
.filter((entry) => entry.result.status === "rejected");
|
|
259
|
+
if (failures.length > 0) {
|
|
260
|
+
throw new ToolError("SSH_CONNECTION_FAILED", failures
|
|
261
|
+
.map(({ name, result }) => `[${name}] ${result.reason instanceof Error
|
|
262
|
+
? result.reason.message
|
|
263
|
+
: String(result.reason)}`)
|
|
264
|
+
.join("; "), true);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Connect to SSH with specified name
|
|
269
|
+
*/
|
|
270
|
+
async connect(name) {
|
|
271
|
+
const key = this.resolveFlatName(name);
|
|
272
|
+
if (this.hasUsableConnection(key)) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const existingConnection = this.pendingConnections.get(key);
|
|
276
|
+
if (existingConnection) {
|
|
277
|
+
await existingConnection;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const config = this.getConfig(key);
|
|
281
|
+
const client = this.createClient();
|
|
282
|
+
const connectionPromise = new Promise(async (resolve, reject) => {
|
|
283
|
+
let settled = false;
|
|
284
|
+
const timeoutMs = this.getConnectionTimeoutMs(config);
|
|
285
|
+
const timeoutId = setTimeout(() => {
|
|
286
|
+
rejectOnce(new ToolError("SSH_CONNECTION_TIMEOUT", `SSH connection [${key}] timed out after ${timeoutMs}ms`, true));
|
|
287
|
+
this.invalidateConnection(key);
|
|
288
|
+
try {
|
|
289
|
+
client.destroy();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Ignore cleanup errors during connection timeout.
|
|
293
|
+
}
|
|
294
|
+
}, timeoutMs);
|
|
295
|
+
const clearConnectionTimeout = () => clearTimeout(timeoutId);
|
|
296
|
+
const resolveOnce = () => {
|
|
297
|
+
if (settled) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
settled = true;
|
|
301
|
+
clearConnectionTimeout();
|
|
302
|
+
resolve();
|
|
303
|
+
};
|
|
304
|
+
const rejectOnce = (error) => {
|
|
305
|
+
if (settled) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
settled = true;
|
|
309
|
+
clearConnectionTimeout();
|
|
310
|
+
reject(error);
|
|
311
|
+
};
|
|
312
|
+
client.on("ready", async () => {
|
|
313
|
+
Logger.log(`Successfully connected to SSH server [${key}] ${config.host}:${config.port}`);
|
|
314
|
+
try {
|
|
315
|
+
if (this.getTransportMode(config) === "shell") {
|
|
316
|
+
await this.initializeShellSession(client, key, config);
|
|
317
|
+
}
|
|
318
|
+
this.clients.set(key, client);
|
|
319
|
+
this.connected.set(key, true);
|
|
320
|
+
this.scheduleStatusCollection(key);
|
|
321
|
+
resolveOnce();
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.connected.set(key, false);
|
|
325
|
+
this.cleanupShellState(key, true);
|
|
326
|
+
try {
|
|
327
|
+
client.end();
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Ignore cleanup errors during failed initialization.
|
|
331
|
+
}
|
|
332
|
+
rejectOnce(error instanceof ToolError
|
|
333
|
+
? error
|
|
334
|
+
: new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${error.message}`, true));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
client.on("error", (err) => {
|
|
338
|
+
this.connected.set(key, false);
|
|
339
|
+
if (this.clients.get(key) === client || this.shellStreams.has(key)) {
|
|
340
|
+
this.invalidateConnection(key);
|
|
341
|
+
}
|
|
342
|
+
rejectOnce(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${err.message}`, true));
|
|
343
|
+
});
|
|
344
|
+
client.on("close", () => {
|
|
345
|
+
this.clearConnectionState(key);
|
|
346
|
+
Logger.log(`SSH connection [${key}] closed`, "info");
|
|
347
|
+
});
|
|
348
|
+
try {
|
|
349
|
+
const sshConfig = await this.buildClientConfig(key, config);
|
|
350
|
+
client.connect(sshConfig);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
rejectOnce(error);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
this.pendingConnections.set(key, connectionPromise);
|
|
357
|
+
try {
|
|
358
|
+
await connectionPromise;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
this.pendingConnections.delete(key);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get SSH Client with specified name
|
|
366
|
+
*/
|
|
367
|
+
getClient(name) {
|
|
368
|
+
const key = this.resolveFlatName(name);
|
|
369
|
+
const client = this.clients.get(key);
|
|
370
|
+
if (!client) {
|
|
371
|
+
throw new Error(`SSH client for '${key}' not connected`);
|
|
372
|
+
}
|
|
373
|
+
return client;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Execute SSH command
|
|
377
|
+
*/
|
|
378
|
+
async executeCommand(cmdString, directory, name, options = {}) {
|
|
379
|
+
try {
|
|
380
|
+
const result = await this.runCommandInternal(cmdString, directory, name, options);
|
|
381
|
+
this.auditLog(this.auditName(name), "execute-command", true, directory ? `cd ${directory} && ${cmdString}` : cmdString);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
this.auditLog(this.auditName(name), "execute-command", false, directory ? `cd ${directory} && ${cmdString}` : cmdString);
|
|
386
|
+
throw e;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Upload file
|
|
391
|
+
*/
|
|
392
|
+
validateLocalPath(localPath, name, purpose = "read") {
|
|
393
|
+
if (typeof localPath !== "string" || localPath.length === 0) {
|
|
394
|
+
throw new ToolError("LOCAL_PATH_NOT_ALLOWED", "Local path must be a non-empty string.", false);
|
|
395
|
+
}
|
|
396
|
+
if (localPath.includes("\0")) {
|
|
397
|
+
throw new ToolError("LOCAL_PATH_NOT_ALLOWED", "Local path must not contain null bytes.", false);
|
|
398
|
+
}
|
|
399
|
+
const resolvedPath = path.resolve(localPath);
|
|
400
|
+
const allowedRoots = this.getAllowedLocalRoots(name);
|
|
401
|
+
const parentPath = path.dirname(resolvedPath);
|
|
402
|
+
const existingPath = this.tryRealpath(resolvedPath);
|
|
403
|
+
const parentRealPath = this.tryRealpath(parentPath);
|
|
404
|
+
let pathToCheck = existingPath;
|
|
405
|
+
if (!pathToCheck && parentRealPath) {
|
|
406
|
+
pathToCheck = path.join(parentRealPath, path.basename(resolvedPath));
|
|
407
|
+
}
|
|
408
|
+
if (!pathToCheck) {
|
|
409
|
+
pathToCheck = resolvedPath;
|
|
410
|
+
}
|
|
411
|
+
if (purpose === "write" && !parentRealPath) {
|
|
412
|
+
throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Local path parent directory must exist and be within an allowed local path. Resolved to: ${resolvedPath}. ${describeAllowedRoots("local", allowedRoots)}`, false);
|
|
413
|
+
}
|
|
414
|
+
const isAllowed = allowedRoots.some((allowedRoot) => isPathWithinRoot(pathToCheck, allowedRoot));
|
|
415
|
+
if (!isAllowed) {
|
|
416
|
+
throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Path traversal detected. Local path resolved to: ${pathToCheck}. ${describeAllowedRoots("local", allowedRoots)}`, false);
|
|
417
|
+
}
|
|
418
|
+
return resolvedPath;
|
|
419
|
+
}
|
|
420
|
+
getAllowedLocalRoots(name) {
|
|
421
|
+
const config = this.getConfig(name);
|
|
422
|
+
return [process.cwd(), ...(config.allowedLocalPaths || [])]
|
|
423
|
+
.filter((allowedPath) => allowedPath.trim().length > 0)
|
|
424
|
+
.map((allowedPath) => {
|
|
425
|
+
const resolvedRoot = path.resolve(allowedPath);
|
|
426
|
+
return this.tryRealpath(resolvedRoot) || resolvedRoot;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
tryRealpath(localPath) {
|
|
430
|
+
try {
|
|
431
|
+
return fs.realpathSync.native(localPath);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
validateRemotePath(remotePath, name) {
|
|
438
|
+
if (typeof remotePath !== "string" || remotePath.length === 0) {
|
|
439
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must be a non-empty string.", false);
|
|
440
|
+
}
|
|
441
|
+
if (remotePath.includes("\0")) {
|
|
442
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must not contain null bytes.", false);
|
|
443
|
+
}
|
|
444
|
+
if (!path.posix.isAbsolute(remotePath)) {
|
|
445
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must be an absolute POSIX path, got: ${remotePath}`, false);
|
|
446
|
+
}
|
|
447
|
+
const resolvedPath = path.posix.normalize(remotePath);
|
|
448
|
+
const config = this.getConfig(name);
|
|
449
|
+
const allowedRoots = config.allowedRemotePaths || [];
|
|
450
|
+
if (allowedRoots.length === 0) {
|
|
451
|
+
return resolvedPath;
|
|
452
|
+
}
|
|
453
|
+
const isAllowed = allowedRoots.some((allowedRoot) => resolvedPath === allowedRoot ||
|
|
454
|
+
resolvedPath.startsWith(allowedRoot.endsWith("/") ? allowedRoot : `${allowedRoot}/`));
|
|
455
|
+
if (!isAllowed) {
|
|
456
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path is not within the configured allowedRemotePaths. Resolved to: ${resolvedPath}. ${describeAllowedRoots("remote", allowedRoots)}`, false);
|
|
457
|
+
}
|
|
458
|
+
return resolvedPath;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Upload file
|
|
462
|
+
*/
|
|
463
|
+
async upload(localPath, remotePath, name) {
|
|
464
|
+
try {
|
|
465
|
+
const result = await this.uploadInternal(localPath, remotePath, name);
|
|
466
|
+
this.auditLog(this.auditName(name), "upload", true, `${localPath} → ${remotePath}`);
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
this.auditLog(this.auditName(name), "upload", false, `${localPath} → ${remotePath}`);
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async uploadInternal(localPath, remotePath, name) {
|
|
475
|
+
const config = this.getConfig(name);
|
|
476
|
+
const key = this.resolveFlatName(name);
|
|
477
|
+
if (this.getTransportMode(config) === "shell") {
|
|
478
|
+
throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP upload/download.", false);
|
|
479
|
+
}
|
|
480
|
+
const validatedLocalPath = this.validateLocalPath(localPath, name, "read");
|
|
481
|
+
const validatedRemotePath = this.validateRemotePath(remotePath, name);
|
|
482
|
+
const client = await this.ensureConnected(name);
|
|
483
|
+
const sftpTimeoutMs = this.getSftpTimeoutMs(config);
|
|
484
|
+
const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
|
|
485
|
+
try {
|
|
486
|
+
const localSize = await this.getLocalSizeForFastTransfer(validatedLocalPath);
|
|
487
|
+
if (localSize === undefined) {
|
|
488
|
+
await this.withTimeout(pipeline(fs.createReadStream(validatedLocalPath), sftp.createWriteStream(validatedRemotePath)), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${sftpTimeoutMs}ms`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
await this.withTimeout(new Promise((resolve, reject) => {
|
|
492
|
+
sftp.fastPut(validatedLocalPath, validatedRemotePath, SFTP_FAST_TRANSFER_OPTIONS, (err) => (err ? reject(err) : resolve()));
|
|
493
|
+
}), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${sftpTimeoutMs}ms`);
|
|
494
|
+
await this.appendUploadTail(sftp, validatedLocalPath, validatedRemotePath, localSize, sftpTimeoutMs, key);
|
|
495
|
+
}
|
|
496
|
+
return "File uploaded successfully";
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (error instanceof ToolError && error.code === "OPERATION_TIMEOUT") {
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
if (this.errorPathMatches(error, validatedLocalPath)) {
|
|
503
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file: ${error.message}`, false);
|
|
504
|
+
}
|
|
505
|
+
throw new ToolError("SFTP_ERROR", `File upload failed: ${error.message}`, true);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
this.closeSftp(sftp);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* List remote directory
|
|
513
|
+
*/
|
|
514
|
+
async listDirectory(remotePath, name) {
|
|
515
|
+
try {
|
|
516
|
+
const result = await this.listDirectoryInternal(remotePath, name);
|
|
517
|
+
this.auditLog(this.auditName(name), "list-directory", true, remotePath);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
this.auditLog(this.auditName(name), "list-directory", false, remotePath);
|
|
522
|
+
throw e;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async listDirectoryInternal(remotePath, name) {
|
|
526
|
+
const config = this.getConfig(name);
|
|
527
|
+
const key = this.resolveFlatName(name);
|
|
528
|
+
if (this.getTransportMode(config) === "shell") {
|
|
529
|
+
throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP directory listing. Use execute-command with ls instead.", false);
|
|
530
|
+
}
|
|
531
|
+
const validatedRemotePath = this.validateRemotePath(remotePath, name);
|
|
532
|
+
const client = await this.ensureConnected(name);
|
|
533
|
+
const sftpTimeoutMs = this.getSftpTimeoutMs(config);
|
|
534
|
+
const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
|
|
535
|
+
let entries;
|
|
536
|
+
try {
|
|
537
|
+
entries = await this.withTimeout(new Promise((resolve, reject) => {
|
|
538
|
+
sftp.readdir(validatedRemotePath, (err, list) => err ? reject(err) : resolve(list));
|
|
539
|
+
}), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP readdir timed out after ${sftpTimeoutMs}ms`);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
const err = error;
|
|
543
|
+
if (err?.code === "ENOENT" || /no such file/i.test(String(err?.message))) {
|
|
544
|
+
throw new ToolError("DIR_NOT_FOUND", `Remote directory not found: ${validatedRemotePath}`, false);
|
|
545
|
+
}
|
|
546
|
+
if (err instanceof ToolError)
|
|
547
|
+
throw err;
|
|
548
|
+
if (/not a directory/i.test(String(err?.message))) {
|
|
549
|
+
throw new ToolError("NOT_A_DIRECTORY", `Remote path is not a directory: ${validatedRemotePath}`, false);
|
|
550
|
+
}
|
|
551
|
+
throw new ToolError("SFTP_ERROR", `Directory listing failed: ${String(err?.message || err)}`, true);
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
this.closeSftp(sftp);
|
|
555
|
+
}
|
|
556
|
+
const mapped = entries.map((e) => ({
|
|
557
|
+
name: String(e.filename),
|
|
558
|
+
type: e.longname?.[0] === "d" ? "dir" : e.longname?.[0] === "l" ? "symlink" : "file",
|
|
559
|
+
size: typeof e.attrs?.size === "number" ? e.attrs.size : undefined,
|
|
560
|
+
mtimeMs: typeof e.attrs?.mtime === "number" ? e.attrs.mtime * 1000 : undefined,
|
|
561
|
+
}));
|
|
562
|
+
// 目录在前、其余按名称排序,方便模型快速定位
|
|
563
|
+
mapped.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
|
|
564
|
+
return mapped;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Download file
|
|
568
|
+
*/
|
|
569
|
+
async download(remotePath, localPath, name) {
|
|
570
|
+
try {
|
|
571
|
+
const result = await this.downloadInternal(remotePath, localPath, name);
|
|
572
|
+
this.auditLog(this.auditName(name), "download", true, `${remotePath} → ${localPath}`);
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
catch (e) {
|
|
576
|
+
this.auditLog(this.auditName(name), "download", false, `${remotePath} → ${localPath}`);
|
|
577
|
+
throw e;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async downloadInternal(remotePath, localPath, name) {
|
|
581
|
+
const config = this.getConfig(name);
|
|
582
|
+
const key = this.resolveFlatName(name);
|
|
583
|
+
if (this.getTransportMode(config) === "shell") {
|
|
584
|
+
throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP upload/download.", false);
|
|
585
|
+
}
|
|
586
|
+
const validatedLocalPath = this.validateLocalPath(localPath, name, "write");
|
|
587
|
+
const validatedRemotePath = this.validateRemotePath(remotePath, name);
|
|
588
|
+
const client = await this.ensureConnected(name);
|
|
589
|
+
const sftpTimeoutMs = this.getSftpTimeoutMs(config);
|
|
590
|
+
const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
|
|
591
|
+
const tempLocalPath = `${validatedLocalPath}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
|
592
|
+
.toString(16)
|
|
593
|
+
.slice(2)}`;
|
|
594
|
+
try {
|
|
595
|
+
const remoteSize = await this.getRemoteSizeForFastTransfer(sftp, validatedRemotePath, sftpTimeoutMs, key);
|
|
596
|
+
if (remoteSize === undefined) {
|
|
597
|
+
await this.withTimeout(pipeline(sftp.createReadStream(validatedRemotePath), fs.createWriteStream(tempLocalPath, { flags: "wx" })), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${sftpTimeoutMs}ms`);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// fastGet opens the destination with "w", which would drop the
|
|
601
|
+
// exclusive-create guarantee the streaming path gets from "wx".
|
|
602
|
+
// Claiming the temp name first keeps it.
|
|
603
|
+
await fs.promises.writeFile(tempLocalPath, "", { flag: "wx" });
|
|
604
|
+
await this.withTimeout(new Promise((resolve, reject) => {
|
|
605
|
+
sftp.fastGet(validatedRemotePath, tempLocalPath, SFTP_FAST_TRANSFER_OPTIONS, (err) => (err ? reject(err) : resolve()));
|
|
606
|
+
}), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${sftpTimeoutMs}ms`);
|
|
607
|
+
await this.appendDownloadTail(sftp, validatedRemotePath, tempLocalPath, sftpTimeoutMs, key);
|
|
608
|
+
}
|
|
609
|
+
await fs.promises.rename(tempLocalPath, validatedLocalPath);
|
|
610
|
+
return "File downloaded successfully";
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
await this.unlinkIfExists(tempLocalPath);
|
|
614
|
+
if (error instanceof ToolError && error.code === "OPERATION_TIMEOUT") {
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
if (this.errorPathMatches(error, tempLocalPath) ||
|
|
618
|
+
this.errorPathMatches(error, validatedLocalPath)) {
|
|
619
|
+
throw new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${error.message}`, false);
|
|
620
|
+
}
|
|
621
|
+
throw new ToolError("SFTP_ERROR", `File download failed: ${error.message}`, true);
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
this.closeSftp(sftp);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
openSftp(client) {
|
|
628
|
+
return new Promise((resolve, reject) => {
|
|
629
|
+
client.sftp((err, sftp) => {
|
|
630
|
+
if (err) {
|
|
631
|
+
reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
resolve(sftp);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
statRemote(sftp, remotePath) {
|
|
639
|
+
return new Promise((resolve, reject) => {
|
|
640
|
+
sftp.stat(remotePath, (err, stats) => err ? reject(err) : resolve(stats));
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Size to hand to the concurrent transfer path, or undefined to stay on the
|
|
645
|
+
* streaming path. Anything unexpected — a missing file, a directory, a
|
|
646
|
+
* server without stat support — falls back so the streaming path reports the
|
|
647
|
+
* same error it reports today.
|
|
648
|
+
*/
|
|
649
|
+
async getRemoteSizeForFastTransfer(sftp, remotePath, timeoutMs, key) {
|
|
650
|
+
let stats;
|
|
651
|
+
try {
|
|
652
|
+
stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
// A timeout already tore the connection down, so it has to surface.
|
|
656
|
+
if (error instanceof ToolError) {
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
return stats.isFile() && stats.size >= SFTP_FAST_TRANSFER_MIN_BYTES
|
|
662
|
+
? stats.size
|
|
663
|
+
: undefined;
|
|
664
|
+
}
|
|
665
|
+
async getLocalSizeForFastTransfer(localPath) {
|
|
666
|
+
try {
|
|
667
|
+
const stats = await fs.promises.stat(localPath);
|
|
668
|
+
return stats.isFile() && stats.size >= SFTP_FAST_TRANSFER_MIN_BYTES
|
|
669
|
+
? stats.size
|
|
670
|
+
: undefined;
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* fastGet transfers exactly the byte count the remote file had when it
|
|
678
|
+
* started, so data appended while it ran would be dropped. The streaming path
|
|
679
|
+
* reads until EOF and would have picked that tail up, so fetch it here to
|
|
680
|
+
* keep both paths equivalent for files that are still being written.
|
|
681
|
+
*/
|
|
682
|
+
async appendDownloadTail(sftp, remotePath, tempLocalPath, timeoutMs, key) {
|
|
683
|
+
const downloadedBytes = (await fs.promises.stat(tempLocalPath)).size;
|
|
684
|
+
const stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
|
|
685
|
+
if (stats.size <= downloadedBytes) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
await this.withTimeout(pipeline(sftp.createReadStream(remotePath, { start: downloadedBytes }), fs.createWriteStream(tempLocalPath, { flags: "a" })), timeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${timeoutMs}ms`);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Upload counterpart of appendDownloadTail. The local stat is free, so the
|
|
692
|
+
* remote round trip only happens when the local file actually grew.
|
|
693
|
+
*/
|
|
694
|
+
async appendUploadTail(sftp, localPath, remotePath, sizeBeforeTransfer, timeoutMs, key) {
|
|
695
|
+
const currentLocalSize = (await fs.promises.stat(localPath)).size;
|
|
696
|
+
if (currentLocalSize <= sizeBeforeTransfer) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
|
|
700
|
+
if (stats.size >= currentLocalSize) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
await this.withTimeout(pipeline(fs.createReadStream(localPath, { start: stats.size }), sftp.createWriteStream(remotePath, { flags: "a" })), timeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${timeoutMs}ms`);
|
|
704
|
+
}
|
|
705
|
+
closeSftp(sftp) {
|
|
706
|
+
try {
|
|
707
|
+
sftp.end();
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Ignore cleanup errors after transfer completion.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
withTimeout(promise, timeoutMs, onTimeout, message) {
|
|
714
|
+
let timeoutId;
|
|
715
|
+
return new Promise((resolve, reject) => {
|
|
716
|
+
timeoutId = setTimeout(() => {
|
|
717
|
+
try {
|
|
718
|
+
onTimeout();
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Ignore cleanup errors while rejecting a timed out operation.
|
|
722
|
+
}
|
|
723
|
+
reject(new ToolError("OPERATION_TIMEOUT", message, true));
|
|
724
|
+
}, timeoutMs);
|
|
725
|
+
promise.then((value) => {
|
|
726
|
+
clearTimeout(timeoutId);
|
|
727
|
+
resolve(value);
|
|
728
|
+
}, (error) => {
|
|
729
|
+
clearTimeout(timeoutId);
|
|
730
|
+
reject(error);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
errorPathMatches(error, localPath) {
|
|
735
|
+
const errorPath = error.path;
|
|
736
|
+
return typeof errorPath === "string" && path.resolve(errorPath) === localPath;
|
|
737
|
+
}
|
|
738
|
+
async unlinkIfExists(localPath) {
|
|
739
|
+
try {
|
|
740
|
+
await fs.promises.unlink(localPath);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
if (error.code !== "ENOENT") {
|
|
744
|
+
Logger.log(`Failed to remove partial local file ${localPath}: ${error.message}`, "error");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Disconnect SSH connection
|
|
750
|
+
*/
|
|
751
|
+
disconnect() {
|
|
752
|
+
for (const timeoutId of this.pendingStatusCollections.values()) {
|
|
753
|
+
clearTimeout(timeoutId);
|
|
754
|
+
}
|
|
755
|
+
this.pendingStatusCollections.clear();
|
|
756
|
+
for (const [key] of this.clients) {
|
|
757
|
+
this.cleanupShellState(key, true);
|
|
758
|
+
}
|
|
759
|
+
if (this.clients.size > 0) {
|
|
760
|
+
for (const client of this.clients.values()) {
|
|
761
|
+
client.end();
|
|
762
|
+
}
|
|
763
|
+
this.clients.clear();
|
|
764
|
+
}
|
|
765
|
+
this.connected.clear();
|
|
766
|
+
this.statusCache.clear();
|
|
767
|
+
this.pendingConnections.clear();
|
|
768
|
+
this.commandWhitelistRegexes.clear();
|
|
769
|
+
this.commandBlacklistRegexes.clear();
|
|
770
|
+
this.shellStreams.clear();
|
|
771
|
+
this.shellReady.clear();
|
|
772
|
+
this.shellQueues.clear();
|
|
773
|
+
this.shellBuffers.clear();
|
|
774
|
+
this.shellDecoders.clear();
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Get basic information of all configured servers
|
|
778
|
+
*/
|
|
779
|
+
getAllServerInfos() {
|
|
780
|
+
return Object.keys(this.configs).map((key) => {
|
|
781
|
+
const config = this.configs[key];
|
|
782
|
+
const status = this.statusCache.get(key);
|
|
783
|
+
let project;
|
|
784
|
+
let environment;
|
|
785
|
+
let hostName;
|
|
786
|
+
if (this.currentConfig) {
|
|
787
|
+
const flat = getFlatHosts(this.currentConfig);
|
|
788
|
+
const entry = flat.get(key);
|
|
789
|
+
if (entry) {
|
|
790
|
+
project = entry.project;
|
|
791
|
+
environment = entry.environment;
|
|
792
|
+
hostName = entry.host;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
name: key,
|
|
797
|
+
host: config.host,
|
|
798
|
+
port: config.port,
|
|
799
|
+
username: config.username,
|
|
800
|
+
connected: this.connected.get(key) === true,
|
|
801
|
+
status: status,
|
|
802
|
+
project,
|
|
803
|
+
environment,
|
|
804
|
+
hostName,
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
createClient() {
|
|
809
|
+
const { Client } = require("ssh2");
|
|
810
|
+
return new Client();
|
|
811
|
+
}
|
|
812
|
+
async ensureConnected(name) {
|
|
813
|
+
const key = this.resolveFlatName(name);
|
|
814
|
+
if (!this.hasUsableConnection(key)) {
|
|
815
|
+
await this.connect(key);
|
|
816
|
+
}
|
|
817
|
+
const client = this.clients.get(key);
|
|
818
|
+
if (!client) {
|
|
819
|
+
throw new Error(`SSH client for '${key}' not initialized`);
|
|
820
|
+
}
|
|
821
|
+
return client;
|
|
822
|
+
}
|
|
823
|
+
hasUsableConnection(key) {
|
|
824
|
+
const client = this.clients.get(key);
|
|
825
|
+
if (!client || this.connected.get(key) !== true) {
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
const config = this.getConfig(key);
|
|
829
|
+
if (this.getTransportMode(config) === "shell") {
|
|
830
|
+
return (this.shellReady.get(key) === true && this.shellStreams.has(key));
|
|
831
|
+
}
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
getTransportMode(config) {
|
|
835
|
+
return config.transportMode || "exec";
|
|
836
|
+
}
|
|
837
|
+
getShellReadyTimeoutMs(config) {
|
|
838
|
+
return config.shellReadyTimeoutMs || 10000;
|
|
839
|
+
}
|
|
840
|
+
getShellCommandTimeoutMs(config) {
|
|
841
|
+
return config.shellCommandTimeoutMs || DEFAULT_COMMAND_TIMEOUT_MS;
|
|
842
|
+
}
|
|
843
|
+
getCommandTimeoutMs(config) {
|
|
844
|
+
return config.commandTimeoutMs || DEFAULT_COMMAND_TIMEOUT_MS;
|
|
845
|
+
}
|
|
846
|
+
getConnectionTimeoutMs(config) {
|
|
847
|
+
return config.connectionTimeoutMs || DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
848
|
+
}
|
|
849
|
+
getSftpTimeoutMs(config) {
|
|
850
|
+
return config.sftpTimeoutMs || DEFAULT_SFTP_TIMEOUT_MS;
|
|
851
|
+
}
|
|
852
|
+
getMaxOutputBytes(config) {
|
|
853
|
+
const configured = config.maxOutputBytes;
|
|
854
|
+
if (configured === undefined) {
|
|
855
|
+
return DEFAULT_MAX_OUTPUT_BYTES;
|
|
856
|
+
}
|
|
857
|
+
if (!Number.isSafeInteger(configured) || configured < 0) {
|
|
858
|
+
throw new ToolError("COMMAND_VALIDATION_FAILED", `maxOutputBytes must be a non-negative integer, got: ${String(configured)}`, false);
|
|
859
|
+
}
|
|
860
|
+
return configured;
|
|
861
|
+
}
|
|
862
|
+
async createSocksProxySocket(proxyUrl, config) {
|
|
863
|
+
const { SocksClient } = require("socks");
|
|
864
|
+
const proxyHost = normalizeUrlHostname(proxyUrl.hostname);
|
|
865
|
+
const proxyPort = parseProxyPort(proxyUrl);
|
|
866
|
+
if (!proxyHost) {
|
|
867
|
+
throw new Error("Proxy URL must include a host");
|
|
868
|
+
}
|
|
869
|
+
const proxy = {
|
|
870
|
+
host: proxyHost,
|
|
871
|
+
port: proxyPort,
|
|
872
|
+
type: 5,
|
|
873
|
+
};
|
|
874
|
+
if (proxyUrl.username) {
|
|
875
|
+
proxy.userId = decodeURIComponent(proxyUrl.username);
|
|
876
|
+
}
|
|
877
|
+
if (proxyUrl.password) {
|
|
878
|
+
proxy.password = decodeURIComponent(proxyUrl.password);
|
|
879
|
+
}
|
|
880
|
+
const { socket } = await SocksClient.createConnection({
|
|
881
|
+
proxy,
|
|
882
|
+
command: "connect",
|
|
883
|
+
destination: {
|
|
884
|
+
host: config.host,
|
|
885
|
+
port: config.port,
|
|
886
|
+
},
|
|
887
|
+
timeout: this.getConnectionTimeoutMs(config),
|
|
888
|
+
});
|
|
889
|
+
return socket;
|
|
890
|
+
}
|
|
891
|
+
createHttpProxySocket(proxyUrl, config) {
|
|
892
|
+
const isTlsProxy = proxyUrl.protocol === "https:";
|
|
893
|
+
const proxyHost = normalizeUrlHostname(proxyUrl.hostname);
|
|
894
|
+
const proxyPort = parseProxyPort(proxyUrl, isTlsProxy ? 443 : 80);
|
|
895
|
+
if (!proxyHost) {
|
|
896
|
+
throw new Error("Proxy URL must include a host");
|
|
897
|
+
}
|
|
898
|
+
const destination = formatHostPort(config.host, config.port);
|
|
899
|
+
const headers = { Host: destination };
|
|
900
|
+
if (proxyUrl.username || proxyUrl.password) {
|
|
901
|
+
const username = decodeURIComponent(proxyUrl.username);
|
|
902
|
+
const password = decodeURIComponent(proxyUrl.password);
|
|
903
|
+
headers["Proxy-Authorization"] = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
904
|
+
}
|
|
905
|
+
return new Promise((resolve, reject) => {
|
|
906
|
+
const options = {
|
|
907
|
+
method: "CONNECT",
|
|
908
|
+
hostname: proxyHost,
|
|
909
|
+
port: proxyPort,
|
|
910
|
+
path: destination,
|
|
911
|
+
headers,
|
|
912
|
+
};
|
|
913
|
+
const proxyRequest = isTlsProxy
|
|
914
|
+
? require("node:https").request(options)
|
|
915
|
+
: require("node:http").request(options);
|
|
916
|
+
proxyRequest.once("connect", (response, socket, head) => {
|
|
917
|
+
proxyRequest.setTimeout(0);
|
|
918
|
+
if (response.statusCode !== 200) {
|
|
919
|
+
socket.destroy();
|
|
920
|
+
reject(new Error(`HTTP proxy CONNECT failed with status ${response.statusCode ?? "unknown"}`));
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (head.length > 0) {
|
|
924
|
+
socket.unshift(head);
|
|
925
|
+
}
|
|
926
|
+
resolve(socket);
|
|
927
|
+
});
|
|
928
|
+
proxyRequest.once("error", reject);
|
|
929
|
+
proxyRequest.setTimeout(this.getConnectionTimeoutMs(config), () => {
|
|
930
|
+
proxyRequest.destroy(new Error("HTTP proxy CONNECT timed out"));
|
|
931
|
+
});
|
|
932
|
+
proxyRequest.end();
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
async createProxySocket(proxyUrl, config) {
|
|
936
|
+
switch (proxyUrl.protocol) {
|
|
937
|
+
case "socks:":
|
|
938
|
+
case "socks5:":
|
|
939
|
+
return this.createSocksProxySocket(proxyUrl, config);
|
|
940
|
+
case "http:":
|
|
941
|
+
case "https:":
|
|
942
|
+
return this.createHttpProxySocket(proxyUrl, config);
|
|
943
|
+
default:
|
|
944
|
+
throw new Error(`Unsupported proxy protocol '${proxyUrl.protocol}'. Use socks://, socks5://, http://, or https://`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async buildClientConfig(key, config) {
|
|
948
|
+
const sshConfig = {
|
|
949
|
+
host: config.host,
|
|
950
|
+
port: config.port,
|
|
951
|
+
username: config.username,
|
|
952
|
+
readyTimeout: this.getConnectionTimeoutMs(config),
|
|
953
|
+
timeout: this.getConnectionTimeoutMs(config),
|
|
954
|
+
keepaliveInterval: config.keepaliveIntervalMs || DEFAULT_KEEPALIVE_INTERVAL_MS,
|
|
955
|
+
keepaliveCountMax: config.keepaliveCountMax || DEFAULT_KEEPALIVE_COUNT_MAX,
|
|
956
|
+
};
|
|
957
|
+
if (config.algorithms) {
|
|
958
|
+
sshConfig.algorithms = config.algorithms;
|
|
959
|
+
}
|
|
960
|
+
if (config.proxy && config.socksProxy) {
|
|
961
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Proxy configuration for [${key}] cannot use both 'proxy' and 'socksProxy'`, false);
|
|
962
|
+
}
|
|
963
|
+
const proxyValue = config.proxy || config.socksProxy;
|
|
964
|
+
if (proxyValue) {
|
|
965
|
+
try {
|
|
966
|
+
const proxyUrl = new URL(proxyValue);
|
|
967
|
+
if (config.socksProxy &&
|
|
968
|
+
proxyUrl.protocol !== "socks:" &&
|
|
969
|
+
proxyUrl.protocol !== "socks5:") {
|
|
970
|
+
throw new Error("The legacy 'socksProxy' option only supports socks:// or socks5:// URLs; use 'proxy' for HTTP or HTTPS proxies");
|
|
971
|
+
}
|
|
972
|
+
Logger.log(`Using proxy for [${key}]: ${redactProxyUrl(proxyUrl)}`, "info");
|
|
973
|
+
sshConfig.sock = await this.createProxySocket(proxyUrl, config);
|
|
974
|
+
Logger.log(`SSH config object with proxy: ${JSON.stringify(sshConfig, (field, value) => (field === "sock" ? "[Socket object]" : value))}`, "info");
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to create proxy connection for [${key}]: ${error.message}`, true);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
// Enable keyboard-interactive authentication for 2FA/MFA
|
|
981
|
+
if (config.tryKeyboard) {
|
|
982
|
+
sshConfig.tryKeyboard = true;
|
|
983
|
+
// Build ordered preference of methods this connection supports.
|
|
984
|
+
const authMethods = [];
|
|
985
|
+
if (config.privateKey) {
|
|
986
|
+
authMethods.push("publickey");
|
|
987
|
+
}
|
|
988
|
+
if (config.agent) {
|
|
989
|
+
authMethods.push("agent");
|
|
990
|
+
}
|
|
991
|
+
if (config.password) {
|
|
992
|
+
authMethods.push("password");
|
|
993
|
+
}
|
|
994
|
+
authMethods.push("keyboard-interactive");
|
|
995
|
+
const triedMethods = [];
|
|
996
|
+
const maxAuthAttempts = authMethods.length;
|
|
997
|
+
sshConfig.authHandler = (methodsLeft, partialSuccess, callback) => {
|
|
998
|
+
// Prevent infinite retry loops.
|
|
999
|
+
if (triedMethods.length >= maxAuthAttempts) {
|
|
1000
|
+
Logger.log(`[${key}] Authentication failed after trying [${triedMethods.join(", ")}]`, "error");
|
|
1001
|
+
return callback(false);
|
|
1002
|
+
}
|
|
1003
|
+
// Pick the next preferred method that hasn't been attempted yet
|
|
1004
|
+
// (and is still allowed by the server if methodsLeft is provided).
|
|
1005
|
+
const candidates = methodsLeft !== null
|
|
1006
|
+
? authMethods.filter((m) => isAuthMethodAllowedByServer(m, methodsLeft))
|
|
1007
|
+
: authMethods;
|
|
1008
|
+
const nextMethod = candidates.find((m) => !triedMethods.includes(m));
|
|
1009
|
+
if (!nextMethod) {
|
|
1010
|
+
Logger.log(`[${key}] All supported auth methods exhausted`, "error");
|
|
1011
|
+
return callback(false);
|
|
1012
|
+
}
|
|
1013
|
+
triedMethods.push(nextMethod);
|
|
1014
|
+
Logger.log(`[${key}] Trying auth method: ${nextMethod} (${triedMethods.length}/${maxAuthAttempts})`, "info");
|
|
1015
|
+
return callback(nextMethod);
|
|
1016
|
+
};
|
|
1017
|
+
// Handle keyboard-interactive prompts (for 2FA codes)
|
|
1018
|
+
sshConfig.keyboard = (name, instructions, instructionsLang, prompts, finish) => {
|
|
1019
|
+
Logger.log(`[${key}] Keyboard-interactive authentication requested`, "info");
|
|
1020
|
+
Logger.log(`[${key}] Name: ${name}`, "debug");
|
|
1021
|
+
Logger.log(`[${key}] Instructions: ${instructions}`, "debug");
|
|
1022
|
+
Logger.log(`[${key}] Prompts: ${JSON.stringify(prompts)}`, "debug");
|
|
1023
|
+
const otpCode = process.env.SSH_MCP_2FA_CODE;
|
|
1024
|
+
const responses = [];
|
|
1025
|
+
for (const prompt of prompts) {
|
|
1026
|
+
if (config.password && isPasswordPrompt(prompt.prompt)) {
|
|
1027
|
+
// For password prompts, use the configured password
|
|
1028
|
+
responses.push(config.password);
|
|
1029
|
+
Logger.log(`[${key}] Responding to password prompt: ${prompt.prompt}`, "debug");
|
|
1030
|
+
}
|
|
1031
|
+
else if (otpCode) {
|
|
1032
|
+
// For 2FA/verification code prompts, use SSH_MCP_2FA_CODE if provided
|
|
1033
|
+
responses.push(otpCode);
|
|
1034
|
+
Logger.log(`[${key}] Responding to non-password prompt with SSH_MCP_2FA_CODE: ${prompt.prompt}`, "info");
|
|
1035
|
+
}
|
|
1036
|
+
else if (config.password && prompts.length === 1 && !prompt.echo) {
|
|
1037
|
+
// Single non-echoing prompt without "password" label:
|
|
1038
|
+
// treat as password prompt (common on embedded devices)
|
|
1039
|
+
responses.push(config.password);
|
|
1040
|
+
Logger.log(`[${key}] Responding to single non-echo prompt (assumed password): ${prompt.prompt}`, "debug");
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
// No code available — empty response will fail the auth attempt;
|
|
1044
|
+
// set SSH_MCP_2FA_CODE before connecting to enable 2FA/MFA.
|
|
1045
|
+
responses.push("");
|
|
1046
|
+
Logger.log(`[${key}] Empty response for prompt (set SSH_MCP_2FA_CODE to satisfy 2FA): ${prompt.prompt}`, "info");
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
finish(responses);
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (config.agent) {
|
|
1053
|
+
sshConfig.agent = config.agent;
|
|
1054
|
+
Logger.log(`Using SSH agent authentication for [${key}]: ${config.agent}`, "info");
|
|
1055
|
+
if (!config.tryKeyboard) {
|
|
1056
|
+
return sshConfig;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (config.privateKey) {
|
|
1060
|
+
try {
|
|
1061
|
+
sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
|
|
1062
|
+
if (config.passphrase) {
|
|
1063
|
+
sshConfig.passphrase = config.passphrase;
|
|
1064
|
+
}
|
|
1065
|
+
Logger.log(`Using SSH private key authentication for [${key}]`, "info");
|
|
1066
|
+
if (!config.tryKeyboard) {
|
|
1067
|
+
return sshConfig;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${error.message}`, false);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (config.password) {
|
|
1075
|
+
sshConfig.password = config.password;
|
|
1076
|
+
Logger.log(`Using password authentication for [${key}]`, "info");
|
|
1077
|
+
if (!config.tryKeyboard) {
|
|
1078
|
+
return sshConfig;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!config.agent && !config.privateKey && !config.password && !config.tryKeyboard) {
|
|
1082
|
+
throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (agent, password, private key, or tryKeyboard)`, false);
|
|
1083
|
+
}
|
|
1084
|
+
return sshConfig;
|
|
1085
|
+
}
|
|
1086
|
+
scheduleStatusCollection(key) {
|
|
1087
|
+
const existingStatusCollection = this.pendingStatusCollections.get(key);
|
|
1088
|
+
if (existingStatusCollection) {
|
|
1089
|
+
clearTimeout(existingStatusCollection);
|
|
1090
|
+
}
|
|
1091
|
+
const timeoutId = setTimeout(() => {
|
|
1092
|
+
this.pendingStatusCollections.delete(key);
|
|
1093
|
+
void this.collectStatusForConnection(key);
|
|
1094
|
+
}, 1000);
|
|
1095
|
+
this.pendingStatusCollections.set(key, timeoutId);
|
|
1096
|
+
}
|
|
1097
|
+
async collectStatusForConnection(key) {
|
|
1098
|
+
try {
|
|
1099
|
+
const status = await collectSystemStatus((command, connectionName) => this.runCommandInternal(command, undefined, connectionName, {
|
|
1100
|
+
prevalidatedInternalCommand: true,
|
|
1101
|
+
}), key, (command, connectionName) => this.validateCommand(command, connectionName).isAllowed);
|
|
1102
|
+
this.statusCache.set(key, status);
|
|
1103
|
+
Logger.log(`System status collected for [${key}]`, "info");
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
Logger.log(`Failed to collect system status for [${key}]: ${error.message}`, "error");
|
|
1107
|
+
this.statusCache.set(key, {
|
|
1108
|
+
reachable: true,
|
|
1109
|
+
lastUpdated: new Date().toISOString(),
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
compilePatterns(patterns, connectionName, kind) {
|
|
1114
|
+
if (!patterns || patterns.length === 0) {
|
|
1115
|
+
return [];
|
|
1116
|
+
}
|
|
1117
|
+
return patterns.map((pattern) => {
|
|
1118
|
+
try {
|
|
1119
|
+
return new RegExp(pattern);
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
throw new Error(`Invalid ${kind} pattern for '${connectionName}': ${pattern} (${error.message})`);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
validateCommand(command, name) {
|
|
1127
|
+
const key = this.resolveFlatName(name);
|
|
1128
|
+
const whitelistRegexes = this.commandWhitelistRegexes.get(key) || [];
|
|
1129
|
+
if (whitelistRegexes.length > 0) {
|
|
1130
|
+
const matchesWhitelist = whitelistRegexes.some((regex) => regex.test(command));
|
|
1131
|
+
if (!matchesWhitelist) {
|
|
1132
|
+
return {
|
|
1133
|
+
isAllowed: false,
|
|
1134
|
+
reason: "Command not in whitelist, execution forbidden",
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const blacklistRegexes = this.commandBlacklistRegexes.get(key) || [];
|
|
1139
|
+
if (blacklistRegexes.length > 0) {
|
|
1140
|
+
const matchesBlacklist = blacklistRegexes.some((regex) => regex.test(command));
|
|
1141
|
+
if (matchesBlacklist) {
|
|
1142
|
+
return {
|
|
1143
|
+
isAllowed: false,
|
|
1144
|
+
reason: "Command matches blacklist, execution forbidden",
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return {
|
|
1149
|
+
isAllowed: true,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
formatCommandFailure(stdout, stderr, exitCode, exitSignal) {
|
|
1153
|
+
const outputSections = [];
|
|
1154
|
+
if (stdout) {
|
|
1155
|
+
outputSections.push(stdout);
|
|
1156
|
+
}
|
|
1157
|
+
if (stderr) {
|
|
1158
|
+
outputSections.push(`[stderr]\n${stderr}`);
|
|
1159
|
+
}
|
|
1160
|
+
if (exitCode !== undefined) {
|
|
1161
|
+
outputSections.push(`[exit code] ${exitCode}`);
|
|
1162
|
+
}
|
|
1163
|
+
if (exitSignal) {
|
|
1164
|
+
outputSections.push(`[signal] ${exitSignal}`);
|
|
1165
|
+
}
|
|
1166
|
+
return outputSections.join("\n");
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Format the output of a command that finished successfully.
|
|
1170
|
+
*
|
|
1171
|
+
* stderr is kept instead of being dropped: with `pty: false` a successful
|
|
1172
|
+
* command's stderr is delivered on a separate channel, and discarding it
|
|
1173
|
+
* silently loses warnings and progress output written there by tools such as
|
|
1174
|
+
* git, docker and npm. With the default `pty: true` the remote end merges
|
|
1175
|
+
* stderr into stdout, so `stderr` is empty here and the output is unchanged.
|
|
1176
|
+
*/
|
|
1177
|
+
formatCommandSuccess(stdout, stderr) {
|
|
1178
|
+
if (!stderr) {
|
|
1179
|
+
return stdout;
|
|
1180
|
+
}
|
|
1181
|
+
return [stdout, `[stderr]\n${stderr}`].filter(Boolean).join("\n");
|
|
1182
|
+
}
|
|
1183
|
+
async runCommandInternal(cmdString, directory, name, options = {}) {
|
|
1184
|
+
if (!options.prevalidatedInternalCommand) {
|
|
1185
|
+
const validationResult = this.validateCommand(cmdString, name);
|
|
1186
|
+
if (!validationResult.isAllowed) {
|
|
1187
|
+
throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const key = this.resolveFlatName(name);
|
|
1191
|
+
const config = this.getConfig(name);
|
|
1192
|
+
const transportMode = this.getTransportMode(config);
|
|
1193
|
+
const timeout = options.timeout ??
|
|
1194
|
+
(transportMode === "shell"
|
|
1195
|
+
? this.getShellCommandTimeoutMs(config)
|
|
1196
|
+
: this.getCommandTimeoutMs(config));
|
|
1197
|
+
const connectionTimeoutMs = this.getConnectionTimeoutMs(config);
|
|
1198
|
+
const client = await this.withTimeout(this.ensureConnected(name), connectionTimeoutMs, () => this.invalidateConnection(key), `SSH connection [${key}] timed out after ${connectionTimeoutMs}ms`);
|
|
1199
|
+
if (transportMode === "shell") {
|
|
1200
|
+
return this.runShellCommand(cmdString, directory, name, timeout);
|
|
1201
|
+
}
|
|
1202
|
+
return this.runExecCommand(client, config, cmdString, directory, timeout, key);
|
|
1203
|
+
}
|
|
1204
|
+
runExecCommand(client, config, cmdString, directory, timeout, key) {
|
|
1205
|
+
let commandToRun = directory
|
|
1206
|
+
? `cd -- ${shellQuote(directory)} && ${cmdString}`
|
|
1207
|
+
: cmdString;
|
|
1208
|
+
if (config.commandTemplate) {
|
|
1209
|
+
commandToRun = applyCommandTemplate(config.commandTemplate, commandToRun);
|
|
1210
|
+
}
|
|
1211
|
+
const maxOutputBytes = this.getMaxOutputBytes(config);
|
|
1212
|
+
return new Promise((resolve, reject) => {
|
|
1213
|
+
let openTimeoutId;
|
|
1214
|
+
let commandTimeoutId;
|
|
1215
|
+
let settled = false;
|
|
1216
|
+
const cleanup = () => {
|
|
1217
|
+
if (openTimeoutId) {
|
|
1218
|
+
clearTimeout(openTimeoutId);
|
|
1219
|
+
}
|
|
1220
|
+
if (commandTimeoutId) {
|
|
1221
|
+
clearTimeout(commandTimeoutId);
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
client.exec(commandToRun, { pty: config.pty !== undefined ? config.pty : true }, (err, stream) => {
|
|
1225
|
+
if (openTimeoutId) {
|
|
1226
|
+
clearTimeout(openTimeoutId);
|
|
1227
|
+
openTimeoutId = undefined;
|
|
1228
|
+
}
|
|
1229
|
+
if (settled) {
|
|
1230
|
+
try {
|
|
1231
|
+
stream?.close();
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
// Ignore late stream cleanup errors after timeout.
|
|
1235
|
+
}
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (err) {
|
|
1239
|
+
cleanup();
|
|
1240
|
+
settled = true;
|
|
1241
|
+
reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, true));
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
let data = "";
|
|
1245
|
+
let errorData = "";
|
|
1246
|
+
let exitCode;
|
|
1247
|
+
let exitSignal;
|
|
1248
|
+
let capturedBytes = 0;
|
|
1249
|
+
// Decoding each chunk on its own corrupts any multi-byte character
|
|
1250
|
+
// that happens to be split across a chunk boundary.
|
|
1251
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
1252
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
1253
|
+
// Without a cap a single command (`cat` on a huge file, an unbounded
|
|
1254
|
+
// `journalctl`, ...) can buffer unbounded output in memory until the
|
|
1255
|
+
// command timeout fires. Stop capturing and close the channel instead.
|
|
1256
|
+
const appendChunk = (chunk, isStderr) => {
|
|
1257
|
+
if (settled) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (maxOutputBytes > 0 &&
|
|
1261
|
+
capturedBytes + chunk.length > maxOutputBytes) {
|
|
1262
|
+
const remaining = maxOutputBytes - capturedBytes;
|
|
1263
|
+
if (remaining > 0) {
|
|
1264
|
+
const partial = chunk.subarray(0, remaining);
|
|
1265
|
+
if (isStderr) {
|
|
1266
|
+
errorData += stderrDecoder.write(partial);
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
data += stdoutDecoder.write(partial);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
capturedBytes = maxOutputBytes;
|
|
1273
|
+
cleanup();
|
|
1274
|
+
settled = true;
|
|
1275
|
+
try {
|
|
1276
|
+
stream.close();
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
// Ignore close errors while aborting an oversized command.
|
|
1280
|
+
}
|
|
1281
|
+
const stdout = data.trimEnd();
|
|
1282
|
+
const stderr = errorData.trimEnd();
|
|
1283
|
+
reject(new ToolError("OUTPUT_LIMIT_EXCEEDED", [
|
|
1284
|
+
this.formatCommandSuccess(stdout, stderr),
|
|
1285
|
+
`[truncated] Output exceeded maxOutputBytes=${maxOutputBytes}; the command was aborted.`,
|
|
1286
|
+
]
|
|
1287
|
+
.filter(Boolean)
|
|
1288
|
+
.join("\n"), false));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
capturedBytes += chunk.length;
|
|
1292
|
+
if (isStderr) {
|
|
1293
|
+
errorData += stderrDecoder.write(chunk);
|
|
1294
|
+
}
|
|
1295
|
+
else {
|
|
1296
|
+
data += stdoutDecoder.write(chunk);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
stream.on("data", (chunk) => appendChunk(chunk, false));
|
|
1300
|
+
stream.stderr.on("data", (chunk) => appendChunk(chunk, true));
|
|
1301
|
+
stream.on("exit", (code, signal) => {
|
|
1302
|
+
exitCode = code;
|
|
1303
|
+
exitSignal = signal;
|
|
1304
|
+
});
|
|
1305
|
+
stream.on("close", (code, signal) => {
|
|
1306
|
+
cleanup();
|
|
1307
|
+
if (settled) {
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
settled = true;
|
|
1311
|
+
if (exitCode === undefined) {
|
|
1312
|
+
exitCode = code;
|
|
1313
|
+
}
|
|
1314
|
+
if (!exitSignal && signal) {
|
|
1315
|
+
exitSignal = signal;
|
|
1316
|
+
}
|
|
1317
|
+
// Flush any trailing incomplete multi-byte sequence.
|
|
1318
|
+
const stdout = (data + stdoutDecoder.end()).trimEnd();
|
|
1319
|
+
const stderr = (errorData + stderrDecoder.end()).trimEnd();
|
|
1320
|
+
const hasNonZeroExitCode = exitCode !== undefined && exitCode !== 0;
|
|
1321
|
+
const hasExitSignal = exitSignal !== undefined && exitSignal !== "";
|
|
1322
|
+
if (hasNonZeroExitCode || hasExitSignal) {
|
|
1323
|
+
reject(new ToolError("COMMAND_EXECUTION_ERROR", this.formatCommandFailure(stdout, stderr, exitCode, exitSignal) ||
|
|
1324
|
+
(hasExitSignal
|
|
1325
|
+
? `Command terminated by signal ${exitSignal}${exitCode !== undefined ? ` (exit code ${exitCode})` : ""}`
|
|
1326
|
+
: `Command failed with exit code ${exitCode}`), false));
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
resolve(this.formatCommandSuccess(stdout, stderr));
|
|
1330
|
+
});
|
|
1331
|
+
stream.on("error", (streamError) => {
|
|
1332
|
+
cleanup();
|
|
1333
|
+
settled = true;
|
|
1334
|
+
reject(new ToolError("COMMAND_EXECUTION_ERROR", `Stream error: ${streamError.message}`, true));
|
|
1335
|
+
});
|
|
1336
|
+
commandTimeoutId = setTimeout(() => {
|
|
1337
|
+
try {
|
|
1338
|
+
stream.close();
|
|
1339
|
+
}
|
|
1340
|
+
catch {
|
|
1341
|
+
// Ignore stream close errors during timeout handling.
|
|
1342
|
+
}
|
|
1343
|
+
if (!settled) {
|
|
1344
|
+
settled = true;
|
|
1345
|
+
const stdout = data.trimEnd();
|
|
1346
|
+
const stderr = errorData.trimEnd();
|
|
1347
|
+
reject(new ToolError("COMMAND_TIMEOUT", [
|
|
1348
|
+
this.formatCommandFailure(stdout, stderr),
|
|
1349
|
+
`[timeout] Command timed out after ${timeout}ms`,
|
|
1350
|
+
]
|
|
1351
|
+
.filter(Boolean)
|
|
1352
|
+
.join("\n"), true));
|
|
1353
|
+
}
|
|
1354
|
+
}, timeout);
|
|
1355
|
+
});
|
|
1356
|
+
openTimeoutId = setTimeout(() => {
|
|
1357
|
+
if (!settled) {
|
|
1358
|
+
settled = true;
|
|
1359
|
+
this.invalidateConnection(key);
|
|
1360
|
+
reject(new ToolError("COMMAND_TIMEOUT", `[timeout] Command channel did not open within ${timeout}ms`, true));
|
|
1361
|
+
}
|
|
1362
|
+
}, timeout);
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
async initializeShellSession(client, key, config) {
|
|
1366
|
+
const stream = await new Promise((resolve, reject) => {
|
|
1367
|
+
client.shell({ term: "xterm" }, (err, channel) => {
|
|
1368
|
+
if (err) {
|
|
1369
|
+
reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to initialize shell transport for [${key}]: ${err.message}`, true));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
resolve(channel);
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
this.shellStreams.set(key, stream);
|
|
1376
|
+
this.shellReady.set(key, false);
|
|
1377
|
+
this.shellQueues.set(key, Promise.resolve());
|
|
1378
|
+
this.shellBuffers.set(key, "");
|
|
1379
|
+
this.shellDecoders.set(key, new StringDecoder("utf8"));
|
|
1380
|
+
const readyId = this.generateMarkerId("ready");
|
|
1381
|
+
const readyMarker = `__MCP_READY__${readyId}__`;
|
|
1382
|
+
try {
|
|
1383
|
+
await this.waitForShellReady(key, stream, readyMarker, this.getShellReadyTimeoutMs(config));
|
|
1384
|
+
await this.configureShellSession(key, stream, config);
|
|
1385
|
+
this.shellReady.set(key, true);
|
|
1386
|
+
this.attachShellLifecycleListeners(key, stream);
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
this.cleanupShellState(key, true);
|
|
1390
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Shell transport initialization failed for [${key}]: ${error.message}`, true);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
waitForShellReady(key, stream, readyMarker, timeout) {
|
|
1394
|
+
return new Promise((resolve, reject) => {
|
|
1395
|
+
let settled = false;
|
|
1396
|
+
let timeoutId;
|
|
1397
|
+
let probeIntervalId;
|
|
1398
|
+
// Escape the marker in the probe payload so the command text does not
|
|
1399
|
+
// contain the literal marker string. When the PTY echoes the probe
|
|
1400
|
+
// command back (echo is still on during the first probe), the echo no
|
|
1401
|
+
// longer matches readyMarker, so resolveIfReady only fires once the
|
|
1402
|
+
// printf actually executes and emits the literal marker. Without this,
|
|
1403
|
+
// waitForShellReady resolves on the echoed command line, before the
|
|
1404
|
+
// probed command has run — which is fatal when the probed command is
|
|
1405
|
+
// `stty -echo` in configureShellSession: the next real command would be
|
|
1406
|
+
// sent before echo is disabled and would itself be echoed, leaving
|
|
1407
|
+
// extractShellCommandResult matching markers inside the echoed script.
|
|
1408
|
+
const escapedMarker = readyMarker
|
|
1409
|
+
.replace(/\\/g, "\\\\")
|
|
1410
|
+
.replace(/_/g, "\\137");
|
|
1411
|
+
const payload = `printf '${escapedMarker}\\n'\n`;
|
|
1412
|
+
const cleanup = () => {
|
|
1413
|
+
if (timeoutId) {
|
|
1414
|
+
clearTimeout(timeoutId);
|
|
1415
|
+
}
|
|
1416
|
+
if (probeIntervalId) {
|
|
1417
|
+
clearInterval(probeIntervalId);
|
|
1418
|
+
}
|
|
1419
|
+
stream.off("data", onData);
|
|
1420
|
+
stream.off("close", onClose);
|
|
1421
|
+
stream.off("error", onError);
|
|
1422
|
+
};
|
|
1423
|
+
const resolveIfReady = () => {
|
|
1424
|
+
const buffer = this.shellBuffers.get(key) || "";
|
|
1425
|
+
const markerIndex = buffer.indexOf(readyMarker);
|
|
1426
|
+
if (markerIndex === -1) {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const lineEndIndex = buffer.indexOf("\n", markerIndex);
|
|
1430
|
+
if (lineEndIndex === -1) {
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (!settled) {
|
|
1434
|
+
settled = true;
|
|
1435
|
+
this.shellBuffers.set(key, buffer.slice(lineEndIndex + 1));
|
|
1436
|
+
cleanup();
|
|
1437
|
+
resolve();
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
const onData = (chunk) => {
|
|
1441
|
+
this.appendShellBuffer(key, chunk);
|
|
1442
|
+
resolveIfReady();
|
|
1443
|
+
};
|
|
1444
|
+
const onClose = () => {
|
|
1445
|
+
if (settled) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
settled = true;
|
|
1449
|
+
cleanup();
|
|
1450
|
+
reject(new Error("Shell channel closed before ready probe completed"));
|
|
1451
|
+
};
|
|
1452
|
+
const onError = (error) => {
|
|
1453
|
+
if (settled) {
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
settled = true;
|
|
1457
|
+
cleanup();
|
|
1458
|
+
reject(error);
|
|
1459
|
+
};
|
|
1460
|
+
stream.on("data", onData);
|
|
1461
|
+
stream.on("close", onClose);
|
|
1462
|
+
stream.on("error", onError);
|
|
1463
|
+
timeoutId = setTimeout(() => {
|
|
1464
|
+
if (settled) {
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
settled = true;
|
|
1468
|
+
cleanup();
|
|
1469
|
+
reject(new Error(`Timed out waiting for shell ready marker after ${timeout}ms`));
|
|
1470
|
+
}, timeout);
|
|
1471
|
+
stream.write(payload);
|
|
1472
|
+
probeIntervalId = setInterval(() => {
|
|
1473
|
+
if (!settled) {
|
|
1474
|
+
stream.write(payload);
|
|
1475
|
+
}
|
|
1476
|
+
}, 1000);
|
|
1477
|
+
resolveIfReady();
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
attachShellLifecycleListeners(key, stream) {
|
|
1481
|
+
const handleUnavailable = (reason) => {
|
|
1482
|
+
if (this.shellStreams.get(key) !== stream) {
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
Logger.log(`Shell channel [${key}] unavailable: ${reason}`, "error");
|
|
1486
|
+
this.invalidateConnection(key);
|
|
1487
|
+
};
|
|
1488
|
+
stream.on("close", () => handleUnavailable("closed"));
|
|
1489
|
+
stream.on("error", (error) => handleUnavailable(`error: ${error.message}`));
|
|
1490
|
+
}
|
|
1491
|
+
async configureShellSession(key, stream, config) {
|
|
1492
|
+
// Send `export PS1=''` and `stty -echo` first. The verification probe
|
|
1493
|
+
// (printf, emitted by waitForShellReady below with an escaped marker)
|
|
1494
|
+
// follows them in the same shell input stream, so by the time the
|
|
1495
|
+
// probe's literal marker is emitted, stty has executed and terminal echo
|
|
1496
|
+
// is off. We must NOT write our own `printf '${marker}'` here: that
|
|
1497
|
+
// payload contains the literal marker, so when echo is still on it would
|
|
1498
|
+
// be echoed back and waitForShellReady would resolve prematurely, before
|
|
1499
|
+
// stty runs — leaving the next real command echoed.
|
|
1500
|
+
stream.write("export PS1=''\n");
|
|
1501
|
+
stream.write("stty -echo >/dev/null 2>&1 || true\n");
|
|
1502
|
+
const verifyId = this.generateMarkerId("stty");
|
|
1503
|
+
const verifyMarker = `__MCP_CONFIGURE__${verifyId}__`;
|
|
1504
|
+
await this.waitForShellReady(key, stream, verifyMarker, this.getShellReadyTimeoutMs(config));
|
|
1505
|
+
}
|
|
1506
|
+
runShellCommand(cmdString, directory, name, timeout) {
|
|
1507
|
+
const key = this.resolveFlatName(name);
|
|
1508
|
+
return this.enqueueShellCommand(key, () => this.executeShellCommand(key, cmdString, directory, timeout));
|
|
1509
|
+
}
|
|
1510
|
+
enqueueShellCommand(key, task) {
|
|
1511
|
+
const previous = this.shellQueues.get(key) || Promise.resolve();
|
|
1512
|
+
const next = previous.catch(() => undefined).then(task);
|
|
1513
|
+
this.shellQueues.set(key, next.then(() => undefined, () => undefined));
|
|
1514
|
+
return next;
|
|
1515
|
+
}
|
|
1516
|
+
executeShellCommand(key, cmdString, directory, timeout) {
|
|
1517
|
+
const stream = this.shellStreams.get(key);
|
|
1518
|
+
if (!stream || this.shellReady.get(key) !== true) {
|
|
1519
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Shell transport for [${key}] is not ready`, true);
|
|
1520
|
+
}
|
|
1521
|
+
const commandId = this.generateMarkerId("command");
|
|
1522
|
+
const config = this.getConfig(key);
|
|
1523
|
+
const script = this.buildShellCommandScript(commandId, cmdString, directory, config.commandTemplate);
|
|
1524
|
+
const maxOutputBytes = this.getMaxOutputBytes(config);
|
|
1525
|
+
return new Promise((resolve, reject) => {
|
|
1526
|
+
let settled = false;
|
|
1527
|
+
let timeoutId;
|
|
1528
|
+
const scanState = {
|
|
1529
|
+
outputStartIndex: -1,
|
|
1530
|
+
tail: "",
|
|
1531
|
+
// Whatever the previous command left behind cannot hold this command's
|
|
1532
|
+
// markers, so the scan starts past it.
|
|
1533
|
+
tailStart: (this.shellBuffers.get(key) || "").length,
|
|
1534
|
+
countedOutputEndIndex: -1,
|
|
1535
|
+
capturedOutputBytes: 0,
|
|
1536
|
+
};
|
|
1537
|
+
const cleanup = () => {
|
|
1538
|
+
if (timeoutId) {
|
|
1539
|
+
clearTimeout(timeoutId);
|
|
1540
|
+
}
|
|
1541
|
+
stream.off("data", onData);
|
|
1542
|
+
stream.off("close", onClose);
|
|
1543
|
+
stream.off("error", onError);
|
|
1544
|
+
};
|
|
1545
|
+
const finish = (error, output) => {
|
|
1546
|
+
if (settled) {
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
settled = true;
|
|
1550
|
+
cleanup();
|
|
1551
|
+
if (error) {
|
|
1552
|
+
reject(error);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
resolve(output || "");
|
|
1556
|
+
};
|
|
1557
|
+
const resolveIfComplete = () => {
|
|
1558
|
+
const matched = this.extractShellCommandResult(key, commandId, scanState);
|
|
1559
|
+
if (maxOutputBytes > 0 &&
|
|
1560
|
+
scanState.capturedOutputBytes > maxOutputBytes) {
|
|
1561
|
+
// The shell channel is shared by every command on this connection,
|
|
1562
|
+
// so it cannot simply be closed like an exec channel: the command
|
|
1563
|
+
// would keep writing into the buffer. Drop the connection instead.
|
|
1564
|
+
this.invalidateConnection(key);
|
|
1565
|
+
finish(new ToolError("OUTPUT_LIMIT_EXCEEDED", `[truncated] Output exceeded maxOutputBytes=${maxOutputBytes}; the command was aborted.`, false));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
if (!matched) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
this.shellBuffers.set(key, matched.remainder);
|
|
1572
|
+
const output = this.stripLeadingBeginMarker(this.cleanShellOutput(matched.output), commandId).trimEnd();
|
|
1573
|
+
if (matched.exitCode !== 0) {
|
|
1574
|
+
finish(new ToolError("COMMAND_EXECUTION_ERROR", this.formatCommandFailure(output, "", matched.exitCode) ||
|
|
1575
|
+
`Command failed with exit code ${matched.exitCode}`, false));
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
finish(undefined, output);
|
|
1579
|
+
};
|
|
1580
|
+
const onData = (chunk) => {
|
|
1581
|
+
scanState.tail += this.appendShellBuffer(key, chunk);
|
|
1582
|
+
resolveIfComplete();
|
|
1583
|
+
};
|
|
1584
|
+
const onClose = () => {
|
|
1585
|
+
finish(new ToolError("COMMAND_EXECUTION_ERROR", "Shell channel closed during command execution", true));
|
|
1586
|
+
};
|
|
1587
|
+
const onError = (error) => {
|
|
1588
|
+
finish(new ToolError("COMMAND_EXECUTION_ERROR", `Shell channel error during command execution: ${error.message}`, true));
|
|
1589
|
+
};
|
|
1590
|
+
stream.on("data", onData);
|
|
1591
|
+
stream.on("close", onClose);
|
|
1592
|
+
stream.on("error", onError);
|
|
1593
|
+
timeoutId = setTimeout(() => {
|
|
1594
|
+
this.invalidateConnection(key);
|
|
1595
|
+
finish(new ToolError("COMMAND_TIMEOUT", `[timeout] Command timed out after ${timeout}ms`, true));
|
|
1596
|
+
}, timeout);
|
|
1597
|
+
stream.write(script);
|
|
1598
|
+
resolveIfComplete();
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
buildShellCommandScript(commandId, cmdString, directory, commandTemplate) {
|
|
1602
|
+
const beginMarker = `__MCP_BEGIN__${commandId}__`;
|
|
1603
|
+
const endMarker = `__MCP_END__${commandId}__RC__`;
|
|
1604
|
+
let commandBody = directory
|
|
1605
|
+
? `cd -- ${shellQuote(directory)} && { ${cmdString}; }`
|
|
1606
|
+
: `{ ${cmdString}; }`;
|
|
1607
|
+
if (commandTemplate) {
|
|
1608
|
+
commandBody = applyCommandTemplate(commandTemplate, commandBody);
|
|
1609
|
+
}
|
|
1610
|
+
return [
|
|
1611
|
+
`printf '${beginMarker}\\n'`,
|
|
1612
|
+
commandBody,
|
|
1613
|
+
"__mcp_rc=$?",
|
|
1614
|
+
`printf '\\n${endMarker}%s__\\n' "$__mcp_rc"`,
|
|
1615
|
+
"",
|
|
1616
|
+
].join("\n");
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Drop the part of the tail that can no longer start a marker: a marker only
|
|
1620
|
+
* straddles the boundary of the chunk that just arrived, so keeping the last
|
|
1621
|
+
* `markerLength - 1` characters is enough.
|
|
1622
|
+
*/
|
|
1623
|
+
trimShellScanTail(scanState, markerLength) {
|
|
1624
|
+
this.advanceShellScanTail(scanState, scanState.tail.length - Math.min(scanState.tail.length, markerLength - 1));
|
|
1625
|
+
}
|
|
1626
|
+
advanceShellScanTail(scanState, offset) {
|
|
1627
|
+
if (offset <= 0) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
scanState.tailStart += offset;
|
|
1631
|
+
scanState.tail = scanState.tail.slice(offset);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Locate a finished command, scanning only the freshly arrived tail. The
|
|
1635
|
+
* accumulated buffer is read once, after the whole end marker is in hand.
|
|
1636
|
+
*/
|
|
1637
|
+
extractShellCommandResult(key, commandId, scanState) {
|
|
1638
|
+
if (scanState.outputStartIndex === -1) {
|
|
1639
|
+
const beginMarker = `__MCP_BEGIN__${commandId}__`;
|
|
1640
|
+
const beginIndex = scanState.tail.indexOf(beginMarker);
|
|
1641
|
+
if (beginIndex === -1) {
|
|
1642
|
+
this.trimShellScanTail(scanState, beginMarker.length);
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
const beginLineEndIndex = scanState.tail.indexOf("\n", beginIndex);
|
|
1646
|
+
if (beginLineEndIndex === -1) {
|
|
1647
|
+
this.advanceShellScanTail(scanState, beginIndex);
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
this.advanceShellScanTail(scanState, beginLineEndIndex + 1);
|
|
1651
|
+
scanState.outputStartIndex = scanState.tailStart;
|
|
1652
|
+
scanState.countedOutputEndIndex = scanState.tailStart;
|
|
1653
|
+
}
|
|
1654
|
+
// Search the fixed prefix rather than the whole pattern: its length is
|
|
1655
|
+
// known, which is what makes the retained overlap provably sufficient. The
|
|
1656
|
+
// exit code is then parsed from the short slice that follows it.
|
|
1657
|
+
const endPrefix = `__MCP_END__${commandId}__RC__`;
|
|
1658
|
+
const endIndex = scanState.tail.indexOf(endPrefix);
|
|
1659
|
+
if (endIndex === -1) {
|
|
1660
|
+
// Keep the marker overlap plus CRLF immediately before it. The command
|
|
1661
|
+
// wrapper emits that newline as framing, so it must not count against the
|
|
1662
|
+
// user's output limit.
|
|
1663
|
+
this.trimShellScanTail(scanState, endPrefix.length + 2);
|
|
1664
|
+
this.countShellOutputThrough(key, scanState, scanState.tailStart);
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
const absoluteEndIndex = scanState.tailStart + endIndex;
|
|
1668
|
+
const buffer = this.shellBuffers.get(key) || "";
|
|
1669
|
+
let outputEndIndex = absoluteEndIndex;
|
|
1670
|
+
if (buffer[outputEndIndex - 1] === "\n") {
|
|
1671
|
+
outputEndIndex -= 1;
|
|
1672
|
+
if (buffer[outputEndIndex - 1] === "\r") {
|
|
1673
|
+
outputEndIndex -= 1;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
this.countShellOutputThrough(key, scanState, outputEndIndex);
|
|
1677
|
+
const exitCodeStart = endIndex + endPrefix.length;
|
|
1678
|
+
const matched = SHELL_EXIT_CODE_PATTERN.exec(scanState.tail.slice(exitCodeStart, exitCodeStart + SHELL_EXIT_CODE_MAX_LENGTH));
|
|
1679
|
+
if (!matched) {
|
|
1680
|
+
// The prefix arrived but the exit code has not; resume from here.
|
|
1681
|
+
this.advanceShellScanTail(scanState, endIndex);
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
const consumedEndIndex = absoluteEndIndex + endPrefix.length + matched[0].length;
|
|
1685
|
+
return {
|
|
1686
|
+
output: buffer.slice(scanState.outputStartIndex, absoluteEndIndex),
|
|
1687
|
+
exitCode: Number.parseInt(matched[1], 10),
|
|
1688
|
+
remainder: buffer.slice(consumedEndIndex),
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
countShellOutputThrough(key, scanState, endIndex) {
|
|
1692
|
+
if (scanState.outputStartIndex === -1 ||
|
|
1693
|
+
endIndex <= scanState.countedOutputEndIndex) {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const buffer = this.shellBuffers.get(key) || "";
|
|
1697
|
+
scanState.capturedOutputBytes += Buffer.byteLength(buffer.slice(scanState.countedOutputEndIndex, endIndex), "utf8");
|
|
1698
|
+
scanState.countedOutputEndIndex = endIndex;
|
|
1699
|
+
}
|
|
1700
|
+
/** Appends the decoded chunk and returns just that text. */
|
|
1701
|
+
appendShellBuffer(key, chunk) {
|
|
1702
|
+
let decoder = this.shellDecoders.get(key);
|
|
1703
|
+
if (!decoder) {
|
|
1704
|
+
decoder = new StringDecoder("utf8");
|
|
1705
|
+
this.shellDecoders.set(key, decoder);
|
|
1706
|
+
}
|
|
1707
|
+
// Concatenation alone stays cheap; it is reading the result that forces the
|
|
1708
|
+
// rope to flatten, so nothing here may inspect the accumulated buffer.
|
|
1709
|
+
const text = decoder.write(chunk);
|
|
1710
|
+
const current = this.shellBuffers.get(key) || "";
|
|
1711
|
+
this.shellBuffers.set(key, current + text);
|
|
1712
|
+
return text;
|
|
1713
|
+
}
|
|
1714
|
+
cleanShellOutput(output) {
|
|
1715
|
+
return output
|
|
1716
|
+
.replace(ANSI_OSC_PATTERN, "")
|
|
1717
|
+
.replace(ANSI_CSI_PATTERN, "")
|
|
1718
|
+
.replace(/\r\n/g, "\n")
|
|
1719
|
+
.replace(/\r/g, "\n");
|
|
1720
|
+
}
|
|
1721
|
+
stripLeadingBeginMarker(output, commandId) {
|
|
1722
|
+
const beginPrefix = `__MCP_BEGIN__${commandId}__`;
|
|
1723
|
+
if (!output.startsWith(beginPrefix)) {
|
|
1724
|
+
return output;
|
|
1725
|
+
}
|
|
1726
|
+
const newlineIndex = output.indexOf("\n");
|
|
1727
|
+
if (newlineIndex === -1) {
|
|
1728
|
+
return "";
|
|
1729
|
+
}
|
|
1730
|
+
return output.slice(newlineIndex + 1);
|
|
1731
|
+
}
|
|
1732
|
+
generateMarkerId(prefix) {
|
|
1733
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
|
1734
|
+
}
|
|
1735
|
+
cleanupShellState(key, closeStream = false) {
|
|
1736
|
+
const stream = this.shellStreams.get(key);
|
|
1737
|
+
if (closeStream && stream) {
|
|
1738
|
+
try {
|
|
1739
|
+
stream.close();
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
// Ignore shell close errors during cleanup.
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
this.shellStreams.delete(key);
|
|
1746
|
+
this.shellReady.delete(key);
|
|
1747
|
+
this.shellQueues.delete(key);
|
|
1748
|
+
this.shellBuffers.delete(key);
|
|
1749
|
+
this.shellDecoders.delete(key);
|
|
1750
|
+
}
|
|
1751
|
+
clearConnectionState(key) {
|
|
1752
|
+
const pendingStatusCollection = this.pendingStatusCollections.get(key);
|
|
1753
|
+
if (pendingStatusCollection) {
|
|
1754
|
+
clearTimeout(pendingStatusCollection);
|
|
1755
|
+
this.pendingStatusCollections.delete(key);
|
|
1756
|
+
}
|
|
1757
|
+
this.cleanupShellState(key);
|
|
1758
|
+
this.connected.set(key, false);
|
|
1759
|
+
this.clients.delete(key);
|
|
1760
|
+
this.pendingConnections.delete(key);
|
|
1761
|
+
}
|
|
1762
|
+
invalidateConnection(key) {
|
|
1763
|
+
const client = this.clients.get(key);
|
|
1764
|
+
this.clearConnectionState(key);
|
|
1765
|
+
if (client) {
|
|
1766
|
+
try {
|
|
1767
|
+
client.end();
|
|
1768
|
+
}
|
|
1769
|
+
catch {
|
|
1770
|
+
// Ignore client close errors during invalidation.
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
//# sourceMappingURL=ssh-connection-manager.js.map
|