@yawlabs/ssh-mcp 0.14.0 → 0.15.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 +312 -291
- package/bin/ssh-mcp.mjs +237 -67
- package/dist/diagnose.d.ts +33 -0
- package/dist/env.d.ts +87 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +990 -673
- package/dist/ops.d.ts +54 -0
- package/dist/policy.d.ts +22 -0
- package/dist/pool.d.ts +34 -0
- package/dist/server.d.ts +17 -223
- package/dist/server.js +989 -672
- package/dist/ssh-config.d.ts +4 -0
- package/dist/ssh.d.ts +217 -0
- package/dist/tools.d.ts +3 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
11
|
|
|
6
12
|
// src/env.ts
|
|
7
13
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
8
|
-
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as
|
|
9
|
-
import { homedir as
|
|
10
|
-
import { join as
|
|
14
|
+
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
|
|
15
|
+
import { homedir as homedir3 } from "os";
|
|
16
|
+
import { join as join3 } from "path";
|
|
11
17
|
|
|
12
18
|
// src/diagnose.ts
|
|
13
19
|
import { execFileSync } from "child_process";
|
|
14
20
|
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
15
21
|
import { homedir } from "os";
|
|
16
22
|
import { join } from "path";
|
|
23
|
+
var SSH_NON_KEY_FILES = /* @__PURE__ */ new Set([
|
|
24
|
+
"known_hosts",
|
|
25
|
+
"known_hosts.old",
|
|
26
|
+
"config",
|
|
27
|
+
"authorized_keys",
|
|
28
|
+
"environment"
|
|
29
|
+
]);
|
|
17
30
|
function isValidHostname(host) {
|
|
18
31
|
if (host.length === 0 || host.length > 253) return false;
|
|
19
32
|
if (host.startsWith("[")) {
|
|
@@ -60,16 +73,16 @@ ${stdout2}` };
|
|
|
60
73
|
};
|
|
61
74
|
}
|
|
62
75
|
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
63
|
-
if (
|
|
76
|
+
if (stdout.includes("The agent has no identities") || stdout.includes("no identities")) {
|
|
64
77
|
return {
|
|
65
|
-
status: "
|
|
66
|
-
message:
|
|
78
|
+
status: "warning",
|
|
79
|
+
message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
67
80
|
};
|
|
68
81
|
}
|
|
69
|
-
if (
|
|
82
|
+
if (!ok) {
|
|
70
83
|
return {
|
|
71
|
-
status: "
|
|
72
|
-
message: "ssh-agent is
|
|
84
|
+
status: "error",
|
|
85
|
+
message: stdout.includes("Could not open a connection") ? `SSH_AUTH_SOCK is set to "${sock}" but the agent is not reachable. The agent process may have died. Run: eval "$(ssh-agent -s)"` : `SSH_AUTH_SOCK is set to "${sock}" but ssh-add could not query the agent: ${stdout || "no output"}. Run: eval "$(ssh-agent -s)"`
|
|
73
86
|
};
|
|
74
87
|
}
|
|
75
88
|
return { status: "ok", message: `ssh-agent running with keys:
|
|
@@ -90,9 +103,7 @@ function checkSshKeys() {
|
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
try {
|
|
93
|
-
const allFiles = readdirSync(sshDir).filter(
|
|
94
|
-
(f) => !f.endsWith(".pub") && !["known_hosts", "known_hosts.old", "config", "authorized_keys"].includes(f)
|
|
95
|
-
);
|
|
106
|
+
const allFiles = readdirSync(sshDir).filter((f) => !f.endsWith(".pub") && !SSH_NON_KEY_FILES.has(f));
|
|
96
107
|
for (const f of allFiles) {
|
|
97
108
|
if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
|
|
98
109
|
try {
|
|
@@ -134,10 +145,17 @@ function checkKnownHosts(host) {
|
|
|
134
145
|
}
|
|
135
146
|
return { status: "ok", message: `Host "${host}" found in known_hosts` };
|
|
136
147
|
}
|
|
137
|
-
function
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
function classifySshProbe(ok, output) {
|
|
149
|
+
if (ok && output.includes("SSH_OK")) return "ok";
|
|
150
|
+
if (output.includes("Permission denied")) return "permission-denied";
|
|
151
|
+
if (output.includes("Connection refused")) return "connection-refused";
|
|
152
|
+
if (output.includes("timed out")) return "timed-out";
|
|
153
|
+
if (output.includes("Host key verification failed")) return "host-key-mismatch";
|
|
154
|
+
if (output.includes("Could not resolve")) return "dns-failure";
|
|
155
|
+
return "unknown";
|
|
156
|
+
}
|
|
157
|
+
function probeSshConnection(host, port) {
|
|
158
|
+
const start = Date.now();
|
|
141
159
|
const { ok, stdout } = runArgs("ssh", [
|
|
142
160
|
"-o",
|
|
143
161
|
"ConnectTimeout=5",
|
|
@@ -152,40 +170,66 @@ function checkConnectivity(host, port = 22) {
|
|
|
152
170
|
"echo",
|
|
153
171
|
"SSH_OK"
|
|
154
172
|
]);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
return {
|
|
160
|
-
status: "error",
|
|
161
|
-
message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
if (stdout.includes("Connection refused")) {
|
|
165
|
-
return {
|
|
166
|
-
status: "error",
|
|
167
|
-
message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
if (stdout.includes("Connection timed out") || stdout.includes("timed out")) {
|
|
171
|
-
return {
|
|
172
|
-
status: "error",
|
|
173
|
-
message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
|
|
174
|
-
};
|
|
173
|
+
return { outcome: classifySshProbe(ok, stdout), output: stdout, elapsedMs: Date.now() - start };
|
|
174
|
+
}
|
|
175
|
+
function checkConnectivity(host, port = 22) {
|
|
176
|
+
if (!isValidHostname(host)) {
|
|
177
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
175
178
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
const { outcome, output } = probeSshConnection(host, port);
|
|
180
|
+
switch (outcome) {
|
|
181
|
+
case "ok":
|
|
182
|
+
return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
|
|
183
|
+
case "permission-denied":
|
|
184
|
+
return {
|
|
185
|
+
status: "error",
|
|
186
|
+
message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
|
|
187
|
+
};
|
|
188
|
+
case "connection-refused":
|
|
189
|
+
return {
|
|
190
|
+
status: "error",
|
|
191
|
+
message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
|
|
192
|
+
};
|
|
193
|
+
case "timed-out":
|
|
194
|
+
return {
|
|
195
|
+
status: "error",
|
|
196
|
+
message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
|
|
197
|
+
};
|
|
198
|
+
case "host-key-mismatch":
|
|
199
|
+
return {
|
|
200
|
+
status: "error",
|
|
201
|
+
message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R "${host}" && ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
|
|
202
|
+
};
|
|
203
|
+
case "dns-failure":
|
|
204
|
+
return {
|
|
205
|
+
status: "error",
|
|
206
|
+
message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
|
|
207
|
+
};
|
|
208
|
+
default:
|
|
209
|
+
return { status: "error", message: `SSH connection failed: ${output}` };
|
|
181
210
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
211
|
+
}
|
|
212
|
+
function matchesHostPattern(pattern, host) {
|
|
213
|
+
if (pattern === "*") return true;
|
|
214
|
+
if (pattern === host) return true;
|
|
215
|
+
if (pattern.includes("*") || pattern.includes("?")) {
|
|
216
|
+
const escaped = pattern.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
217
|
+
return new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$").test(host);
|
|
187
218
|
}
|
|
188
|
-
return
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
function hostLineSelects(patternList, host) {
|
|
222
|
+
let positive = false;
|
|
223
|
+
for (const raw of patternList.split(/\s+/)) {
|
|
224
|
+
if (!raw) continue;
|
|
225
|
+
const negated = raw.startsWith("!");
|
|
226
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
227
|
+
if (!pattern) continue;
|
|
228
|
+
if (!matchesHostPattern(pattern, host)) continue;
|
|
229
|
+
if (negated) return false;
|
|
230
|
+
positive = true;
|
|
231
|
+
}
|
|
232
|
+
return positive;
|
|
189
233
|
}
|
|
190
234
|
function checkSshConfig(host) {
|
|
191
235
|
const configPath = join(homedir(), ".ssh", "config");
|
|
@@ -194,24 +238,17 @@ function checkSshConfig(host) {
|
|
|
194
238
|
}
|
|
195
239
|
try {
|
|
196
240
|
const content = readFileSync(configPath, "utf8");
|
|
197
|
-
const lines = content.split(
|
|
241
|
+
const lines = content.split(/\r?\n/);
|
|
198
242
|
let inHostBlock = false;
|
|
199
243
|
const hostConfig = [];
|
|
200
|
-
for (const
|
|
201
|
-
const trimmed =
|
|
202
|
-
if (/^Host\s
|
|
203
|
-
const
|
|
204
|
-
inHostBlock =
|
|
205
|
-
if (p === "*") return true;
|
|
206
|
-
if (p === host) return true;
|
|
207
|
-
if (p.includes("*") || p.includes("?")) {
|
|
208
|
-
const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
209
|
-
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
210
|
-
return regex.test(host);
|
|
211
|
-
}
|
|
212
|
-
return false;
|
|
213
|
-
});
|
|
244
|
+
for (const rawLine of lines) {
|
|
245
|
+
const trimmed = rawLine.replace(/(^|\s)#.*/, "$1").trim();
|
|
246
|
+
if (/^Host[\s=]/i.test(trimmed)) {
|
|
247
|
+
const patternList = trimmed.replace(/^Host[\s=]+/i, "").trim();
|
|
248
|
+
inHostBlock = hostLineSelects(patternList, host);
|
|
214
249
|
if (inHostBlock) hostConfig.push(trimmed);
|
|
250
|
+
} else if (/^Match[\s=]/i.test(trimmed)) {
|
|
251
|
+
inHostBlock = false;
|
|
215
252
|
} else if (inHostBlock && trimmed) {
|
|
216
253
|
hostConfig.push(trimmed);
|
|
217
254
|
}
|
|
@@ -261,6 +298,15 @@ function diagnose(host, port = 22) {
|
|
|
261
298
|
return { overall, checks, suggestions };
|
|
262
299
|
}
|
|
263
300
|
|
|
301
|
+
// src/ssh.ts
|
|
302
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
303
|
+
import { createRequire } from "module";
|
|
304
|
+
import { homedir as homedir2 } from "os";
|
|
305
|
+
import { join as join2 } from "path";
|
|
306
|
+
import {
|
|
307
|
+
Client
|
|
308
|
+
} from "ssh2";
|
|
309
|
+
|
|
264
310
|
// src/ssh-config.ts
|
|
265
311
|
function parseSshConfigOutput(stdout) {
|
|
266
312
|
const all = {};
|
|
@@ -280,450 +326,220 @@ function parseSshConfigOutput(stdout) {
|
|
|
280
326
|
return { all, identityFiles };
|
|
281
327
|
}
|
|
282
328
|
|
|
283
|
-
// src/
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
329
|
+
// src/ssh.ts
|
|
330
|
+
var sshConfigCache = /* @__PURE__ */ new Map();
|
|
331
|
+
function resolveFromSshConfig(host) {
|
|
332
|
+
const cached = sshConfigCache.get(host);
|
|
333
|
+
if (cached !== void 0) return cached;
|
|
334
|
+
const result = resolveFromSshConfigUncached(host);
|
|
335
|
+
sshConfigCache.set(host, result);
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
function resolveFromSshConfigUncached(host) {
|
|
339
|
+
try {
|
|
340
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
341
|
+
if (!ok) return null;
|
|
342
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
343
|
+
return {
|
|
344
|
+
hostname: all.hostname || host,
|
|
345
|
+
user: all.user || "",
|
|
346
|
+
port: all.port || "22",
|
|
347
|
+
identityFiles,
|
|
348
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
349
|
+
};
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
288
352
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
353
|
+
}
|
|
354
|
+
function unbracketHost(host) {
|
|
355
|
+
return host.length > 2 && host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
356
|
+
}
|
|
357
|
+
function knownHostsTargets(host, port) {
|
|
358
|
+
const bare = unbracketHost(host);
|
|
359
|
+
const isIpv6 = bare.includes(":");
|
|
360
|
+
if (!isValidHostname(isIpv6 ? `[${bare}]` : bare)) return [];
|
|
361
|
+
return port && port !== 22 ? [`[${bare}]:${port}`, bare] : [bare];
|
|
362
|
+
}
|
|
363
|
+
function readKnownHostsEntries(host, port) {
|
|
364
|
+
const entries = [];
|
|
365
|
+
for (const target of knownHostsTargets(host, port)) {
|
|
366
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
367
|
+
if (!ok || !stdout.trim()) continue;
|
|
368
|
+
for (const line of stdout.split("\n")) {
|
|
369
|
+
const trimmed = line.trim();
|
|
370
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
371
|
+
const parts = trimmed.split(/\s+/);
|
|
372
|
+
if (parts[0].startsWith("@")) continue;
|
|
373
|
+
if (parts.length < 3) continue;
|
|
374
|
+
try {
|
|
375
|
+
entries.push({ type: parts[1], key: Buffer.from(parts[2], "base64") });
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
294
378
|
}
|
|
295
379
|
}
|
|
296
|
-
|
|
297
|
-
const stdout = execFileSync2(cmd, args, {
|
|
298
|
-
env,
|
|
299
|
-
encoding: "utf8",
|
|
300
|
-
timeout: 1e4,
|
|
301
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
302
|
-
});
|
|
303
|
-
return { stdout: stdout.trim(), ok: true };
|
|
304
|
-
} catch (e) {
|
|
305
|
-
const err = e;
|
|
306
|
-
const so = err.stdout?.toString().trim() || "";
|
|
307
|
-
const se = err.stderr?.toString().trim() || "";
|
|
308
|
-
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
309
|
-
return { stdout: output, ok: false };
|
|
310
|
-
}
|
|
380
|
+
return entries;
|
|
311
381
|
}
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
319
|
-
return {
|
|
320
|
-
running: true,
|
|
321
|
-
reachable: true,
|
|
322
|
-
socket,
|
|
323
|
-
keys,
|
|
324
|
-
started: false,
|
|
325
|
-
message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
|
|
326
|
-
};
|
|
382
|
+
function hostKeyBlobType(key) {
|
|
383
|
+
if (key.length < 4) return null;
|
|
384
|
+
const len = key.readUInt32BE(0);
|
|
385
|
+
if (len === 0 || len > 64 || key.length < 4 + len) return null;
|
|
386
|
+
const type = key.toString("utf8", 4, 4 + len);
|
|
387
|
+
return /^[a-zA-Z0-9@._-]+$/.test(type) ? type : null;
|
|
327
388
|
}
|
|
328
|
-
var
|
|
329
|
-
|
|
330
|
-
|
|
389
|
+
var HOST_KEY_TYPE_TO_ALGORITHMS = {
|
|
390
|
+
"ssh-rsa": ["rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"]
|
|
391
|
+
};
|
|
392
|
+
var SSH2_CONSTANTS_MODULE = "ssh2/lib/protocol/constants.js";
|
|
393
|
+
function loadSsh2Constants() {
|
|
331
394
|
try {
|
|
332
|
-
|
|
395
|
+
return __require("ssh2/lib/protocol/constants.js");
|
|
333
396
|
} catch {
|
|
334
397
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (sock) {
|
|
340
|
-
const result = probeAgent(sock, "ssh-agent");
|
|
341
|
-
if (result) return result;
|
|
342
|
-
}
|
|
343
|
-
if (process.platform === "win32") {
|
|
344
|
-
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
345
|
-
if (result) return result;
|
|
346
|
-
return {
|
|
347
|
-
running: false,
|
|
348
|
-
reachable: false,
|
|
349
|
-
keys: [],
|
|
350
|
-
started: false,
|
|
351
|
-
message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
352
|
-
};
|
|
398
|
+
try {
|
|
399
|
+
return createRequire(import.meta.url)(SSH2_CONSTANTS_MODULE);
|
|
400
|
+
} catch {
|
|
401
|
+
return null;
|
|
353
402
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
403
|
+
}
|
|
404
|
+
var ssh2DefaultHostKeyAlgos;
|
|
405
|
+
function defaultServerHostKeyAlgorithms() {
|
|
406
|
+
if (ssh2DefaultHostKeyAlgos !== void 0) return ssh2DefaultHostKeyAlgos;
|
|
407
|
+
const DEFAULT_SERVER_HOST_KEY = loadSsh2Constants()?.DEFAULT_SERVER_HOST_KEY;
|
|
408
|
+
ssh2DefaultHostKeyAlgos = Array.isArray(DEFAULT_SERVER_HOST_KEY) && DEFAULT_SERVER_HOST_KEY.length > 0 && DEFAULT_SERVER_HOST_KEY.every((a) => typeof a === "string") ? DEFAULT_SERVER_HOST_KEY.slice() : null;
|
|
409
|
+
return ssh2DefaultHostKeyAlgos;
|
|
410
|
+
}
|
|
411
|
+
function hostKeyAlgorithmOrder(knownHostTypes) {
|
|
412
|
+
if (knownHostTypes.length === 0) return null;
|
|
413
|
+
const defaults = defaultServerHostKeyAlgorithms();
|
|
414
|
+
if (!defaults) return null;
|
|
415
|
+
const preferred = /* @__PURE__ */ new Set();
|
|
416
|
+
for (const type of knownHostTypes) {
|
|
417
|
+
for (const algo of HOST_KEY_TYPE_TO_ALGORITHMS[type] ?? [type]) preferred.add(algo);
|
|
418
|
+
}
|
|
419
|
+
const front = defaults.filter((a) => preferred.has(a));
|
|
420
|
+
const back = defaults.filter((a) => !preferred.has(a));
|
|
421
|
+
if (front.length === 0 || back.length === 0) return null;
|
|
422
|
+
return [...front, ...back];
|
|
423
|
+
}
|
|
424
|
+
var KNOWN_HOST_TYPE_TTL_MS = 5e3;
|
|
425
|
+
var knownHostTypeCache = /* @__PURE__ */ new Map();
|
|
426
|
+
function cachedKnownHostTypes(hosts, port) {
|
|
427
|
+
const cacheKey = `${hosts.join(" ")}:${port ?? ""}`;
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const hit = knownHostTypeCache.get(cacheKey);
|
|
430
|
+
if (hit && now - hit.at < KNOWN_HOST_TYPE_TTL_MS) return hit.types;
|
|
431
|
+
const types = [...new Set(hosts.flatMap((h) => readKnownHostsEntries(h, port).map((e) => e.type)))];
|
|
432
|
+
knownHostTypeCache.set(cacheKey, { at: now, types });
|
|
433
|
+
return types;
|
|
434
|
+
}
|
|
435
|
+
function buildHostVerifier(hosts, port, rejection) {
|
|
436
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
437
|
+
const label = hosts.join(" / ");
|
|
438
|
+
const remediationHost = unbracketHost(hosts[0]);
|
|
439
|
+
return (key) => {
|
|
440
|
+
rejection.current = null;
|
|
441
|
+
const known = hosts.flatMap((h) => readKnownHostsEntries(h, port));
|
|
442
|
+
if (known.length === 0) {
|
|
443
|
+
if (strict) {
|
|
444
|
+
rejection.current = {
|
|
445
|
+
reason: "unknown-host-strict",
|
|
446
|
+
message: `no known_hosts entry for ${label}, and SSH_MCP_STRICT_HOST_KEY=1 requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
447
|
+
};
|
|
363
448
|
}
|
|
364
|
-
return
|
|
365
|
-
running: true,
|
|
366
|
-
reachable: true,
|
|
367
|
-
socket: sockMatch[1],
|
|
368
|
-
keys: [],
|
|
369
|
-
started: true,
|
|
370
|
-
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
371
|
-
message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
372
|
-
};
|
|
449
|
+
return !strict;
|
|
373
450
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
451
|
+
if (known.some((e) => e.key.equals(key))) return true;
|
|
452
|
+
const offered = hostKeyBlobType(key);
|
|
453
|
+
const knownTypes = [...new Set(known.map((e) => e.type))];
|
|
454
|
+
rejection.current = offered && !knownTypes.includes(offered) ? {
|
|
455
|
+
reason: "algorithm-not-in-known-hosts",
|
|
456
|
+
message: `the server offered a ${offered} host key, but known_hosts has only ${knownTypes.join(", ")} for ${label}. This is NOT a key mismatch -- there is no ${offered} entry to compare it against. Refresh the entry: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
457
|
+
} : {
|
|
458
|
+
reason: "key-mismatch",
|
|
459
|
+
message: `the server's ${offered ?? "offered"} host key does NOT match the known_hosts entry of the same type for ${label}. This can mean a man-in-the-middle attack, or that the host was legitimately rekeyed. Verify the fingerprint out of band before removing the old entry with: ssh-keygen -R "${remediationHost}"`
|
|
460
|
+
};
|
|
461
|
+
return false;
|
|
381
462
|
};
|
|
382
463
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
464
|
+
var PRIVATE_KEY_MARKER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
465
|
+
function looksLikePrivateKey(content) {
|
|
466
|
+
const text = content.toString("utf8");
|
|
467
|
+
return PRIVATE_KEY_MARKER.test(text) || text.trimStart().startsWith("PuTTY-User-Key-File-");
|
|
468
|
+
}
|
|
469
|
+
function isEncryptedKey(content) {
|
|
470
|
+
const text = content.toString("utf8");
|
|
471
|
+
if (text.includes("ENCRYPTED")) return true;
|
|
472
|
+
const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
|
|
473
|
+
if (m) {
|
|
386
474
|
try {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
475
|
+
const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
|
|
476
|
+
const magic = "openssh-key-v1\0";
|
|
477
|
+
if (raw.toString("latin1", 0, magic.length) !== magic) return true;
|
|
478
|
+
const cipherLen = raw.readUInt32BE(magic.length);
|
|
479
|
+
const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
|
|
480
|
+
return cipher !== "none";
|
|
392
481
|
} catch {
|
|
482
|
+
return true;
|
|
393
483
|
}
|
|
394
484
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
function resolveConfig(config) {
|
|
488
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
489
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
490
|
+
const verifierHosts = [];
|
|
491
|
+
const seenHosts = /* @__PURE__ */ new Set();
|
|
492
|
+
for (const candidate of [config.host, sshConfig?.hostname]) {
|
|
493
|
+
if (!candidate) continue;
|
|
494
|
+
const canonical = unbracketHost(candidate);
|
|
495
|
+
if (seenHosts.has(canonical)) continue;
|
|
496
|
+
seenHosts.add(canonical);
|
|
497
|
+
verifierHosts.push(candidate);
|
|
498
|
+
}
|
|
499
|
+
const hostKeyRejection = { current: null };
|
|
500
|
+
const connectConfig = {
|
|
501
|
+
host: sshConfig?.hostname || config.host,
|
|
502
|
+
port,
|
|
503
|
+
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
504
|
+
keepaliveInterval: 15e3,
|
|
505
|
+
keepaliveCountMax: 3,
|
|
506
|
+
hostVerifier: buildHostVerifier(verifierHosts, port, hostKeyRejection)
|
|
507
|
+
};
|
|
508
|
+
let algorithmsApplied = false;
|
|
509
|
+
const applyHostKeyAlgorithms = () => {
|
|
510
|
+
if (algorithmsApplied) return;
|
|
511
|
+
algorithmsApplied = true;
|
|
512
|
+
const algorithmOrder = hostKeyAlgorithmOrder(cachedKnownHostTypes(verifierHosts, port));
|
|
513
|
+
if (algorithmOrder) {
|
|
514
|
+
connectConfig.algorithms = { serverHostKey: algorithmOrder };
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const home = homedir2();
|
|
518
|
+
if (config.privateKeyPath) {
|
|
519
|
+
const keyPath = config.privateKeyPath.startsWith("~") ? join2(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
|
|
520
|
+
connectConfig.privateKey = readFileSync2(keyPath);
|
|
521
|
+
} else if (config.password) {
|
|
522
|
+
connectConfig.password = config.password;
|
|
523
|
+
} else {
|
|
524
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
525
|
+
if (agentSock) {
|
|
526
|
+
connectConfig.agent = agentSock;
|
|
527
|
+
}
|
|
528
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
|
|
529
|
+
for (const keyPath of keyPaths) {
|
|
530
|
+
let keyData;
|
|
531
|
+
try {
|
|
532
|
+
keyData = readFileSync2(keyPath);
|
|
533
|
+
} catch {
|
|
534
|
+
continue;
|
|
409
535
|
}
|
|
536
|
+
if (!looksLikePrivateKey(keyData)) continue;
|
|
537
|
+
if (agentSock && isEncryptedKey(keyData)) continue;
|
|
538
|
+
connectConfig.privateKey = keyData;
|
|
539
|
+
break;
|
|
410
540
|
}
|
|
411
|
-
} catch {
|
|
412
|
-
}
|
|
413
|
-
return "unknown";
|
|
414
|
-
}
|
|
415
|
-
function listSshKeys() {
|
|
416
|
-
const sshDir = join2(homedir2(), ".ssh");
|
|
417
|
-
if (!existsSync2(sshDir)) return [];
|
|
418
|
-
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
419
|
-
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
420
|
-
if (agentOk && !agentOut.includes("no identities")) {
|
|
421
|
-
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
422
|
-
const match = line.match(/(\S+:\S+)/);
|
|
423
|
-
if (match) loadedFingerprints.add(match[1]);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
|
|
427
|
-
const keys = [];
|
|
428
|
-
let files;
|
|
429
|
-
try {
|
|
430
|
-
files = readdirSync2(sshDir);
|
|
431
|
-
} catch {
|
|
432
|
-
return [];
|
|
433
|
-
}
|
|
434
|
-
for (const file of files) {
|
|
435
|
-
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
436
|
-
const filePath = join2(sshDir, file);
|
|
437
|
-
try {
|
|
438
|
-
const stat = statSync(filePath);
|
|
439
|
-
if (!stat.isFile()) continue;
|
|
440
|
-
const content = readFileSync2(filePath, "utf8");
|
|
441
|
-
if (!content.includes("PRIVATE KEY")) continue;
|
|
442
|
-
const type = detectKeyType(filePath, file);
|
|
443
|
-
let fingerprint;
|
|
444
|
-
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
445
|
-
if (fpOk) {
|
|
446
|
-
const match = fpOut.match(/(\S+:\S+)/);
|
|
447
|
-
fingerprint = match?.[1];
|
|
448
|
-
}
|
|
449
|
-
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
450
|
-
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
451
|
-
} catch {
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return keys;
|
|
455
|
-
}
|
|
456
|
-
function loadKey(keyPath) {
|
|
457
|
-
const agent = ensureAgent();
|
|
458
|
-
if (!agent.reachable) {
|
|
459
|
-
return { status: "error", message: agent.message };
|
|
460
|
-
}
|
|
461
|
-
const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
|
|
462
|
-
if (!existsSync2(resolved)) {
|
|
463
|
-
return { status: "error", message: `Key not found: ${resolved}` };
|
|
464
|
-
}
|
|
465
|
-
const { stdout, ok } = runArgs("ssh-add", [resolved]);
|
|
466
|
-
if (ok) {
|
|
467
|
-
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
468
|
-
}
|
|
469
|
-
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
470
|
-
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
471
|
-
}
|
|
472
|
-
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
473
|
-
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
474
|
-
}
|
|
475
|
-
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
476
|
-
}
|
|
477
|
-
function configLookup(host) {
|
|
478
|
-
if (!isValidHostname(host)) {
|
|
479
|
-
return { error: `Invalid hostname: "${host}"` };
|
|
480
|
-
}
|
|
481
|
-
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
482
|
-
if (!ok) {
|
|
483
|
-
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
484
|
-
}
|
|
485
|
-
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
486
|
-
return {
|
|
487
|
-
hostname: all.hostname || host,
|
|
488
|
-
user: all.user || "",
|
|
489
|
-
port: all.port || "22",
|
|
490
|
-
identityFile: identityFiles,
|
|
491
|
-
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
492
|
-
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
493
|
-
all,
|
|
494
|
-
raw: stdout
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
function fixKnownHosts(host, port = 22) {
|
|
498
|
-
if (!isValidHostname(host)) {
|
|
499
|
-
return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
|
|
500
|
-
}
|
|
501
|
-
const actions = [];
|
|
502
|
-
const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
|
|
503
|
-
if (removeOk) {
|
|
504
|
-
actions.push(`Removed old host key for ${host}`);
|
|
505
|
-
}
|
|
506
|
-
if (port !== 22) {
|
|
507
|
-
const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
|
|
508
|
-
if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
|
|
509
|
-
}
|
|
510
|
-
const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
|
|
511
|
-
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
512
|
-
if (scanOk && scanOut.trim()) {
|
|
513
|
-
try {
|
|
514
|
-
const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
|
|
515
|
-
appendFileSync(knownHostsPath, `
|
|
516
|
-
${scanOut.trim()}
|
|
517
|
-
`);
|
|
518
|
-
actions.push(`Added new host key for ${host}`);
|
|
519
|
-
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
520
|
-
} catch (e) {
|
|
521
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
522
|
-
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
526
|
-
}
|
|
527
|
-
function checkGitSsh(host = "github.com", user = "git") {
|
|
528
|
-
if (!isValidHostname(host)) {
|
|
529
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
530
|
-
}
|
|
531
|
-
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
532
|
-
const text = stdout;
|
|
533
|
-
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
534
|
-
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
535
|
-
return {
|
|
536
|
-
status: "ok",
|
|
537
|
-
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
538
|
-
authenticatedAs: userMatch?.[1]
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
if (text.includes("Permission denied")) {
|
|
542
|
-
return {
|
|
543
|
-
status: "error",
|
|
544
|
-
message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
if (text.includes("Connection refused")) {
|
|
548
|
-
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
549
|
-
}
|
|
550
|
-
if (text.includes("timed out") || text.includes("Connection timed out")) {
|
|
551
|
-
return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
|
|
552
|
-
}
|
|
553
|
-
if (text.includes("Could not resolve")) {
|
|
554
|
-
return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
|
|
555
|
-
}
|
|
556
|
-
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
557
|
-
}
|
|
558
|
-
function testConnection(host, port = 22) {
|
|
559
|
-
if (!isValidHostname(host)) {
|
|
560
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
561
|
-
}
|
|
562
|
-
const start = Date.now();
|
|
563
|
-
const { ok, stdout } = runArgs("ssh", [
|
|
564
|
-
"-o",
|
|
565
|
-
"ConnectTimeout=5",
|
|
566
|
-
"-o",
|
|
567
|
-
"BatchMode=yes",
|
|
568
|
-
"-o",
|
|
569
|
-
"StrictHostKeyChecking=no",
|
|
570
|
-
"-p",
|
|
571
|
-
String(port),
|
|
572
|
-
"--",
|
|
573
|
-
host,
|
|
574
|
-
"echo",
|
|
575
|
-
"SSH_OK"
|
|
576
|
-
]);
|
|
577
|
-
const elapsed = Date.now() - start;
|
|
578
|
-
if (ok && stdout.includes("SSH_OK")) {
|
|
579
|
-
return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
|
|
580
|
-
}
|
|
581
|
-
if (stdout.includes("Permission denied")) {
|
|
582
|
-
return {
|
|
583
|
-
status: "error",
|
|
584
|
-
message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
if (stdout.includes("Connection refused")) {
|
|
588
|
-
return {
|
|
589
|
-
status: "error",
|
|
590
|
-
message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
if (stdout.includes("timed out")) {
|
|
594
|
-
return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
|
|
595
|
-
}
|
|
596
|
-
if (stdout.includes("Host key verification failed")) {
|
|
597
|
-
return {
|
|
598
|
-
status: "error",
|
|
599
|
-
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
if (stdout.includes("Could not resolve")) {
|
|
603
|
-
return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
|
|
604
|
-
}
|
|
605
|
-
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// src/pool.ts
|
|
609
|
-
import { createHash } from "crypto";
|
|
610
|
-
|
|
611
|
-
// src/ssh.ts
|
|
612
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
613
|
-
import { homedir as homedir3 } from "os";
|
|
614
|
-
import { join as join3 } from "path";
|
|
615
|
-
import { Client } from "ssh2";
|
|
616
|
-
var sshConfigCache = /* @__PURE__ */ new Map();
|
|
617
|
-
function resolveFromSshConfig(host) {
|
|
618
|
-
const cached = sshConfigCache.get(host);
|
|
619
|
-
if (cached !== void 0) return cached;
|
|
620
|
-
const result = resolveFromSshConfigUncached(host);
|
|
621
|
-
sshConfigCache.set(host, result);
|
|
622
|
-
return result;
|
|
623
|
-
}
|
|
624
|
-
function resolveFromSshConfigUncached(host) {
|
|
625
|
-
try {
|
|
626
|
-
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
627
|
-
if (!ok) return null;
|
|
628
|
-
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
629
|
-
return {
|
|
630
|
-
hostname: all.hostname || host,
|
|
631
|
-
user: all.user || "",
|
|
632
|
-
port: all.port || "22",
|
|
633
|
-
identityFiles,
|
|
634
|
-
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
635
|
-
};
|
|
636
|
-
} catch {
|
|
637
|
-
return null;
|
|
638
541
|
}
|
|
639
|
-
}
|
|
640
|
-
function readKnownHostsKeys(host, port) {
|
|
641
|
-
if (!isValidHostname(host)) return [];
|
|
642
|
-
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
643
|
-
const keys = [];
|
|
644
|
-
for (const target of targets) {
|
|
645
|
-
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
646
|
-
if (!ok || !stdout.trim()) continue;
|
|
647
|
-
for (const line of stdout.split("\n")) {
|
|
648
|
-
const trimmed = line.trim();
|
|
649
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
650
|
-
const parts = trimmed.split(/\s+/);
|
|
651
|
-
if (parts.length < 3) continue;
|
|
652
|
-
try {
|
|
653
|
-
keys.push(Buffer.from(parts[2], "base64"));
|
|
654
|
-
} catch {
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
return keys;
|
|
659
|
-
}
|
|
660
|
-
function buildHostVerifier(hosts, port) {
|
|
661
|
-
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
662
|
-
return (key) => {
|
|
663
|
-
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
664
|
-
if (known.length === 0) {
|
|
665
|
-
return !strict;
|
|
666
|
-
}
|
|
667
|
-
return known.some((k) => k.equals(key));
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
function isEncryptedKey(content) {
|
|
671
|
-
const text = content.toString("utf8");
|
|
672
|
-
if (text.includes("ENCRYPTED")) return true;
|
|
673
|
-
const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
|
|
674
|
-
if (m) {
|
|
675
|
-
try {
|
|
676
|
-
const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
|
|
677
|
-
const magic = "openssh-key-v1\0";
|
|
678
|
-
if (raw.toString("latin1", 0, magic.length) === magic) {
|
|
679
|
-
const cipherLen = raw.readUInt32BE(magic.length);
|
|
680
|
-
const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
|
|
681
|
-
return cipher !== "none";
|
|
682
|
-
}
|
|
683
|
-
} catch {
|
|
684
|
-
return true;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
return false;
|
|
688
|
-
}
|
|
689
|
-
function resolveConfig(config) {
|
|
690
|
-
const sshConfig = resolveFromSshConfig(config.host);
|
|
691
|
-
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
692
|
-
const verifierHosts = [config.host];
|
|
693
|
-
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
694
|
-
verifierHosts.push(sshConfig.hostname);
|
|
695
|
-
}
|
|
696
|
-
const connectConfig = {
|
|
697
|
-
host: sshConfig?.hostname || config.host,
|
|
698
|
-
port,
|
|
699
|
-
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
700
|
-
keepaliveInterval: 15e3,
|
|
701
|
-
keepaliveCountMax: 3,
|
|
702
|
-
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
703
|
-
};
|
|
704
|
-
const home = homedir3();
|
|
705
|
-
if (config.privateKeyPath) {
|
|
706
|
-
const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
|
|
707
|
-
connectConfig.privateKey = readFileSync3(keyPath);
|
|
708
|
-
} else if (config.password) {
|
|
709
|
-
connectConfig.password = config.password;
|
|
710
|
-
} else {
|
|
711
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
712
|
-
if (agentSock) {
|
|
713
|
-
connectConfig.agent = agentSock;
|
|
714
|
-
}
|
|
715
|
-
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
|
|
716
|
-
for (const keyPath of keyPaths) {
|
|
717
|
-
try {
|
|
718
|
-
const keyData = readFileSync3(keyPath);
|
|
719
|
-
if (agentSock && isEncryptedKey(keyData)) continue;
|
|
720
|
-
connectConfig.privateKey = keyData;
|
|
721
|
-
break;
|
|
722
|
-
} catch {
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
542
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump, hostKeyRejection, applyHostKeyAlgorithms };
|
|
727
543
|
}
|
|
728
544
|
var DIAG_CACHE_TTL_MS = 2e3;
|
|
729
545
|
var diagAgentCache = null;
|
|
@@ -782,12 +598,82 @@ function connectRaw(connectConfig) {
|
|
|
782
598
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
783
599
|
});
|
|
784
600
|
}
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
601
|
+
function parsePort(text) {
|
|
602
|
+
if (!/^\d{1,5}$/.test(text)) return void 0;
|
|
603
|
+
const port = Number.parseInt(text, 10);
|
|
604
|
+
return port >= 1 && port <= 65535 ? port : void 0;
|
|
605
|
+
}
|
|
606
|
+
function parseJumpSpec(spec) {
|
|
607
|
+
const hops = [];
|
|
608
|
+
for (const piece of spec.split(",")) {
|
|
609
|
+
const hop = parseJumpHop(piece.trim());
|
|
610
|
+
if (hop) hops.push(hop);
|
|
611
|
+
}
|
|
612
|
+
return hops;
|
|
613
|
+
}
|
|
614
|
+
function parseJumpHop(piece) {
|
|
615
|
+
if (!piece) return null;
|
|
616
|
+
let rest = piece.startsWith("ssh://") ? piece.slice("ssh://".length) : piece;
|
|
617
|
+
if (!rest) return null;
|
|
618
|
+
let username;
|
|
619
|
+
const at = rest.lastIndexOf("@");
|
|
620
|
+
if (at !== -1) {
|
|
621
|
+
username = rest.slice(0, at) || void 0;
|
|
622
|
+
rest = rest.slice(at + 1);
|
|
623
|
+
}
|
|
624
|
+
let host = rest;
|
|
625
|
+
let port;
|
|
626
|
+
if (host.startsWith("[")) {
|
|
627
|
+
const close = host.indexOf("]");
|
|
628
|
+
if (close !== -1) {
|
|
629
|
+
const after = host.slice(close + 1);
|
|
630
|
+
host = host.slice(1, close);
|
|
631
|
+
if (after.startsWith(":")) port = parsePort(after.slice(1));
|
|
632
|
+
}
|
|
633
|
+
} else if (host.indexOf(":") !== -1 && host.indexOf(":") === host.lastIndexOf(":")) {
|
|
634
|
+
const colon = host.lastIndexOf(":");
|
|
635
|
+
const parsed = parsePort(host.slice(colon + 1));
|
|
636
|
+
if (parsed !== void 0) {
|
|
637
|
+
port = parsed;
|
|
638
|
+
host = host.slice(0, colon);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!host) return null;
|
|
642
|
+
const hop = { host };
|
|
643
|
+
if (port !== void 0) hop.port = port;
|
|
644
|
+
if (username !== void 0) hop.username = username;
|
|
645
|
+
return hop;
|
|
646
|
+
}
|
|
647
|
+
function formatJumpHop(hop) {
|
|
648
|
+
const host = hop.host.includes(":") ? `[${hop.host}]` : hop.host;
|
|
649
|
+
const withPort = hop.port ? `${host}:${hop.port}` : host;
|
|
650
|
+
return hop.username ? `${hop.username}@${withPort}` : withPort;
|
|
651
|
+
}
|
|
652
|
+
async function connectWithProxy(resolved) {
|
|
653
|
+
resolved.applyHostKeyAlgorithms?.();
|
|
654
|
+
const hops = resolved.proxyJump ? parseJumpSpec(resolved.proxyJump) : [];
|
|
655
|
+
if (hops.length === 0) {
|
|
656
|
+
return connectRaw(resolved.connectConfig);
|
|
657
|
+
}
|
|
658
|
+
const jumpHop = hops[hops.length - 1];
|
|
659
|
+
const jumpLabel = formatJumpHop(jumpHop);
|
|
660
|
+
const jumpResolved = resolveConfig({ host: jumpHop.host, port: jumpHop.port, username: jumpHop.username });
|
|
661
|
+
if (hops.length > 1) {
|
|
662
|
+
jumpResolved.proxyJump = hops.slice(0, -1).map(formatJumpHop).join(",");
|
|
663
|
+
}
|
|
664
|
+
let jumpClient;
|
|
665
|
+
try {
|
|
666
|
+
jumpClient = await connectWithProxy(jumpResolved);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
const jumpRejection = jumpResolved.hostKeyRejection?.current;
|
|
669
|
+
if (jumpRejection && resolved.hostKeyRejection) {
|
|
670
|
+
resolved.hostKeyRejection.current = {
|
|
671
|
+
reason: jumpRejection.reason,
|
|
672
|
+
message: `on jump host ${jumpLabel} -- ${jumpRejection.message}`
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
throw err;
|
|
788
676
|
}
|
|
789
|
-
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
790
|
-
const jumpClient = await connectWithProxy(jumpResolved);
|
|
791
677
|
const targetHost = resolved.connectConfig.host;
|
|
792
678
|
const targetPort = resolved.connectConfig.port;
|
|
793
679
|
const endJump = () => {
|
|
@@ -815,28 +701,44 @@ async function connectWithProxy(resolved) {
|
|
|
815
701
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
816
702
|
});
|
|
817
703
|
}
|
|
704
|
+
function enhanceSshError(err, host, resolved) {
|
|
705
|
+
const extra = [];
|
|
706
|
+
const rejection = resolved?.hostKeyRejection?.current;
|
|
707
|
+
if (rejection) extra.push(`Host key check failed -- ${rejection.message}`);
|
|
708
|
+
const diag = formatDiagnostics(host);
|
|
709
|
+
if (diag) extra.push(`SSH Diagnostics:
|
|
710
|
+
${diag}`);
|
|
711
|
+
if (extra.length === 0) return err;
|
|
712
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
713
|
+
const enhanced = new Error([message, ...extra].join("\n\n"));
|
|
714
|
+
enhanced.cause = err;
|
|
715
|
+
return enhanced;
|
|
716
|
+
}
|
|
818
717
|
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
819
718
|
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
820
719
|
return new Promise((resolve, reject) => {
|
|
821
720
|
let settled = false;
|
|
822
721
|
let activeStream = null;
|
|
722
|
+
let releaseCapture = null;
|
|
823
723
|
const settle = (fn) => {
|
|
824
724
|
if (settled) return;
|
|
825
725
|
settled = true;
|
|
826
726
|
clearTimeout(timer);
|
|
827
727
|
fn();
|
|
828
728
|
};
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
} catch {
|
|
838
|
-
}
|
|
729
|
+
const teardownStream = (stream) => {
|
|
730
|
+
try {
|
|
731
|
+
stream.signal("TERM");
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
stream.close();
|
|
736
|
+
} catch {
|
|
839
737
|
}
|
|
738
|
+
};
|
|
739
|
+
const timer = setTimeout(() => {
|
|
740
|
+
releaseCapture?.();
|
|
741
|
+
if (activeStream) teardownStream(activeStream);
|
|
840
742
|
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
841
743
|
}, timeoutMs);
|
|
842
744
|
client.exec(command, (err, stream) => {
|
|
@@ -898,6 +800,18 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
898
800
|
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
899
801
|
settle(() => reject(err2));
|
|
900
802
|
});
|
|
803
|
+
releaseCapture = () => {
|
|
804
|
+
stream.removeListener("data", appendStdout);
|
|
805
|
+
stream.stderr.removeListener("data", appendStderr);
|
|
806
|
+
stdoutChunks.length = 0;
|
|
807
|
+
stderrChunks.length = 0;
|
|
808
|
+
stdoutBytes = 0;
|
|
809
|
+
stderrBytes = 0;
|
|
810
|
+
};
|
|
811
|
+
if (settled) {
|
|
812
|
+
releaseCapture();
|
|
813
|
+
teardownStream(stream);
|
|
814
|
+
}
|
|
901
815
|
});
|
|
902
816
|
});
|
|
903
817
|
}
|
|
@@ -924,143 +838,475 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
924
838
|
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
925
839
|
);
|
|
926
840
|
}
|
|
927
|
-
return await new Promise((resolve, reject) => {
|
|
928
|
-
sftp.readFile(remotePath, (err, data) => {
|
|
929
|
-
if (err) return reject(err);
|
|
930
|
-
resolve(data.toString("utf8"));
|
|
931
|
-
});
|
|
932
|
-
});
|
|
933
|
-
} finally {
|
|
934
|
-
sftp.end();
|
|
841
|
+
return await new Promise((resolve, reject) => {
|
|
842
|
+
sftp.readFile(remotePath, (err, data) => {
|
|
843
|
+
if (err) return reject(err);
|
|
844
|
+
resolve(data.toString("utf8"));
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
} finally {
|
|
848
|
+
sftp.end();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async function writeFile(client, remotePath, content) {
|
|
852
|
+
const sftp = await getSftp(client);
|
|
853
|
+
try {
|
|
854
|
+
await new Promise((resolve, reject) => {
|
|
855
|
+
sftp.writeFile(remotePath, content, (err) => {
|
|
856
|
+
if (err) return reject(err);
|
|
857
|
+
resolve();
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
} finally {
|
|
861
|
+
sftp.end();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
async function uploadFile(client, localPath, remotePath) {
|
|
865
|
+
const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
|
|
866
|
+
const sftp = await getSftp(client);
|
|
867
|
+
try {
|
|
868
|
+
await new Promise((resolve, reject) => {
|
|
869
|
+
sftp.fastPut(resolvedLocal, remotePath, (err) => {
|
|
870
|
+
if (err) return reject(err);
|
|
871
|
+
resolve();
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
} finally {
|
|
875
|
+
sftp.end();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async function downloadFile(client, remotePath, localPath) {
|
|
879
|
+
const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
|
|
880
|
+
const sftp = await getSftp(client);
|
|
881
|
+
try {
|
|
882
|
+
await new Promise((resolve, reject) => {
|
|
883
|
+
sftp.fastGet(remotePath, resolvedLocal, (err) => {
|
|
884
|
+
if (err) return reject(err);
|
|
885
|
+
resolve();
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
} finally {
|
|
889
|
+
sftp.end();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function listDir(client, remotePath) {
|
|
893
|
+
const sftp = await getSftp(client);
|
|
894
|
+
try {
|
|
895
|
+
return await new Promise((resolve, reject) => {
|
|
896
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
897
|
+
if (err) return reject(err);
|
|
898
|
+
resolve(list.map((item) => item.filename));
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
} finally {
|
|
902
|
+
sftp.end();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function statFile(client, remotePath) {
|
|
906
|
+
const sftp = await getSftp(client);
|
|
907
|
+
const call = (fn) => new Promise((resolve, reject) => {
|
|
908
|
+
sftp[fn](remotePath, (err, stats) => err ? reject(err) : resolve(stats));
|
|
909
|
+
});
|
|
910
|
+
try {
|
|
911
|
+
const link = await call("lstat");
|
|
912
|
+
const isSymbolicLink = link.isSymbolicLink();
|
|
913
|
+
let meta = link;
|
|
914
|
+
if (isSymbolicLink) {
|
|
915
|
+
try {
|
|
916
|
+
meta = await call("stat");
|
|
917
|
+
} catch {
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
size: meta.size,
|
|
922
|
+
mode: meta.mode,
|
|
923
|
+
modeOctal: (meta.mode & 4095).toString(8).padStart(4, "0"),
|
|
924
|
+
uid: meta.uid,
|
|
925
|
+
gid: meta.gid,
|
|
926
|
+
mtime: meta.mtime,
|
|
927
|
+
atime: meta.atime,
|
|
928
|
+
// isFile / isDirectory describe the TARGET (they pair with the metadata above);
|
|
929
|
+
// isSymbolicLink describes the PATH. A symlink to a directory is therefore both
|
|
930
|
+
// a directory and a symlink, and the caller decides which matters.
|
|
931
|
+
isFile: meta.isFile(),
|
|
932
|
+
isDirectory: meta.isDirectory(),
|
|
933
|
+
isSymbolicLink
|
|
934
|
+
};
|
|
935
|
+
} finally {
|
|
936
|
+
sftp.end();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async function deleteFile(client, remotePath) {
|
|
940
|
+
const sftp = await getSftp(client);
|
|
941
|
+
try {
|
|
942
|
+
const stats = await new Promise((resolve, reject) => {
|
|
943
|
+
sftp.lstat(remotePath, (err, stats2) => {
|
|
944
|
+
if (err) return reject(err);
|
|
945
|
+
resolve(stats2);
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
await new Promise((resolve, reject) => {
|
|
949
|
+
const done = (err) => err ? reject(err) : resolve();
|
|
950
|
+
if (stats.isDirectory()) {
|
|
951
|
+
sftp.rmdir(remotePath, done);
|
|
952
|
+
} else {
|
|
953
|
+
sftp.unlink(remotePath, done);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
} finally {
|
|
957
|
+
sftp.end();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async function makeDir(client, remotePath, recursive = false) {
|
|
961
|
+
const sftp = await getSftp(client);
|
|
962
|
+
try {
|
|
963
|
+
const mkOne = (path) => new Promise((resolve, reject) => {
|
|
964
|
+
sftp.mkdir(path, (err) => err ? reject(err) : resolve());
|
|
965
|
+
});
|
|
966
|
+
if (!recursive) {
|
|
967
|
+
await mkOne(remotePath);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
971
|
+
let cur = remotePath.startsWith("/") ? "" : ".";
|
|
972
|
+
for (let i = 0; i < parts.length; i++) {
|
|
973
|
+
cur = `${cur}/${parts[i]}`;
|
|
974
|
+
const isLeaf = i === parts.length - 1;
|
|
975
|
+
try {
|
|
976
|
+
await mkOne(cur);
|
|
977
|
+
} catch (e) {
|
|
978
|
+
if (isLeaf) throw e;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
} finally {
|
|
982
|
+
sftp.end();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/env.ts
|
|
987
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
988
|
+
const env = {};
|
|
989
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
990
|
+
if (typeof v === "string") env[k] = v;
|
|
991
|
+
}
|
|
992
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
993
|
+
if (v === void 0) {
|
|
994
|
+
delete env[k];
|
|
995
|
+
} else {
|
|
996
|
+
env[k] = v;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
const stdout = execFileSync2(cmd, args, {
|
|
1001
|
+
env,
|
|
1002
|
+
encoding: "utf8",
|
|
1003
|
+
timeout: 1e4,
|
|
1004
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1005
|
+
});
|
|
1006
|
+
return { stdout: stdout.trim(), ok: true };
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
const err = e;
|
|
1009
|
+
const so = err.stdout?.toString().trim() || "";
|
|
1010
|
+
const se = err.stderr?.toString().trim() || "";
|
|
1011
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
1012
|
+
return { stdout: output, ok: false };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
function probeAgent(socket, agentLabel) {
|
|
1016
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
1017
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
1018
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
1019
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
1020
|
+
if (!ok && !noIdentities) return null;
|
|
1021
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
1022
|
+
return {
|
|
1023
|
+
running: true,
|
|
1024
|
+
reachable: true,
|
|
1025
|
+
socket,
|
|
1026
|
+
keys,
|
|
1027
|
+
started: false,
|
|
1028
|
+
message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
var startedAgentPid = null;
|
|
1032
|
+
function killStartedAgent() {
|
|
1033
|
+
if (startedAgentPid === null) return;
|
|
1034
|
+
try {
|
|
1035
|
+
process.kill(startedAgentPid);
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
startedAgentPid = null;
|
|
1039
|
+
}
|
|
1040
|
+
function ensureAgent() {
|
|
1041
|
+
const sock = process.env.SSH_AUTH_SOCK;
|
|
1042
|
+
if (sock) {
|
|
1043
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
1044
|
+
if (result) return result;
|
|
1045
|
+
}
|
|
1046
|
+
if (process.platform === "win32") {
|
|
1047
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
1048
|
+
if (result) return result;
|
|
1049
|
+
return {
|
|
1050
|
+
running: false,
|
|
1051
|
+
reachable: false,
|
|
1052
|
+
keys: [],
|
|
1053
|
+
started: false,
|
|
1054
|
+
message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
1058
|
+
if (ok) {
|
|
1059
|
+
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
1060
|
+
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
1061
|
+
if (sockMatch) {
|
|
1062
|
+
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
1063
|
+
if (pidMatch) {
|
|
1064
|
+
process.env.SSH_AGENT_PID = pidMatch[1];
|
|
1065
|
+
startedAgentPid = Number.parseInt(pidMatch[1], 10);
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
running: true,
|
|
1069
|
+
reachable: true,
|
|
1070
|
+
socket: sockMatch[1],
|
|
1071
|
+
keys: [],
|
|
1072
|
+
started: true,
|
|
1073
|
+
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
1074
|
+
message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
running: false,
|
|
1080
|
+
reachable: false,
|
|
1081
|
+
keys: [],
|
|
1082
|
+
started: false,
|
|
1083
|
+
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function detectKeyType(filePath, fileName) {
|
|
1087
|
+
const pubPath = `${filePath}.pub`;
|
|
1088
|
+
if (existsSync2(pubPath)) {
|
|
1089
|
+
try {
|
|
1090
|
+
const pub = readFileSync3(pubPath, "utf8");
|
|
1091
|
+
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
1092
|
+
if (pub.includes("ssh-rsa")) return "rsa";
|
|
1093
|
+
if (pub.includes("ecdsa")) return "ecdsa";
|
|
1094
|
+
if (pub.includes("ssh-dss")) return "dsa";
|
|
1095
|
+
} catch {
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (fileName.includes("ed25519")) return "ed25519";
|
|
1099
|
+
if (fileName.includes("rsa")) return "rsa";
|
|
1100
|
+
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
1101
|
+
if (fileName.includes("dsa")) return "dsa";
|
|
1102
|
+
try {
|
|
1103
|
+
const content = readFileSync3(filePath, "utf8");
|
|
1104
|
+
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
1105
|
+
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
1106
|
+
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
1107
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
1108
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
1109
|
+
if (ok) {
|
|
1110
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
1111
|
+
if (match) return match[1].toLowerCase();
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
} catch {
|
|
1115
|
+
}
|
|
1116
|
+
return "unknown";
|
|
1117
|
+
}
|
|
1118
|
+
function listSshKeysDetailed() {
|
|
1119
|
+
const sshDir = join3(homedir3(), ".ssh");
|
|
1120
|
+
if (!existsSync2(sshDir)) return { status: "no-dir", dir: sshDir, keys: [] };
|
|
1121
|
+
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
1122
|
+
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
1123
|
+
if (agentOk && !agentOut.includes("no identities")) {
|
|
1124
|
+
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
1125
|
+
const match = line.match(/(\S+:\S+)/);
|
|
1126
|
+
if (match) loadedFingerprints.add(match[1]);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const keys = [];
|
|
1130
|
+
let files;
|
|
1131
|
+
try {
|
|
1132
|
+
files = readdirSync2(sshDir);
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
return { status: "unreadable", dir: sshDir, keys: [], reason: e instanceof Error ? e.message : String(e) };
|
|
1135
|
+
}
|
|
1136
|
+
for (const file of files) {
|
|
1137
|
+
if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
|
|
1138
|
+
const filePath = join3(sshDir, file);
|
|
1139
|
+
try {
|
|
1140
|
+
const stat = statSync(filePath);
|
|
1141
|
+
if (!stat.isFile()) continue;
|
|
1142
|
+
const content = readFileSync3(filePath, "utf8");
|
|
1143
|
+
if (!content.includes("PRIVATE KEY")) continue;
|
|
1144
|
+
const type = detectKeyType(filePath, file);
|
|
1145
|
+
let fingerprint;
|
|
1146
|
+
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
1147
|
+
if (fpOk) {
|
|
1148
|
+
const match = fpOut.match(/(\S+:\S+)/);
|
|
1149
|
+
fingerprint = match?.[1];
|
|
1150
|
+
}
|
|
1151
|
+
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
1152
|
+
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
1153
|
+
} catch {
|
|
1154
|
+
}
|
|
935
1155
|
}
|
|
1156
|
+
return { status: "ok", dir: sshDir, keys };
|
|
936
1157
|
}
|
|
937
|
-
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
});
|
|
946
|
-
} finally {
|
|
947
|
-
sftp.end();
|
|
1158
|
+
function loadKey(keyPath) {
|
|
1159
|
+
const agent = ensureAgent();
|
|
1160
|
+
if (!agent.reachable) {
|
|
1161
|
+
return { status: "error", message: agent.message };
|
|
1162
|
+
}
|
|
1163
|
+
const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
|
|
1164
|
+
if (!existsSync2(resolved)) {
|
|
1165
|
+
return { status: "error", message: `Key not found: ${resolved}` };
|
|
948
1166
|
}
|
|
1167
|
+
const { stdout, ok } = runArgs("ssh-add", [resolved]);
|
|
1168
|
+
if (ok) {
|
|
1169
|
+
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
1170
|
+
}
|
|
1171
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
1172
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
1173
|
+
}
|
|
1174
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
1175
|
+
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
1176
|
+
}
|
|
1177
|
+
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
949
1178
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
resolve();
|
|
958
|
-
});
|
|
959
|
-
});
|
|
960
|
-
} finally {
|
|
961
|
-
sftp.end();
|
|
1179
|
+
function configLookup(host) {
|
|
1180
|
+
if (!isValidHostname(host)) {
|
|
1181
|
+
return { error: `Invalid hostname: "${host}"` };
|
|
1182
|
+
}
|
|
1183
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
1184
|
+
if (!ok) {
|
|
1185
|
+
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
962
1186
|
}
|
|
1187
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
1188
|
+
return {
|
|
1189
|
+
hostname: all.hostname || host,
|
|
1190
|
+
user: all.user || "",
|
|
1191
|
+
port: all.port || "22",
|
|
1192
|
+
identityFile: identityFiles,
|
|
1193
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
1194
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
1195
|
+
all,
|
|
1196
|
+
raw: stdout
|
|
1197
|
+
};
|
|
963
1198
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
if (err) return reject(err);
|
|
971
|
-
resolve();
|
|
972
|
-
});
|
|
973
|
-
});
|
|
974
|
-
} finally {
|
|
975
|
-
sftp.end();
|
|
1199
|
+
var KEYGEN_REMOVED = [/(?:^|\s)found: line \d+/m, / updated\.$/m];
|
|
1200
|
+
var KEYGEN_NO_FILE = /Cannot stat .*No such file or directory/s;
|
|
1201
|
+
function removeKnownHostEntry(target) {
|
|
1202
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-R", target]);
|
|
1203
|
+
if (!ok) {
|
|
1204
|
+
return KEYGEN_NO_FILE.test(stdout) ? { result: "absent", output: stdout } : { result: "failed", output: stdout };
|
|
976
1205
|
}
|
|
1206
|
+
if (KEYGEN_REMOVED.some((re) => re.test(stdout))) return { result: "removed", output: stdout };
|
|
1207
|
+
return { result: "absent", output: stdout };
|
|
977
1208
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
});
|
|
987
|
-
} finally {
|
|
988
|
-
sftp.end();
|
|
1209
|
+
function describeRemoval(target, removal) {
|
|
1210
|
+
switch (removal.result) {
|
|
1211
|
+
case "removed":
|
|
1212
|
+
return `Removed old host key for ${target}`;
|
|
1213
|
+
case "absent":
|
|
1214
|
+
return `No existing host key for ${target} (nothing to remove)`;
|
|
1215
|
+
default:
|
|
1216
|
+
return `Could not remove existing host key for ${target}: ${removal.output || "ssh-keygen failed"}`;
|
|
989
1217
|
}
|
|
990
1218
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
})
|
|
1011
|
-
|
|
1012
|
-
|
|
1219
|
+
function fixKnownHosts(host, port = 22) {
|
|
1220
|
+
if (!isValidHostname(host)) {
|
|
1221
|
+
return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
|
|
1222
|
+
}
|
|
1223
|
+
const actions = [];
|
|
1224
|
+
for (const target of knownHostsTargets(host, port)) {
|
|
1225
|
+
actions.push(describeRemoval(target, removeKnownHostEntry(target)));
|
|
1226
|
+
}
|
|
1227
|
+
const scanHost = unbracketHost(host);
|
|
1228
|
+
const scanArgs = port !== 22 ? ["-H", "-p", String(port), scanHost] : ["-H", scanHost];
|
|
1229
|
+
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
1230
|
+
if (scanOk && scanOut.trim()) {
|
|
1231
|
+
try {
|
|
1232
|
+
const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
|
|
1233
|
+
appendFileSync(knownHostsPath, `
|
|
1234
|
+
${scanOut.trim()}
|
|
1235
|
+
`);
|
|
1236
|
+
actions.push(`Added new host key for ${host}`);
|
|
1237
|
+
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
1238
|
+
} catch (e) {
|
|
1239
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1240
|
+
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
1241
|
+
}
|
|
1013
1242
|
}
|
|
1243
|
+
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
1014
1244
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1245
|
+
function checkGitSsh(host = "github.com", user = "git") {
|
|
1246
|
+
if (!isValidHostname(host)) {
|
|
1247
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
1248
|
+
}
|
|
1249
|
+
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
1250
|
+
const text = stdout;
|
|
1251
|
+
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
1252
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
1253
|
+
return {
|
|
1254
|
+
status: "ok",
|
|
1255
|
+
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
1256
|
+
authenticatedAs: userMatch?.[1]
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (text.includes("Permission denied")) {
|
|
1260
|
+
return {
|
|
1261
|
+
status: "error",
|
|
1262
|
+
message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
if (text.includes("Connection refused")) {
|
|
1266
|
+
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
1267
|
+
}
|
|
1268
|
+
if (text.includes("timed out") || text.includes("Connection timed out")) {
|
|
1269
|
+
return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
|
|
1270
|
+
}
|
|
1271
|
+
if (text.includes("Could not resolve")) {
|
|
1272
|
+
return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
|
|
1034
1273
|
}
|
|
1274
|
+
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
1035
1275
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1276
|
+
function testConnection(host, port = 22) {
|
|
1277
|
+
if (!isValidHostname(host)) {
|
|
1278
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
1279
|
+
}
|
|
1280
|
+
const { outcome, output, elapsedMs } = probeSshConnection(host, port);
|
|
1281
|
+
switch (outcome) {
|
|
1282
|
+
case "ok":
|
|
1283
|
+
return { status: "ok", message: `Connected to ${host}:${port} in ${elapsedMs}ms` };
|
|
1284
|
+
case "permission-denied":
|
|
1285
|
+
return {
|
|
1286
|
+
status: "error",
|
|
1287
|
+
message: `Authentication failed to ${host}:${port} (${elapsedMs}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
|
|
1288
|
+
};
|
|
1289
|
+
case "connection-refused":
|
|
1290
|
+
return {
|
|
1291
|
+
status: "error",
|
|
1292
|
+
message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
|
|
1293
|
+
};
|
|
1294
|
+
case "timed-out":
|
|
1295
|
+
return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
|
|
1296
|
+
case "host-key-mismatch":
|
|
1297
|
+
return {
|
|
1298
|
+
status: "error",
|
|
1299
|
+
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
1300
|
+
};
|
|
1301
|
+
case "dns-failure":
|
|
1302
|
+
return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
|
|
1303
|
+
default:
|
|
1304
|
+
return { status: "error", message: `Connection failed to ${host}:${port}: ${output}` };
|
|
1060
1305
|
}
|
|
1061
1306
|
}
|
|
1062
1307
|
|
|
1063
1308
|
// src/pool.ts
|
|
1309
|
+
import { createHash } from "crypto";
|
|
1064
1310
|
function defaultMaxPoolSize() {
|
|
1065
1311
|
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1066
1312
|
if (!raw) return 100;
|
|
@@ -1083,10 +1329,26 @@ function authFingerprint(cc) {
|
|
|
1083
1329
|
}
|
|
1084
1330
|
return h.digest("hex").slice(0, 16);
|
|
1085
1331
|
}
|
|
1332
|
+
function resolveOrDiagnose(config) {
|
|
1333
|
+
try {
|
|
1334
|
+
return resolveConfig(config);
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
throw enhanceSshError(err, config.host);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1086
1339
|
var ConnectionPool = class {
|
|
1087
1340
|
entries = /* @__PURE__ */ new Map();
|
|
1088
1341
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
1089
1342
|
// duplicate TCP connections when N tool calls fire simultaneously.
|
|
1343
|
+
//
|
|
1344
|
+
// The ResolvedConfig is stored alongside the promise because the coalesced dial
|
|
1345
|
+
// runs with the FIRST caller's resolved: only that one's `hostVerifier` is ever
|
|
1346
|
+
// invoked, so only that one's `hostKeyRejection` side channel records why the
|
|
1347
|
+
// server's key was turned down. Waiters must report the failure from THAT resolved
|
|
1348
|
+
// rather than their own (whose verifier never ran and whose rejection is still
|
|
1349
|
+
// null), or one caller gets "the server offered an ed25519 key but known_hosts
|
|
1350
|
+
// has only ecdsa" while the other N-1 get generic environment diagnostics for the
|
|
1351
|
+
// very same failure.
|
|
1090
1352
|
pending = /* @__PURE__ */ new Map();
|
|
1091
1353
|
idleTtlMs;
|
|
1092
1354
|
maxPoolSize;
|
|
@@ -1102,7 +1364,7 @@ var ConnectionPool = class {
|
|
|
1102
1364
|
this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
|
|
1103
1365
|
}
|
|
1104
1366
|
async acquire(config) {
|
|
1105
|
-
const resolved =
|
|
1367
|
+
const resolved = resolveOrDiagnose(config);
|
|
1106
1368
|
const cc = resolved.connectConfig;
|
|
1107
1369
|
const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
|
|
1108
1370
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
@@ -1123,8 +1385,8 @@ var ConnectionPool = class {
|
|
|
1123
1385
|
if (existing?.dead) {
|
|
1124
1386
|
this.entries.delete(key);
|
|
1125
1387
|
}
|
|
1126
|
-
let
|
|
1127
|
-
if (!
|
|
1388
|
+
let inflight = this.pending.get(key);
|
|
1389
|
+
if (!inflight) {
|
|
1128
1390
|
if (this.entries.size >= this.maxPoolSize) {
|
|
1129
1391
|
let evicted = false;
|
|
1130
1392
|
for (const [k, e] of this.entries) {
|
|
@@ -1143,7 +1405,7 @@ var ConnectionPool = class {
|
|
|
1143
1405
|
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
1144
1406
|
}
|
|
1145
1407
|
}
|
|
1146
|
-
|
|
1408
|
+
const promise = (async () => {
|
|
1147
1409
|
try {
|
|
1148
1410
|
const client2 = await connectWithProxy(resolved);
|
|
1149
1411
|
if (this.drained) {
|
|
@@ -1174,23 +1436,14 @@ var ConnectionPool = class {
|
|
|
1174
1436
|
this.pending.delete(key);
|
|
1175
1437
|
}
|
|
1176
1438
|
})();
|
|
1177
|
-
|
|
1439
|
+
inflight = { promise, resolved };
|
|
1440
|
+
this.pending.set(key, inflight);
|
|
1178
1441
|
}
|
|
1179
1442
|
let client;
|
|
1180
1443
|
try {
|
|
1181
|
-
client = await
|
|
1444
|
+
client = await inflight.promise;
|
|
1182
1445
|
} catch (err) {
|
|
1183
|
-
|
|
1184
|
-
if (diag) {
|
|
1185
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1186
|
-
const enhanced = new Error(`${message}
|
|
1187
|
-
|
|
1188
|
-
SSH Diagnostics:
|
|
1189
|
-
${diag}`);
|
|
1190
|
-
enhanced.cause = err;
|
|
1191
|
-
throw enhanced;
|
|
1192
|
-
}
|
|
1193
|
-
throw err;
|
|
1446
|
+
throw enhanceSshError(err, config.host, inflight.resolved);
|
|
1194
1447
|
}
|
|
1195
1448
|
const entry = this.entries.get(key);
|
|
1196
1449
|
if (!entry || entry.dead || entry.client !== client) {
|
|
@@ -1306,6 +1559,7 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1306
1559
|
});
|
|
1307
1560
|
}
|
|
1308
1561
|
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1562
|
+
var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
|
|
1309
1563
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1310
1564
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1311
1565
|
throw new Error(
|
|
@@ -1317,7 +1571,8 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1317
1571
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1318
1572
|
);
|
|
1319
1573
|
}
|
|
1320
|
-
const
|
|
1574
|
+
const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
|
|
1575
|
+
const args = [shellQuote(pathOperand)];
|
|
1321
1576
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1322
1577
|
if (options.type) args.push("-type", options.type);
|
|
1323
1578
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
@@ -1364,28 +1619,38 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1364
1619
|
}
|
|
1365
1620
|
|
|
1366
1621
|
// src/policy.ts
|
|
1367
|
-
function parsePatterns(raw) {
|
|
1622
|
+
function parsePatterns(raw, envVarName) {
|
|
1368
1623
|
if (!raw) return [];
|
|
1369
1624
|
const patterns = [];
|
|
1625
|
+
const malformed = [];
|
|
1370
1626
|
for (const p of raw.split(",")) {
|
|
1371
1627
|
const cleaned = p.replace(/^\s+/, "");
|
|
1372
1628
|
if (!cleaned) continue;
|
|
1373
1629
|
try {
|
|
1374
1630
|
patterns.push(new RegExp(cleaned));
|
|
1375
1631
|
} catch {
|
|
1632
|
+
malformed.push(cleaned);
|
|
1376
1633
|
console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
|
|
1377
1634
|
}
|
|
1378
1635
|
}
|
|
1636
|
+
if (patterns.length === 0 && raw.trim() !== "") {
|
|
1637
|
+
const detail = malformed.length > 0 ? `Malformed pattern(s): ${malformed.map((m) => `"${m}"`).join(", ")}.` : `The value ${JSON.stringify(raw)} contains only separators, so it declares no patterns.`;
|
|
1638
|
+
throw new Error(
|
|
1639
|
+
`Command blocked -- ssh-mcp command policy is MISCONFIGURED: ${envVarName} is set, but not one usable regex could be compiled from it, so ${envVarName} is NOT IN EFFECT. Every ssh_exec / ssh_multi_exec call is refused until it is fixed (failing closed: a policy that cannot be compiled must not be read as "no policy"). ${detail} Each comma-separated entry must be a valid JavaScript regex -- note that comma is the delimiter, so a pattern needing a literal comma must write it as \\x2c or [,]. Fix or unset the variable to restore service.`
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1379
1642
|
return patterns;
|
|
1380
1643
|
}
|
|
1381
|
-
function enforcePolicy(command) {
|
|
1382
|
-
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
|
|
1644
|
+
function enforcePolicy(command, context = {}) {
|
|
1645
|
+
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST, "SSH_MCP_COMMAND_WHITELIST");
|
|
1646
|
+
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST, "SSH_MCP_COMMAND_BLACKLIST");
|
|
1383
1647
|
if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1648
|
+
let message = `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`;
|
|
1649
|
+
if (context.envPrefixApplied) {
|
|
1650
|
+
message += ". NOTE: an `env` prefix was applied, and policy is checked against the PREFIXED command -- the string starts with the first `KEY='value'` assignment, not the command verb, so a `^`-anchored pattern that matches without `env` stops matching with it. Either set the variables inside the command string instead of passing `env`, or add a pattern that tolerates the prefix (e.g. `^([A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\\\\')+ )*ls( |$)`). Keep the trailing `( |$)`: without a tail anchor the suggestion is a PREFIX match, so `^(...)*ls` would also admit `lsof -i` and `ls; <anything>`. The value group is `(?:'[^']*'|\\\\')+`, not `'[^']*'`: an env VALUE containing an apostrophe is emitted by shellQuote in the close-escape-reopen form (`O'Brien` -> `'O'\\''Brien'`), which a single `'[^']*'` cannot match -- a suggestion built on it would block exactly the calls it claims to allow.";
|
|
1651
|
+
}
|
|
1652
|
+
throw new Error(message);
|
|
1387
1653
|
}
|
|
1388
|
-
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
|
|
1389
1654
|
for (const pattern of blacklist) {
|
|
1390
1655
|
if (pattern.test(command)) {
|
|
1391
1656
|
throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
|
|
@@ -1405,6 +1670,9 @@ var PasswordSchema = z.string().optional().describe(
|
|
|
1405
1670
|
"SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
|
|
1406
1671
|
);
|
|
1407
1672
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1673
|
+
var EnvSchema = z.record(z.string(), z.string()).optional().describe(
|
|
1674
|
+
"Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. VALUES are POSIX-single-quoted, so any byte is safe in a value. KEYS cannot be quoted (a shell assignment prefix requires a bare name), so each key must match /^[A-Za-z_][A-Za-z0-9_]*$/ (the POSIX name grammar) \u2014 a key outside that grammar is rejected and the call fails before anything is sent to a host. Command policy is checked against the PREFIXED command, so a `^`-anchored whitelist pattern stops matching once this is set."
|
|
1675
|
+
);
|
|
1408
1676
|
var connectionParams = {
|
|
1409
1677
|
host: HostSchema,
|
|
1410
1678
|
port: PortSchema,
|
|
@@ -1412,6 +1680,22 @@ var connectionParams = {
|
|
|
1412
1680
|
privateKeyPath: KeyPathSchema,
|
|
1413
1681
|
password: PasswordSchema
|
|
1414
1682
|
};
|
|
1683
|
+
var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
|
|
1684
|
+
var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1685
|
+
function applyEnvPrefix(command, env) {
|
|
1686
|
+
if (!env || Object.keys(env).length === 0) {
|
|
1687
|
+
return { finalCommand: command, envPrefixApplied: false };
|
|
1688
|
+
}
|
|
1689
|
+
const prefix = Object.entries(env).map(([k, v]) => {
|
|
1690
|
+
if (!ENV_NAME_PATTERN.test(k)) {
|
|
1691
|
+
throw new Error(
|
|
1692
|
+
`Invalid environment variable name ${JSON.stringify(k)}: env keys must match ${ENV_NAME_PATTERN.source} (the POSIX name grammar). Values are single-quoted before they reach the remote shell, but a key is emitted as a bare \`KEY=\` assignment and cannot be quoted, so anything outside that grammar is rejected instead of escaped.`
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
return `${k}=${shellQuote(v)}`;
|
|
1696
|
+
}).join(" ");
|
|
1697
|
+
return { finalCommand: `${prefix} ${command}`, envPrefixApplied: true };
|
|
1698
|
+
}
|
|
1415
1699
|
function registerTools(server, pool) {
|
|
1416
1700
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1417
1701
|
server.tool(
|
|
@@ -1420,18 +1704,12 @@ function registerTools(server, pool) {
|
|
|
1420
1704
|
{
|
|
1421
1705
|
...connectionParams,
|
|
1422
1706
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1423
|
-
env:
|
|
1424
|
-
"Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
|
|
1425
|
-
),
|
|
1707
|
+
env: EnvSchema,
|
|
1426
1708
|
timeout: TimeoutSchema
|
|
1427
1709
|
},
|
|
1428
1710
|
async ({ command, env, timeout, ...conn }) => {
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
|
|
1432
|
-
finalCommand = `${prefix} ${command}`;
|
|
1433
|
-
}
|
|
1434
|
-
enforcePolicy(finalCommand);
|
|
1711
|
+
const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
|
|
1712
|
+
enforcePolicy(finalCommand, { envPrefixApplied });
|
|
1435
1713
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1436
1714
|
const result = await exec(client, finalCommand, timeout || 3e4);
|
|
1437
1715
|
const parts = [];
|
|
@@ -1462,7 +1740,7 @@ ${result.stderr}`);
|
|
|
1462
1740
|
);
|
|
1463
1741
|
server.tool(
|
|
1464
1742
|
"ssh_write_file",
|
|
1465
|
-
|
|
1743
|
+
`Write content to a file on a remote host via SFTP. Creates or overwrites the file.${POLICY_EXEMPT_NOTE}`,
|
|
1466
1744
|
{
|
|
1467
1745
|
...connectionParams,
|
|
1468
1746
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
@@ -1471,13 +1749,14 @@ ${result.stderr}`);
|
|
|
1471
1749
|
async ({ path, content, ...conn }) => {
|
|
1472
1750
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1473
1751
|
await writeFile(client, path, content);
|
|
1474
|
-
|
|
1752
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
1753
|
+
return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
|
|
1475
1754
|
});
|
|
1476
1755
|
}
|
|
1477
1756
|
);
|
|
1478
1757
|
server.tool(
|
|
1479
1758
|
"ssh_upload",
|
|
1480
|
-
|
|
1759
|
+
`Upload a local file to a remote host via SFTP.${POLICY_EXEMPT_NOTE}`,
|
|
1481
1760
|
{
|
|
1482
1761
|
...connectionParams,
|
|
1483
1762
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
@@ -1515,13 +1794,16 @@ ${result.stderr}`);
|
|
|
1515
1794
|
async ({ path, ...conn }) => {
|
|
1516
1795
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1517
1796
|
const files = await listDir(client, path);
|
|
1797
|
+
if (files.length === 0) {
|
|
1798
|
+
return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
|
|
1799
|
+
}
|
|
1518
1800
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1519
1801
|
});
|
|
1520
1802
|
}
|
|
1521
1803
|
);
|
|
1522
1804
|
server.tool(
|
|
1523
1805
|
"ssh_stat",
|
|
1524
|
-
"Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type
|
|
1806
|
+
"Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and the path type. Symlinks are reported as `symlink -> <target kind>`: the type describes the link itself while size/mode/mtime describe its TARGET, and a dangling symlink is reported rather than erroring. Use this instead of parsing `ls -la` output.",
|
|
1525
1807
|
{
|
|
1526
1808
|
...connectionParams,
|
|
1527
1809
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
@@ -1530,7 +1812,8 @@ ${result.stderr}`);
|
|
|
1530
1812
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1531
1813
|
const stats = await statFile(client, path);
|
|
1532
1814
|
const lines = [];
|
|
1533
|
-
const
|
|
1815
|
+
const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
|
|
1816
|
+
const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
|
|
1534
1817
|
lines.push(`${path}: ${kind}`);
|
|
1535
1818
|
lines.push(` Size: ${stats.size} bytes`);
|
|
1536
1819
|
lines.push(` Mode: ${stats.modeOctal}`);
|
|
@@ -1543,10 +1826,12 @@ ${result.stderr}`);
|
|
|
1543
1826
|
);
|
|
1544
1827
|
server.tool(
|
|
1545
1828
|
"ssh_mkdir",
|
|
1546
|
-
|
|
1829
|
+
`Create a directory on a remote host via SFTP. Set \`recursive: true\` to create parent directories as needed (like \`mkdir -p\`). Existing intermediate dirs are tolerated; an existing leaf path is still an error. Unlike the other SFTP tools, the path may be relative.${POLICY_EXEMPT_NOTE}`,
|
|
1547
1830
|
{
|
|
1548
1831
|
...connectionParams,
|
|
1549
|
-
path: z.string().describe(
|
|
1832
|
+
path: z.string().describe(
|
|
1833
|
+
"Path of the directory to create. Absolute (starting with /) is recommended and unambiguous. A relative path is also accepted and resolves against the SFTP working directory, which is normally the remote user's home. ~ is NOT expanded \u2014 SFTP has no shell to expand it."
|
|
1834
|
+
),
|
|
1550
1835
|
recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
|
|
1551
1836
|
},
|
|
1552
1837
|
async ({ path, recursive, ...conn }) => {
|
|
@@ -1558,7 +1843,7 @@ ${result.stderr}`);
|
|
|
1558
1843
|
);
|
|
1559
1844
|
server.tool(
|
|
1560
1845
|
"ssh_delete",
|
|
1561
|
-
|
|
1846
|
+
`Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with \`rm -rf\` explicitly so the destructive intent is visible in the tool trace.${POLICY_EXEMPT_NOTE}`,
|
|
1562
1847
|
{
|
|
1563
1848
|
...connectionParams,
|
|
1564
1849
|
path: AbsoluteRemotePathSchema.describe(
|
|
@@ -1623,10 +1908,33 @@ ${result.stderr}`);
|
|
|
1623
1908
|
);
|
|
1624
1909
|
server.tool(
|
|
1625
1910
|
"ssh_key_list",
|
|
1626
|
-
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded.",
|
|
1911
|
+
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
|
|
1627
1912
|
{},
|
|
1628
1913
|
async () => {
|
|
1629
|
-
const
|
|
1914
|
+
const listing = listSshKeysDetailed();
|
|
1915
|
+
if (listing.status === "unreadable") {
|
|
1916
|
+
return {
|
|
1917
|
+
content: [
|
|
1918
|
+
{
|
|
1919
|
+
type: "text",
|
|
1920
|
+
text: `Could not read ${listing.dir}: ${listing.reason}. This is NOT "no keys" -- the directory exists but could not be listed. Check that it is a directory and that you own it: ls -ld ${listing.dir}, then chmod 700 ${listing.dir}.`
|
|
1921
|
+
}
|
|
1922
|
+
],
|
|
1923
|
+
isError: true
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
if (listing.status === "no-dir") {
|
|
1927
|
+
return {
|
|
1928
|
+
content: [
|
|
1929
|
+
{
|
|
1930
|
+
type: "text",
|
|
1931
|
+
text: `No ~/.ssh directory yet (${listing.dir} does not exist). Generate a key to create it: ssh-keygen -t ed25519 -C "your@email.com"`
|
|
1932
|
+
}
|
|
1933
|
+
],
|
|
1934
|
+
isError: false
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
const keys = listing.keys;
|
|
1630
1938
|
if (keys.length === 0) {
|
|
1631
1939
|
return {
|
|
1632
1940
|
content: [
|
|
@@ -1634,7 +1942,8 @@ ${result.stderr}`);
|
|
|
1634
1942
|
type: "text",
|
|
1635
1943
|
text: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
|
|
1636
1944
|
}
|
|
1637
|
-
]
|
|
1945
|
+
],
|
|
1946
|
+
isError: false
|
|
1638
1947
|
};
|
|
1639
1948
|
}
|
|
1640
1949
|
const lines = [`Found ${keys.length} SSH key(s):`, ""];
|
|
@@ -1645,7 +1954,7 @@ ${result.stderr}`);
|
|
|
1645
1954
|
if (key.fingerprint) lines.push(` Fingerprint: ${key.fingerprint}`);
|
|
1646
1955
|
lines.push("");
|
|
1647
1956
|
}
|
|
1648
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1957
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
|
|
1649
1958
|
}
|
|
1650
1959
|
);
|
|
1651
1960
|
server.tool(
|
|
@@ -1716,8 +2025,13 @@ ${result.stderr}`);
|
|
|
1716
2025
|
"ssh_git_check",
|
|
1717
2026
|
"Test Git-over-SSH authentication to a hosting provider (GitHub, GitLab, Bitbucket, etc). Verifies your SSH key is registered and working. Use this when git clone/pull/push fails with SSH errors.",
|
|
1718
2027
|
{
|
|
1719
|
-
|
|
1720
|
-
|
|
2028
|
+
// .min(1), not a bare optional string: the handler defaults with `host || "github.com"`,
|
|
2029
|
+
// so an explicitly-empty host would silently probe github.com and report on a host the
|
|
2030
|
+
// caller never named -- and, because "" never reaches checkGitSsh, it would skip the
|
|
2031
|
+
// isValidHostname check every other host-taking tool routes its input through. Omitting
|
|
2032
|
+
// the field is the way to ask for the default; "" is rejected at the schema boundary.
|
|
2033
|
+
host: z.string().min(1, "host must not be empty. Omit it to use the default (github.com).").optional().describe('Git hosting hostname (default: "github.com"). Omit for the default; an empty string is rejected.'),
|
|
2034
|
+
user: z.string().min(1, "user must not be empty. Omit it to use the default (git).").optional().describe('SSH user for the git host (default: "git"). Omit for the default; an empty string is rejected.')
|
|
1721
2035
|
},
|
|
1722
2036
|
async ({ host, user }) => {
|
|
1723
2037
|
const result = checkGitSsh(host || "github.com", user || "git");
|
|
@@ -1730,7 +2044,7 @@ ${result.stderr}`);
|
|
|
1730
2044
|
);
|
|
1731
2045
|
server.tool(
|
|
1732
2046
|
"ssh_multi_exec",
|
|
1733
|
-
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
|
|
2047
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
|
|
1734
2048
|
{
|
|
1735
2049
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1736
2050
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
@@ -1738,12 +2052,14 @@ ${result.stderr}`);
|
|
|
1738
2052
|
username: UsernameSchema,
|
|
1739
2053
|
privateKeyPath: KeyPathSchema,
|
|
1740
2054
|
password: PasswordSchema,
|
|
2055
|
+
env: EnvSchema,
|
|
1741
2056
|
timeout: TimeoutSchema
|
|
1742
2057
|
},
|
|
1743
|
-
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1744
|
-
|
|
2058
|
+
async ({ hosts, command, port, username, privateKeyPath, password, env, timeout }) => {
|
|
2059
|
+
const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
|
|
2060
|
+
enforcePolicy(finalCommand, { envPrefixApplied });
|
|
1745
2061
|
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1746
|
-
const results = await multiExec(connectionPool, hostConfigs,
|
|
2062
|
+
const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || 3e4);
|
|
1747
2063
|
const lines = [];
|
|
1748
2064
|
for (const r of results) {
|
|
1749
2065
|
lines.push(`--- ${r.host} ---`);
|
|
@@ -1752,6 +2068,7 @@ ${result.stderr}`);
|
|
|
1752
2068
|
} else {
|
|
1753
2069
|
if (r.stdout) lines.push(r.stdout);
|
|
1754
2070
|
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
2071
|
+
if (r.signal) lines.push(`[signal: ${r.signal}]`);
|
|
1755
2072
|
lines.push(`[exit code: ${r.code}]`);
|
|
1756
2073
|
}
|
|
1757
2074
|
lines.push("");
|
|
@@ -1803,7 +2120,7 @@ ${files.join("\n")}` }] };
|
|
|
1803
2120
|
content: [
|
|
1804
2121
|
{
|
|
1805
2122
|
type: "text",
|
|
1806
|
-
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty
|
|
2123
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty (no content in the last lines read; whitespace-only counts as empty here)."
|
|
1807
2124
|
}
|
|
1808
2125
|
]
|
|
1809
2126
|
};
|