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