@akms/mcp-ssh 0.0.3 → 0.1.1

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/lib/index.cjs CHANGED
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
4
+ var fs2 = require('fs');
4
5
  var os = require('os');
5
6
  var path2 = require('path');
6
- var fs2 = require('fs');
7
+ var child_process = require('child_process');
7
8
  var crypto = require('crypto');
9
+ var stream = require('stream');
8
10
  var string_decoder = require('string_decoder');
9
11
  var ssh2 = require('ssh2');
10
12
  var timers = require('timers');
@@ -12,9 +14,9 @@ var zod = require('zod');
12
14
 
13
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
14
16
 
17
+ var fs2__default = /*#__PURE__*/_interopDefault(fs2);
15
18
  var os__default = /*#__PURE__*/_interopDefault(os);
16
19
  var path2__default = /*#__PURE__*/_interopDefault(path2);
17
- var fs2__default = /*#__PURE__*/_interopDefault(fs2);
18
20
  var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
19
21
 
20
22
  // src/server.ts
@@ -32,7 +34,7 @@ var SESSION_SWEEP_INTERVAL_MS = 6e4;
32
34
  var CWD_MARKER = "__AKMS_SSH_CWD__";
33
35
  var MAX_DIRECTORY_ENTRIES = 1e3;
34
36
  var SERVER_NAME = "akms-mcp-ssh";
35
- var SERVER_VERSION = "0.0.3";
37
+ var SERVER_VERSION = "0.1.1";
36
38
 
37
39
  // src/_defs/command-rules.ts
38
40
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -454,6 +456,22 @@ var SINGLE_HOST_ENV = {
454
456
  DESCRIPTION: "SSH_DESCRIPTION",
455
457
  /** Directory new sessions start in. */
456
458
  CWD: "SSH_CWD",
459
+ /**
460
+ * Command whose stdin/stdout carry the SSH stream, like OpenSSH's `ProxyCommand`.
461
+ * `%h` / `%p` / `%r` expand to the first hop's host, port and user.
462
+ */
463
+ PROXY_COMMAND: "SSH_PROXY_COMMAND",
464
+ /** Presence of this variable routes the connection through a jump host. */
465
+ JUMP_HOST: "SSH_JUMP_HOST",
466
+ JUMP_PORT: "SSH_JUMP_PORT",
467
+ /** Defaults to `SSH_USER`. */
468
+ JUMP_USER: "SSH_JUMP_USER",
469
+ /** Path to the jump host's private key file; defaults to `SSH_KEY`. */
470
+ JUMP_KEY: "SSH_JUMP_KEY",
471
+ JUMP_PASSPHRASE: "SSH_JUMP_PASSPHRASE",
472
+ JUMP_PASSWORD: "SSH_JUMP_PASSWORD",
473
+ /** Pinned jump host key, `SHA256:…`. */
474
+ JUMP_HOST_KEY: "SSH_JUMP_HOST_KEY",
457
475
  READONLY: "SSH_READONLY",
458
476
  ALLOW_SUDO: "SSH_ALLOW_SUDO",
459
477
  /** Comma-separated binary names. */
@@ -606,8 +624,8 @@ function isRemotePathWithin(candidate, allowedPrefix) {
606
624
  }
607
625
  return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
608
626
  }
609
- function readEnv(name) {
610
- const value = process.env[name];
627
+ function readEnv(env, name) {
628
+ const value = env[name];
611
629
  if (value == null) {
612
630
  return void 0;
613
631
  }
@@ -617,8 +635,8 @@ function readEnv(name) {
617
635
  }
618
636
  return trimmed;
619
637
  }
620
- function readBooleanEnv(name) {
621
- const value = readEnv(name);
638
+ function readBooleanEnv(env, name) {
639
+ const value = readEnv(env, name);
622
640
  if (value == null) {
623
641
  return void 0;
624
642
  }
@@ -631,8 +649,8 @@ function readBooleanEnv(name) {
631
649
  }
632
650
  return false;
633
651
  }
634
- function readIntegerEnv(name) {
635
- const value = readEnv(name);
652
+ function readIntegerEnv(env, name) {
653
+ const value = readEnv(env, name);
636
654
  if (value == null) {
637
655
  return void 0;
638
656
  }
@@ -642,8 +660,8 @@ function readIntegerEnv(name) {
642
660
  }
643
661
  return parsed;
644
662
  }
645
- function readListEnv(name) {
646
- const value = readEnv(name);
663
+ function readListEnv(env, name) {
664
+ const value = readEnv(env, name);
647
665
  if (value == null) {
648
666
  return void 0;
649
667
  }
@@ -668,8 +686,8 @@ function compileDenyPatterns(sources) {
668
686
  }
669
687
  });
670
688
  }
671
- function loadHostProfile() {
672
- const host = readEnv(SINGLE_HOST_ENV.HOST);
689
+ function loadHostProfile(env = process.env) {
690
+ const host = readEnv(env, SINGLE_HOST_ENV.HOST);
673
691
  if (host == null) {
674
692
  const _startupLogger = $logger.child({ context: "loadHostProfile" });
675
693
  _startupLogger.warn("no host is registered; set the connection variables and restart", {
@@ -677,40 +695,40 @@ function loadHostProfile() {
677
695
  });
678
696
  return null;
679
697
  }
680
- const username = readEnv(SINGLE_HOST_ENV.USER);
698
+ const username = readEnv(env, SINGLE_HOST_ENV.USER);
681
699
  if (username == null) {
682
700
  throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
683
701
  }
684
- const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
702
+ const name = readEnv(env, SINGLE_HOST_ENV.NAME) ?? host;
685
703
  const _logger = $logger.child({ context: "loadHostProfile", profile: name });
686
- const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
704
+ const allowCommands = readListEnv(env, SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
687
705
  if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
688
706
  allowCommands.push("pwd");
689
707
  }
690
708
  const policy = {
691
- readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
692
- allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
693
- execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
694
- connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
695
- maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
696
- maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
709
+ readonly: readBooleanEnv(env, SINGLE_HOST_ENV.READONLY) ?? false,
710
+ allowSudo: readBooleanEnv(env, SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
711
+ execTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
712
+ connectTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
713
+ maxOutputCharacters: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
714
+ maxReadFileBytes: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
697
715
  allowCommands,
698
- denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
699
- allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
716
+ denyPatterns: compileDenyPatterns(readListEnv(env, SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
717
+ allowedPaths: readListEnv(env, SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
700
718
  };
701
719
  const profile = {
702
720
  name,
703
721
  host,
704
- port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
722
+ port: readIntegerEnv(env, SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
705
723
  username,
706
- description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
707
- passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
708
- password: readEnv(SINGLE_HOST_ENV.PASSWORD),
709
- hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
710
- defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
724
+ description: readEnv(env, SINGLE_HOST_ENV.DESCRIPTION),
725
+ passphrase: readEnv(env, SINGLE_HOST_ENV.PASSPHRASE),
726
+ password: readEnv(env, SINGLE_HOST_ENV.PASSWORD),
727
+ hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.HOST_KEY),
728
+ defaultCwd: readEnv(env, SINGLE_HOST_ENV.CWD),
711
729
  policy
712
730
  };
713
- const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
731
+ const keyPath = readEnv(env, SINGLE_HOST_ENV.KEY);
714
732
  if (keyPath != null) {
715
733
  const expandedKeyPath = expandHomePath(keyPath);
716
734
  profile.privateKeyPath = expandedKeyPath;
@@ -720,11 +738,49 @@ function loadHostProfile() {
720
738
  });
721
739
  }
722
740
  }
741
+ profile.proxyCommand = readEnv(env, SINGLE_HOST_ENV.PROXY_COMMAND);
742
+ const jumpHost = readEnv(env, SINGLE_HOST_ENV.JUMP_HOST);
743
+ if (jumpHost == null) {
744
+ const strayJumpVariables = [
745
+ SINGLE_HOST_ENV.JUMP_PORT,
746
+ SINGLE_HOST_ENV.JUMP_USER,
747
+ SINGLE_HOST_ENV.JUMP_KEY,
748
+ SINGLE_HOST_ENV.JUMP_PASSPHRASE,
749
+ SINGLE_HOST_ENV.JUMP_PASSWORD,
750
+ SINGLE_HOST_ENV.JUMP_HOST_KEY
751
+ ].filter((variable) => readEnv(env, variable) != null);
752
+ if (strayJumpVariables.length > 0) {
753
+ throw new Error(`${strayJumpVariables.join(", ")} is set, so ${SINGLE_HOST_ENV.JUMP_HOST} is required`);
754
+ }
755
+ } else {
756
+ profile.jump = {
757
+ host: jumpHost,
758
+ port: readIntegerEnv(env, SINGLE_HOST_ENV.JUMP_PORT) ?? DEFAULT_SSH_PORT,
759
+ username: readEnv(env, SINGLE_HOST_ENV.JUMP_USER) ?? username,
760
+ password: readEnv(env, SINGLE_HOST_ENV.JUMP_PASSWORD),
761
+ hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.JUMP_HOST_KEY)
762
+ };
763
+ const jumpKeyPath = readEnv(env, SINGLE_HOST_ENV.JUMP_KEY);
764
+ if (jumpKeyPath != null) {
765
+ profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
766
+ profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
767
+ if (fs2__default.default.existsSync(profile.jump.privateKeyPath) == false) {
768
+ _logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
769
+ private_key_path: profile.jump.privateKeyPath
770
+ });
771
+ }
772
+ } else {
773
+ profile.jump.privateKeyPath = profile.privateKeyPath;
774
+ profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE) ?? profile.passphrase;
775
+ }
776
+ }
723
777
  _logger.info("host profile loaded", {
724
778
  host,
725
779
  port: profile.port,
726
780
  username,
727
- readonly: policy.readonly
781
+ readonly: policy.readonly,
782
+ jump_host: profile.jump?.host,
783
+ via_proxy_command: profile.proxyCommand != null
728
784
  });
729
785
  return profile;
730
786
  }
@@ -735,18 +791,27 @@ function toHostSummary(profile) {
735
791
  } else if (profile.password != null) {
736
792
  authMethod = "password";
737
793
  }
738
- return {
794
+ const summary = {
739
795
  name: profile.name,
740
796
  host: profile.host,
741
797
  port: profile.port,
742
798
  username: profile.username,
743
799
  description: profile.description,
744
800
  authMethod,
801
+ viaProxyCommand: profile.proxyCommand != null,
745
802
  readonly: profile.policy.readonly,
746
803
  allowSudo: profile.policy.allowSudo,
747
804
  allowedPaths: profile.policy.allowedPaths,
748
805
  allowCommands: profile.policy.allowCommands
749
806
  };
807
+ if (profile.jump != null) {
808
+ summary.jump = {
809
+ host: profile.jump.host,
810
+ port: profile.jump.port,
811
+ username: profile.jump.username
812
+ };
813
+ }
814
+ return summary;
750
815
  }
751
816
 
752
817
  // src/modules/guard/command-guard.ts
@@ -1278,7 +1343,7 @@ function resolveAgentPath() {
1278
1343
  }
1279
1344
  return void 0;
1280
1345
  }
1281
- function readPrivateKey(profile, keyPath) {
1346
+ function readPrivateKey(label, keyPath) {
1282
1347
  const cached = PRIVATE_KEY_CACHE.get(keyPath);
1283
1348
  if (cached != null) {
1284
1349
  return cached;
@@ -1288,32 +1353,46 @@ function readPrivateKey(profile, keyPath) {
1288
1353
  PRIVATE_KEY_CACHE.set(keyPath, contents);
1289
1354
  return contents;
1290
1355
  } catch (ex) {
1291
- const _logger = $logger.child({ context: "readPrivateKey", profile: profile.name });
1292
- _logger.error(ex, "cannot read the profile's private key file");
1293
- throw new Error(`cannot read the private key configured for profile '${profile.name}' (see the server log for the path)`);
1356
+ const _logger = $logger.child({ context: "readPrivateKey", profile: label });
1357
+ _logger.error(ex, "cannot read the private key file");
1358
+ throw new Error(`cannot read the private key configured for '${label}' (see the server log for the path)`);
1359
+ }
1360
+ }
1361
+ function applyAuth(connectConfig, credentials, label) {
1362
+ if (credentials.privateKeyPath != null) {
1363
+ connectConfig.privateKey = readPrivateKey(label, credentials.privateKeyPath);
1364
+ }
1365
+ if (credentials.passphrase != null) {
1366
+ connectConfig.passphrase = credentials.passphrase;
1367
+ }
1368
+ if (credentials.password != null) {
1369
+ connectConfig.password = credentials.password;
1370
+ }
1371
+ if (connectConfig.privateKey == null && connectConfig.password == null) {
1372
+ connectConfig.agent = resolveAgentPath();
1294
1373
  }
1295
1374
  }
1296
1375
  function toHostKeyFingerprint(hostKey) {
1297
1376
  const digest = crypto.createHash("sha256").update(hostKey).digest("base64").replace(/=+$/, "");
1298
1377
  return `SHA256:${digest}`;
1299
1378
  }
1300
- function buildHostVerifier(profile, onHostKeySeen) {
1379
+ function buildHostVerifier(hop, onHostKeySeen) {
1301
1380
  return (hostKey) => {
1302
1381
  const fingerprint = toHostKeyFingerprint(hostKey);
1303
1382
  onHostKeySeen(fingerprint);
1304
- const _logger = $logger.child({ context: "hostVerifier", profile: profile.name, host: profile.host });
1305
- if (profile.hostKeyFingerprint == null) {
1306
- _logger.info("host key accepted unpinned; add hostKeyFingerprint to the profile to pin it", {
1383
+ const _logger = $logger.child({ context: "hostVerifier", profile: hop.label, host: hop.host });
1384
+ if (hop.pinnedFingerprint == null) {
1385
+ _logger.info("host key accepted unpinned; pin the fingerprint in the profile to detect a changed key", {
1307
1386
  host_key_fingerprint: fingerprint
1308
1387
  });
1309
1388
  return true;
1310
1389
  }
1311
- if (profile.hostKeyFingerprint === fingerprint) {
1390
+ if (hop.pinnedFingerprint === fingerprint) {
1312
1391
  _logger.debug("host key matches the pinned fingerprint");
1313
1392
  return true;
1314
1393
  }
1315
1394
  _logger.error(null, "host key does not match the pinned fingerprint", {
1316
- expected: profile.hostKeyFingerprint,
1395
+ expected: hop.pinnedFingerprint,
1317
1396
  actual: fingerprint
1318
1397
  });
1319
1398
  return false;
@@ -1326,21 +1405,152 @@ function buildConnectConfig(profile, onHostKeySeen) {
1326
1405
  username: profile.username,
1327
1406
  readyTimeout: profile.policy.connectTimeoutMs,
1328
1407
  keepaliveInterval: KEEPALIVE_INTERVAL_MS,
1329
- hostVerifier: buildHostVerifier(profile, onHostKeySeen)
1408
+ hostVerifier: buildHostVerifier(
1409
+ { label: profile.name, host: profile.host, pinnedFingerprint: profile.hostKeyFingerprint },
1410
+ onHostKeySeen
1411
+ )
1330
1412
  };
1331
- if (profile.privateKeyPath != null) {
1332
- connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
1333
- }
1334
- if (profile.passphrase != null) {
1335
- connectConfig.passphrase = profile.passphrase;
1413
+ applyAuth(connectConfig, profile, profile.name);
1414
+ return connectConfig;
1415
+ }
1416
+ function expandProxyCommand(template, hop) {
1417
+ return template.replace(/%[hpr%]/g, (token) => {
1418
+ if (token === "%h") {
1419
+ return hop.host;
1420
+ }
1421
+ if (token === "%p") {
1422
+ return String(hop.port);
1423
+ }
1424
+ if (token === "%r") {
1425
+ return hop.username;
1426
+ }
1427
+ return "%";
1428
+ });
1429
+ }
1430
+ function openProxyCommand(profile, hop) {
1431
+ const command = expandProxyCommand(profile.proxyCommand ?? "", hop);
1432
+ const _logger = $logger.child({ context: "openProxyCommand", profile: profile.name });
1433
+ _logger.debug("starting the proxy command", { command });
1434
+ const child = child_process.spawn(command, { shell: true, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1435
+ const stderrDecoder = new string_decoder.StringDecoder("utf8");
1436
+ child.stderr.on("data", (chunk) => {
1437
+ const text = stderrDecoder.write(chunk).trim();
1438
+ if (text !== "") {
1439
+ _logger.warn("proxy command stderr", { text });
1440
+ }
1441
+ });
1442
+ child.on("exit", (code, signal) => {
1443
+ _logger.debug("proxy command exited", { exit_code: code, signal });
1444
+ });
1445
+ const sock = stream.Duplex.from({ readable: child.stdout, writable: child.stdin });
1446
+ sock.on("error", (error) => {
1447
+ _logger.debug("proxy command stream error", { error_message: error.message });
1448
+ });
1449
+ child.on("error", (error) => {
1450
+ sock.destroy(error);
1451
+ });
1452
+ let closed = false;
1453
+ return {
1454
+ sock,
1455
+ close: () => {
1456
+ if (closed == true) {
1457
+ return;
1458
+ }
1459
+ closed = true;
1460
+ if (child.exitCode != null || child.signalCode != null) {
1461
+ return;
1462
+ }
1463
+ if (process.platform === "win32" && child.pid != null) {
1464
+ const killer = child_process.spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
1465
+ killer.on("error", (error) => {
1466
+ _logger.error(error, "failed to kill the proxy command's process tree");
1467
+ });
1468
+ return;
1469
+ }
1470
+ child.kill();
1471
+ }
1472
+ };
1473
+ }
1474
+ function openJumpTransport(profile, jump, proxy, onHostKeySeen) {
1475
+ const label = `${profile.name} jump host`;
1476
+ const endpoint = `${jump.username}@${jump.host}:${jump.port}`;
1477
+ const _logger = $logger.child({ context: "openJumpTransport", profile: profile.name, jump_host: jump.host });
1478
+ const jumpConfig = {
1479
+ host: jump.host,
1480
+ port: jump.port,
1481
+ username: jump.username,
1482
+ readyTimeout: profile.policy.connectTimeoutMs,
1483
+ keepaliveInterval: KEEPALIVE_INTERVAL_MS,
1484
+ hostVerifier: buildHostVerifier(
1485
+ { label, host: jump.host, pinnedFingerprint: jump.hostKeyFingerprint },
1486
+ onHostKeySeen
1487
+ )
1488
+ };
1489
+ applyAuth(jumpConfig, jump, label);
1490
+ if (proxy != null) {
1491
+ jumpConfig.sock = proxy.sock;
1492
+ }
1493
+ return new Promise((resolve, reject) => {
1494
+ const jumpClient = new Client();
1495
+ let settled = false;
1496
+ jumpClient.on("error", (error) => {
1497
+ if (settled == false) {
1498
+ settled = true;
1499
+ jumpClient.destroy();
1500
+ reject(new Error(`jump host ${endpoint}: ${error.message}`));
1501
+ return;
1502
+ }
1503
+ _logger.error(error, "jump host connection error after ready");
1504
+ });
1505
+ jumpClient.on("close", () => {
1506
+ _logger.debug("jump host connection closed");
1507
+ proxy?.close();
1508
+ if (settled == false) {
1509
+ settled = true;
1510
+ reject(new Error(`jump host ${endpoint}: connection closed before the handshake completed`));
1511
+ }
1512
+ });
1513
+ jumpClient.once("ready", () => {
1514
+ if (settled == true) {
1515
+ return;
1516
+ }
1517
+ jumpClient.forwardOut("127.0.0.1", 0, profile.host, profile.port, (error, stream) => {
1518
+ if (settled == true) {
1519
+ return;
1520
+ }
1521
+ settled = true;
1522
+ if (error != null) {
1523
+ jumpClient.end();
1524
+ reject(new Error(`jump host ${endpoint} refused the forward to ${profile.host}:${profile.port}: ${error.message}`));
1525
+ return;
1526
+ }
1527
+ _logger.info("jump host connected, target channel forwarded");
1528
+ resolve({
1529
+ sock: stream,
1530
+ close: () => {
1531
+ jumpClient.end();
1532
+ }
1533
+ });
1534
+ });
1535
+ });
1536
+ jumpClient.connect(jumpConfig);
1537
+ });
1538
+ }
1539
+ async function openTransport(profile, onJumpHostKeySeen) {
1540
+ let proxy = null;
1541
+ if (profile.proxyCommand != null) {
1542
+ proxy = openProxyCommand(profile, profile.jump ?? profile);
1336
1543
  }
1337
- if (profile.password != null) {
1338
- connectConfig.password = profile.password;
1544
+ if (profile.jump == null) {
1545
+ return proxy;
1339
1546
  }
1340
- if (connectConfig.privateKey == null && connectConfig.password == null) {
1341
- connectConfig.agent = resolveAgentPath();
1547
+ try {
1548
+ const jumpTransport = await openJumpTransport(profile, profile.jump, proxy, onJumpHostKeySeen);
1549
+ return jumpTransport;
1550
+ } catch (ex) {
1551
+ proxy?.close();
1552
+ throw ex;
1342
1553
  }
1343
- return connectConfig;
1344
1554
  }
1345
1555
  function buildRemoteCommand(command, cwd, trackCwd) {
1346
1556
  const lines = [];
@@ -1382,6 +1592,7 @@ var SshConnection = class {
1382
1592
  /** Set when the peer closed a previously established connection. */
1383
1593
  this.droppedAfterReady = false;
1384
1594
  this.observedFingerprint = null;
1595
+ this.observedJumpFingerprint = null;
1385
1596
  }
1386
1597
  get isConnected() {
1387
1598
  return this.client != null;
@@ -1390,6 +1601,10 @@ var SshConnection = class {
1390
1601
  get hostKeyFingerprint() {
1391
1602
  return this.observedFingerprint;
1392
1603
  }
1604
+ /** Host key the jump host presented; null without a jump host or before its handshake. */
1605
+ get jumpHostKeyFingerprint() {
1606
+ return this.observedJumpFingerprint;
1607
+ }
1393
1608
  /** True when a connection was established and then lost, so shell state is gone. */
1394
1609
  get wasDropped() {
1395
1610
  return this.droppedAfterReady;
@@ -1397,8 +1612,11 @@ var SshConnection = class {
1397
1612
  /**
1398
1613
  * Opens the connection, or joins the in-flight attempt when one is already running.
1399
1614
  *
1615
+ * With a proxy command or a jump host the carrier is opened first and handed to ssh2
1616
+ * as the socket; it is torn down with the target connection, whichever side closes.
1617
+ *
1400
1618
  * @throws {Error} If the connection was closed, authentication fails, the host is
1401
- * unreachable, or the handshake times out.
1619
+ * unreachable, the handshake times out, or the carrier cannot be opened.
1402
1620
  */
1403
1621
  async connect() {
1404
1622
  if (this.disposed == true) {
@@ -1421,43 +1639,66 @@ var SshConnection = class {
1421
1639
  const connectConfig = buildConnectConfig(this.profile, (fingerprint) => {
1422
1640
  this.observedFingerprint = fingerprint;
1423
1641
  });
1424
- this.connecting = new Promise((resolve, reject) => {
1425
- const client = new Client();
1426
- this.pendingClient = client;
1427
- let settled = false;
1428
- client.on("error", (error) => {
1429
- if (settled == false) {
1642
+ this.connecting = (async () => {
1643
+ const transport = await openTransport(this.profile, (fingerprint) => {
1644
+ this.observedJumpFingerprint = fingerprint;
1645
+ });
1646
+ if (this.disposed == true) {
1647
+ transport?.close();
1648
+ throw new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`);
1649
+ }
1650
+ if (transport != null) {
1651
+ connectConfig.sock = transport.sock;
1652
+ }
1653
+ await new Promise((resolve, reject) => {
1654
+ const client = new Client();
1655
+ this.pendingClient = client;
1656
+ let settled = false;
1657
+ client.on("error", (error) => {
1658
+ if (settled == false) {
1659
+ settled = true;
1660
+ this.pendingClient = null;
1661
+ client.destroy();
1662
+ reject(error);
1663
+ return;
1664
+ }
1665
+ if (this.client !== client) {
1666
+ _logger.debug("ssh connection error during teardown", { error_message: error.message });
1667
+ return;
1668
+ }
1669
+ _logger.error(error, "ssh connection error after ready");
1670
+ });
1671
+ client.on("close", () => {
1672
+ transport?.close();
1673
+ if (this.client === client) {
1674
+ _logger.debug("ssh connection closed by the peer");
1675
+ this.client = null;
1676
+ this.droppedAfterReady = true;
1677
+ return;
1678
+ }
1679
+ if (settled == false) {
1680
+ settled = true;
1681
+ this.pendingClient = null;
1682
+ reject(new Error("connection closed before the handshake completed"));
1683
+ }
1684
+ });
1685
+ client.once("ready", () => {
1686
+ if (settled == true) {
1687
+ return;
1688
+ }
1430
1689
  settled = true;
1431
1690
  this.pendingClient = null;
1432
- client.destroy();
1433
- reject(error);
1434
- return;
1435
- }
1436
- _logger.error(error, "ssh connection error after ready");
1437
- });
1438
- client.on("close", () => {
1439
- if (this.client === client) {
1440
- _logger.debug("ssh connection closed by the peer");
1441
- this.client = null;
1442
- this.droppedAfterReady = true;
1443
- }
1444
- });
1445
- client.once("ready", () => {
1446
- if (settled == true) {
1447
- return;
1448
- }
1449
- settled = true;
1450
- this.pendingClient = null;
1451
- if (this.disposed == true) {
1452
- client.destroy();
1453
- reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
1454
- return;
1455
- }
1456
- this.client = client;
1457
- resolve();
1691
+ if (this.disposed == true) {
1692
+ client.destroy();
1693
+ reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
1694
+ return;
1695
+ }
1696
+ this.client = client;
1697
+ resolve();
1698
+ });
1699
+ client.connect(connectConfig);
1458
1700
  });
1459
- client.connect(connectConfig);
1460
- });
1701
+ })();
1461
1702
  try {
1462
1703
  await this.connecting;
1463
1704
  _logger.info("ssh connection established");
@@ -2145,6 +2386,16 @@ function formatHostProfile(profile) {
2145
2386
  if (summary.description != null) {
2146
2387
  lines.push(` ${summary.description}`);
2147
2388
  }
2389
+ const routeParts = [];
2390
+ if (summary.viaProxyCommand == true) {
2391
+ routeParts.push("proxy command");
2392
+ }
2393
+ if (summary.jump != null) {
2394
+ routeParts.push(`jump host ${summary.jump.username}@${summary.jump.host}:${summary.jump.port}`);
2395
+ }
2396
+ if (routeParts.length > 0) {
2397
+ lines.push(` via ${routeParts.join(" \u2192 ")}`);
2398
+ }
2148
2399
  if (summary.allowedPaths.length > 0) {
2149
2400
  lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2150
2401
  }