@akms/mcp-ssh 0.0.2 → 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/LICENSE.md +11 -11
- package/README.md +70 -4
- package/lib/cli.cjs +2173 -248
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +2170 -245
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +340 -89
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +58 -5
- package/lib/index.d.ts +58 -5
- package/lib/index.js +339 -88
- package/lib/index.js.map +1 -1
- package/package.json +69 -61
package/lib/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import fs7 from 'fs';
|
|
4
|
+
import os4 from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawnSync, spawn } from 'child_process';
|
|
5
7
|
import { createHash } from 'crypto';
|
|
6
|
-
import
|
|
8
|
+
import { Duplex } from 'stream';
|
|
7
9
|
import { StringDecoder } from 'string_decoder';
|
|
8
10
|
import ssh2 from 'ssh2';
|
|
9
11
|
import { setInterval, clearInterval } from 'timers';
|
|
@@ -23,7 +25,8 @@ 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
|
|
28
|
+
var PACKAGE_NAME = "@akms/mcp-ssh";
|
|
29
|
+
var SERVER_VERSION = "0.1.0";
|
|
27
30
|
|
|
28
31
|
// src/_defs/command-rules.ts
|
|
29
32
|
var CRITICAL_PATH_PATTERN = new RegExp(
|
|
@@ -445,6 +448,22 @@ var SINGLE_HOST_ENV = {
|
|
|
445
448
|
DESCRIPTION: "SSH_DESCRIPTION",
|
|
446
449
|
/** Directory new sessions start in. */
|
|
447
450
|
CWD: "SSH_CWD",
|
|
451
|
+
/**
|
|
452
|
+
* Command whose stdin/stdout carry the SSH stream, like OpenSSH's `ProxyCommand`.
|
|
453
|
+
* `%h` / `%p` / `%r` expand to the first hop's host, port and user.
|
|
454
|
+
*/
|
|
455
|
+
PROXY_COMMAND: "SSH_PROXY_COMMAND",
|
|
456
|
+
/** Presence of this variable routes the connection through a jump host. */
|
|
457
|
+
JUMP_HOST: "SSH_JUMP_HOST",
|
|
458
|
+
JUMP_PORT: "SSH_JUMP_PORT",
|
|
459
|
+
/** Defaults to `SSH_USER`. */
|
|
460
|
+
JUMP_USER: "SSH_JUMP_USER",
|
|
461
|
+
/** Path to the jump host's private key file; defaults to `SSH_KEY`. */
|
|
462
|
+
JUMP_KEY: "SSH_JUMP_KEY",
|
|
463
|
+
JUMP_PASSPHRASE: "SSH_JUMP_PASSPHRASE",
|
|
464
|
+
JUMP_PASSWORD: "SSH_JUMP_PASSWORD",
|
|
465
|
+
/** Pinned jump host key, `SHA256:…`. */
|
|
466
|
+
JUMP_HOST_KEY: "SSH_JUMP_HOST_KEY",
|
|
448
467
|
READONLY: "SSH_READONLY",
|
|
449
468
|
ALLOW_SUDO: "SSH_ALLOW_SUDO",
|
|
450
469
|
/** Comma-separated binary names. */
|
|
@@ -571,20 +590,38 @@ function formatBytes(bytes) {
|
|
|
571
590
|
const megabytes = bytes / (1024 * 1024);
|
|
572
591
|
return `${megabytes.toFixed(1)} MB`;
|
|
573
592
|
}
|
|
593
|
+
function findExecutable(name) {
|
|
594
|
+
const searchPath = process.env.PATH ?? "";
|
|
595
|
+
const directories = searchPath.split(path.delimiter).filter((directory) => directory !== "");
|
|
596
|
+
let suffixes = [""];
|
|
597
|
+
if (process.platform === "win32") {
|
|
598
|
+
const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
599
|
+
suffixes = pathExt.split(";").filter((suffix) => suffix !== "");
|
|
600
|
+
}
|
|
601
|
+
for (const directory of directories) {
|
|
602
|
+
for (const suffix of suffixes) {
|
|
603
|
+
const candidate = path.join(directory, `${name}${suffix}`);
|
|
604
|
+
if (fs7.existsSync(candidate) == true && fs7.statSync(candidate).isFile() == true) {
|
|
605
|
+
return candidate;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
574
611
|
function expandHomePath(inputPath) {
|
|
575
612
|
let expanded = inputPath;
|
|
576
613
|
if (inputPath === "~") {
|
|
577
|
-
expanded =
|
|
614
|
+
expanded = os4.homedir();
|
|
578
615
|
} else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
|
|
579
|
-
expanded =
|
|
616
|
+
expanded = path.join(os4.homedir(), inputPath.slice(2));
|
|
580
617
|
}
|
|
581
|
-
return
|
|
618
|
+
return path.resolve(expanded);
|
|
582
619
|
}
|
|
583
620
|
function isAbsoluteRemotePath(remotePath) {
|
|
584
621
|
return remotePath.startsWith("/");
|
|
585
622
|
}
|
|
586
623
|
function normalizeRemotePath(remotePath) {
|
|
587
|
-
return
|
|
624
|
+
return path.posix.normalize(remotePath);
|
|
588
625
|
}
|
|
589
626
|
function isRemotePathWithin(candidate, allowedPrefix) {
|
|
590
627
|
const normalizedCandidate = normalizeRemotePath(candidate);
|
|
@@ -597,6 +634,195 @@ function isRemotePathWithin(candidate, allowedPrefix) {
|
|
|
597
634
|
}
|
|
598
635
|
return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
|
|
599
636
|
}
|
|
637
|
+
function readEnv(env, name) {
|
|
638
|
+
const value = env[name];
|
|
639
|
+
if (value == null) {
|
|
640
|
+
return void 0;
|
|
641
|
+
}
|
|
642
|
+
const trimmed = value.trim();
|
|
643
|
+
if (trimmed === "") {
|
|
644
|
+
return void 0;
|
|
645
|
+
}
|
|
646
|
+
return trimmed;
|
|
647
|
+
}
|
|
648
|
+
function readBooleanEnv(env, name) {
|
|
649
|
+
const value = readEnv(env, name);
|
|
650
|
+
if (value == null) {
|
|
651
|
+
return void 0;
|
|
652
|
+
}
|
|
653
|
+
const normalized = value.toLowerCase();
|
|
654
|
+
if (TRUTHY_ENV_VALUES.includes(normalized) == true) {
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
if (FALSY_ENV_VALUES.includes(normalized) == false) {
|
|
658
|
+
throw new Error(`${name} must be one of ${[...TRUTHY_ENV_VALUES, ...FALSY_ENV_VALUES].join(", ")} (got "${value}")`);
|
|
659
|
+
}
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
function readIntegerEnv(env, name) {
|
|
663
|
+
const value = readEnv(env, name);
|
|
664
|
+
if (value == null) {
|
|
665
|
+
return void 0;
|
|
666
|
+
}
|
|
667
|
+
const parsed = Number(value);
|
|
668
|
+
if (Number.isInteger(parsed) == false || parsed <= 0) {
|
|
669
|
+
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
670
|
+
}
|
|
671
|
+
return parsed;
|
|
672
|
+
}
|
|
673
|
+
function readListEnv(env, name) {
|
|
674
|
+
const value = readEnv(env, name);
|
|
675
|
+
if (value == null) {
|
|
676
|
+
return void 0;
|
|
677
|
+
}
|
|
678
|
+
const items = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
679
|
+
if (items.length === 0) {
|
|
680
|
+
return void 0;
|
|
681
|
+
}
|
|
682
|
+
return items;
|
|
683
|
+
}
|
|
684
|
+
function compileDenyPatterns(sources) {
|
|
685
|
+
return sources.map((source) => {
|
|
686
|
+
try {
|
|
687
|
+
return new RegExp(source, "i");
|
|
688
|
+
} catch (ex) {
|
|
689
|
+
const _logger = $logger.child({ context: "compileDenyPatterns" });
|
|
690
|
+
_logger.error(ex, "denyPatterns entry is not a valid regular expression");
|
|
691
|
+
let detail = String(ex);
|
|
692
|
+
if (ex instanceof Error) {
|
|
693
|
+
detail = ex.message;
|
|
694
|
+
}
|
|
695
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
function loadHostProfile(env = process.env) {
|
|
700
|
+
const host = readEnv(env, SINGLE_HOST_ENV.HOST);
|
|
701
|
+
if (host == null) {
|
|
702
|
+
const _startupLogger = $logger.child({ context: "loadHostProfile" });
|
|
703
|
+
_startupLogger.warn("no host is registered; set the connection variables and restart", {
|
|
704
|
+
env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
|
|
705
|
+
});
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const username = readEnv(env, SINGLE_HOST_ENV.USER);
|
|
709
|
+
if (username == null) {
|
|
710
|
+
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
711
|
+
}
|
|
712
|
+
const name = readEnv(env, SINGLE_HOST_ENV.NAME) ?? host;
|
|
713
|
+
const _logger = $logger.child({ context: "loadHostProfile", profile: name });
|
|
714
|
+
const allowCommands = readListEnv(env, SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
715
|
+
if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
|
|
716
|
+
allowCommands.push("pwd");
|
|
717
|
+
}
|
|
718
|
+
const policy = {
|
|
719
|
+
readonly: readBooleanEnv(env, SINGLE_HOST_ENV.READONLY) ?? false,
|
|
720
|
+
allowSudo: readBooleanEnv(env, SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
721
|
+
execTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
722
|
+
connectTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
723
|
+
maxOutputCharacters: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
724
|
+
maxReadFileBytes: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
725
|
+
allowCommands,
|
|
726
|
+
denyPatterns: compileDenyPatterns(readListEnv(env, SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
727
|
+
allowedPaths: readListEnv(env, SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
728
|
+
};
|
|
729
|
+
const profile = {
|
|
730
|
+
name,
|
|
731
|
+
host,
|
|
732
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
|
|
733
|
+
username,
|
|
734
|
+
description: readEnv(env, SINGLE_HOST_ENV.DESCRIPTION),
|
|
735
|
+
passphrase: readEnv(env, SINGLE_HOST_ENV.PASSPHRASE),
|
|
736
|
+
password: readEnv(env, SINGLE_HOST_ENV.PASSWORD),
|
|
737
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.HOST_KEY),
|
|
738
|
+
defaultCwd: readEnv(env, SINGLE_HOST_ENV.CWD),
|
|
739
|
+
policy
|
|
740
|
+
};
|
|
741
|
+
const keyPath = readEnv(env, SINGLE_HOST_ENV.KEY);
|
|
742
|
+
if (keyPath != null) {
|
|
743
|
+
const expandedKeyPath = expandHomePath(keyPath);
|
|
744
|
+
profile.privateKeyPath = expandedKeyPath;
|
|
745
|
+
if (fs7.existsSync(expandedKeyPath) == false) {
|
|
746
|
+
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
747
|
+
private_key_path: expandedKeyPath
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
profile.proxyCommand = readEnv(env, SINGLE_HOST_ENV.PROXY_COMMAND);
|
|
752
|
+
const jumpHost = readEnv(env, SINGLE_HOST_ENV.JUMP_HOST);
|
|
753
|
+
if (jumpHost == null) {
|
|
754
|
+
const strayJumpVariables = [
|
|
755
|
+
SINGLE_HOST_ENV.JUMP_PORT,
|
|
756
|
+
SINGLE_HOST_ENV.JUMP_USER,
|
|
757
|
+
SINGLE_HOST_ENV.JUMP_KEY,
|
|
758
|
+
SINGLE_HOST_ENV.JUMP_PASSPHRASE,
|
|
759
|
+
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
760
|
+
SINGLE_HOST_ENV.JUMP_HOST_KEY
|
|
761
|
+
].filter((variable) => readEnv(env, variable) != null);
|
|
762
|
+
if (strayJumpVariables.length > 0) {
|
|
763
|
+
throw new Error(`${strayJumpVariables.join(", ")} is set, so ${SINGLE_HOST_ENV.JUMP_HOST} is required`);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
profile.jump = {
|
|
767
|
+
host: jumpHost,
|
|
768
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.JUMP_PORT) ?? DEFAULT_SSH_PORT,
|
|
769
|
+
username: readEnv(env, SINGLE_HOST_ENV.JUMP_USER) ?? username,
|
|
770
|
+
password: readEnv(env, SINGLE_HOST_ENV.JUMP_PASSWORD),
|
|
771
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.JUMP_HOST_KEY)
|
|
772
|
+
};
|
|
773
|
+
const jumpKeyPath = readEnv(env, SINGLE_HOST_ENV.JUMP_KEY);
|
|
774
|
+
if (jumpKeyPath != null) {
|
|
775
|
+
profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
|
|
776
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
|
|
777
|
+
if (fs7.existsSync(profile.jump.privateKeyPath) == false) {
|
|
778
|
+
_logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
|
|
779
|
+
private_key_path: profile.jump.privateKeyPath
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
profile.jump.privateKeyPath = profile.privateKeyPath;
|
|
784
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE) ?? profile.passphrase;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
_logger.info("host profile loaded", {
|
|
788
|
+
host,
|
|
789
|
+
port: profile.port,
|
|
790
|
+
username,
|
|
791
|
+
readonly: policy.readonly,
|
|
792
|
+
jump_host: profile.jump?.host,
|
|
793
|
+
via_proxy_command: profile.proxyCommand != null
|
|
794
|
+
});
|
|
795
|
+
return profile;
|
|
796
|
+
}
|
|
797
|
+
function toHostSummary(profile) {
|
|
798
|
+
let authMethod = "agent";
|
|
799
|
+
if (profile.privateKeyPath != null) {
|
|
800
|
+
authMethod = "privateKey";
|
|
801
|
+
} else if (profile.password != null) {
|
|
802
|
+
authMethod = "password";
|
|
803
|
+
}
|
|
804
|
+
const summary = {
|
|
805
|
+
name: profile.name,
|
|
806
|
+
host: profile.host,
|
|
807
|
+
port: profile.port,
|
|
808
|
+
username: profile.username,
|
|
809
|
+
description: profile.description,
|
|
810
|
+
authMethod,
|
|
811
|
+
viaProxyCommand: profile.proxyCommand != null,
|
|
812
|
+
readonly: profile.policy.readonly,
|
|
813
|
+
allowSudo: profile.policy.allowSudo,
|
|
814
|
+
allowedPaths: profile.policy.allowedPaths,
|
|
815
|
+
allowCommands: profile.policy.allowCommands
|
|
816
|
+
};
|
|
817
|
+
if (profile.jump != null) {
|
|
818
|
+
summary.jump = {
|
|
819
|
+
host: profile.jump.host,
|
|
820
|
+
port: profile.jump.port,
|
|
821
|
+
username: profile.jump.username
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
return summary;
|
|
825
|
+
}
|
|
600
826
|
|
|
601
827
|
// src/modules/guard/command-guard.ts
|
|
602
828
|
var WRAPPER_BINARY_SET = new Set(COMMAND_WRAPPER_BINARIES);
|
|
@@ -1034,20 +1260,20 @@ function inspectRemotePath(remotePath, policy, access) {
|
|
|
1034
1260
|
function toRealPath(inputPath) {
|
|
1035
1261
|
const _logger = $logger.child({ context: "toRealPath" });
|
|
1036
1262
|
try {
|
|
1037
|
-
return
|
|
1263
|
+
return fs7.realpathSync.native(inputPath);
|
|
1038
1264
|
} catch (ex) {
|
|
1039
1265
|
_logger.debug("path does not resolve, falling back to its parent directory", {
|
|
1040
1266
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1041
1267
|
});
|
|
1042
1268
|
}
|
|
1043
1269
|
try {
|
|
1044
|
-
const parentRealPath =
|
|
1045
|
-
return
|
|
1270
|
+
const parentRealPath = fs7.realpathSync.native(path.dirname(inputPath));
|
|
1271
|
+
return path.join(parentRealPath, path.basename(inputPath));
|
|
1046
1272
|
} catch (ex) {
|
|
1047
1273
|
_logger.debug("parent directory does not resolve either, comparing lexically", {
|
|
1048
1274
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1049
1275
|
});
|
|
1050
|
-
return
|
|
1276
|
+
return path.resolve(inputPath);
|
|
1051
1277
|
}
|
|
1052
1278
|
}
|
|
1053
1279
|
function toComparablePath(inputPath) {
|
|
@@ -1127,42 +1353,56 @@ function resolveAgentPath() {
|
|
|
1127
1353
|
}
|
|
1128
1354
|
return void 0;
|
|
1129
1355
|
}
|
|
1130
|
-
function readPrivateKey(
|
|
1356
|
+
function readPrivateKey(label, keyPath) {
|
|
1131
1357
|
const cached = PRIVATE_KEY_CACHE.get(keyPath);
|
|
1132
1358
|
if (cached != null) {
|
|
1133
1359
|
return cached;
|
|
1134
1360
|
}
|
|
1135
1361
|
try {
|
|
1136
|
-
const contents =
|
|
1362
|
+
const contents = fs7.readFileSync(keyPath);
|
|
1137
1363
|
PRIVATE_KEY_CACHE.set(keyPath, contents);
|
|
1138
1364
|
return contents;
|
|
1139
1365
|
} catch (ex) {
|
|
1140
|
-
const _logger = $logger.child({ context: "readPrivateKey", profile:
|
|
1141
|
-
_logger.error(ex, "cannot read the
|
|
1142
|
-
throw new Error(`cannot read the private key configured for
|
|
1366
|
+
const _logger = $logger.child({ context: "readPrivateKey", profile: label });
|
|
1367
|
+
_logger.error(ex, "cannot read the private key file");
|
|
1368
|
+
throw new Error(`cannot read the private key configured for '${label}' (see the server log for the path)`);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
function applyAuth(connectConfig, credentials, label) {
|
|
1372
|
+
if (credentials.privateKeyPath != null) {
|
|
1373
|
+
connectConfig.privateKey = readPrivateKey(label, credentials.privateKeyPath);
|
|
1374
|
+
}
|
|
1375
|
+
if (credentials.passphrase != null) {
|
|
1376
|
+
connectConfig.passphrase = credentials.passphrase;
|
|
1377
|
+
}
|
|
1378
|
+
if (credentials.password != null) {
|
|
1379
|
+
connectConfig.password = credentials.password;
|
|
1380
|
+
}
|
|
1381
|
+
if (connectConfig.privateKey == null && connectConfig.password == null) {
|
|
1382
|
+
connectConfig.agent = resolveAgentPath();
|
|
1143
1383
|
}
|
|
1144
1384
|
}
|
|
1145
1385
|
function toHostKeyFingerprint(hostKey) {
|
|
1146
1386
|
const digest = createHash("sha256").update(hostKey).digest("base64").replace(/=+$/, "");
|
|
1147
1387
|
return `SHA256:${digest}`;
|
|
1148
1388
|
}
|
|
1149
|
-
function buildHostVerifier(
|
|
1389
|
+
function buildHostVerifier(hop, onHostKeySeen) {
|
|
1150
1390
|
return (hostKey) => {
|
|
1151
1391
|
const fingerprint = toHostKeyFingerprint(hostKey);
|
|
1152
1392
|
onHostKeySeen(fingerprint);
|
|
1153
|
-
const _logger = $logger.child({ context: "hostVerifier", profile:
|
|
1154
|
-
if (
|
|
1155
|
-
_logger.info("host key accepted unpinned;
|
|
1393
|
+
const _logger = $logger.child({ context: "hostVerifier", profile: hop.label, host: hop.host });
|
|
1394
|
+
if (hop.pinnedFingerprint == null) {
|
|
1395
|
+
_logger.info("host key accepted unpinned; pin the fingerprint in the profile to detect a changed key", {
|
|
1156
1396
|
host_key_fingerprint: fingerprint
|
|
1157
1397
|
});
|
|
1158
1398
|
return true;
|
|
1159
1399
|
}
|
|
1160
|
-
if (
|
|
1400
|
+
if (hop.pinnedFingerprint === fingerprint) {
|
|
1161
1401
|
_logger.debug("host key matches the pinned fingerprint");
|
|
1162
1402
|
return true;
|
|
1163
1403
|
}
|
|
1164
1404
|
_logger.error(null, "host key does not match the pinned fingerprint", {
|
|
1165
|
-
expected:
|
|
1405
|
+
expected: hop.pinnedFingerprint,
|
|
1166
1406
|
actual: fingerprint
|
|
1167
1407
|
});
|
|
1168
1408
|
return false;
|
|
@@ -1175,21 +1415,152 @@ function buildConnectConfig(profile, onHostKeySeen) {
|
|
|
1175
1415
|
username: profile.username,
|
|
1176
1416
|
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1177
1417
|
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1178
|
-
hostVerifier: buildHostVerifier(
|
|
1418
|
+
hostVerifier: buildHostVerifier(
|
|
1419
|
+
{ label: profile.name, host: profile.host, pinnedFingerprint: profile.hostKeyFingerprint },
|
|
1420
|
+
onHostKeySeen
|
|
1421
|
+
)
|
|
1179
1422
|
};
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1423
|
+
applyAuth(connectConfig, profile, profile.name);
|
|
1424
|
+
return connectConfig;
|
|
1425
|
+
}
|
|
1426
|
+
function expandProxyCommand(template, hop) {
|
|
1427
|
+
return template.replace(/%[hpr%]/g, (token) => {
|
|
1428
|
+
if (token === "%h") {
|
|
1429
|
+
return hop.host;
|
|
1430
|
+
}
|
|
1431
|
+
if (token === "%p") {
|
|
1432
|
+
return String(hop.port);
|
|
1433
|
+
}
|
|
1434
|
+
if (token === "%r") {
|
|
1435
|
+
return hop.username;
|
|
1436
|
+
}
|
|
1437
|
+
return "%";
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
function openProxyCommand(profile, hop) {
|
|
1441
|
+
const command = expandProxyCommand(profile.proxyCommand ?? "", hop);
|
|
1442
|
+
const _logger = $logger.child({ context: "openProxyCommand", profile: profile.name });
|
|
1443
|
+
_logger.debug("starting the proxy command", { command });
|
|
1444
|
+
const child = spawn(command, { shell: true, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
1445
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
1446
|
+
child.stderr.on("data", (chunk) => {
|
|
1447
|
+
const text = stderrDecoder.write(chunk).trim();
|
|
1448
|
+
if (text !== "") {
|
|
1449
|
+
_logger.warn("proxy command stderr", { text });
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
child.on("exit", (code, signal) => {
|
|
1453
|
+
_logger.debug("proxy command exited", { exit_code: code, signal });
|
|
1454
|
+
});
|
|
1455
|
+
const sock = Duplex.from({ readable: child.stdout, writable: child.stdin });
|
|
1456
|
+
sock.on("error", (error) => {
|
|
1457
|
+
_logger.debug("proxy command stream error", { error_message: error.message });
|
|
1458
|
+
});
|
|
1459
|
+
child.on("error", (error) => {
|
|
1460
|
+
sock.destroy(error);
|
|
1461
|
+
});
|
|
1462
|
+
let closed = false;
|
|
1463
|
+
return {
|
|
1464
|
+
sock,
|
|
1465
|
+
close: () => {
|
|
1466
|
+
if (closed == true) {
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
closed = true;
|
|
1470
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (process.platform === "win32" && child.pid != null) {
|
|
1474
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
1475
|
+
killer.on("error", (error) => {
|
|
1476
|
+
_logger.error(error, "failed to kill the proxy command's process tree");
|
|
1477
|
+
});
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
child.kill();
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function openJumpTransport(profile, jump, proxy, onHostKeySeen) {
|
|
1485
|
+
const label = `${profile.name} jump host`;
|
|
1486
|
+
const endpoint = `${jump.username}@${jump.host}:${jump.port}`;
|
|
1487
|
+
const _logger = $logger.child({ context: "openJumpTransport", profile: profile.name, jump_host: jump.host });
|
|
1488
|
+
const jumpConfig = {
|
|
1489
|
+
host: jump.host,
|
|
1490
|
+
port: jump.port,
|
|
1491
|
+
username: jump.username,
|
|
1492
|
+
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1493
|
+
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1494
|
+
hostVerifier: buildHostVerifier(
|
|
1495
|
+
{ label, host: jump.host, pinnedFingerprint: jump.hostKeyFingerprint },
|
|
1496
|
+
onHostKeySeen
|
|
1497
|
+
)
|
|
1498
|
+
};
|
|
1499
|
+
applyAuth(jumpConfig, jump, label);
|
|
1500
|
+
if (proxy != null) {
|
|
1501
|
+
jumpConfig.sock = proxy.sock;
|
|
1502
|
+
}
|
|
1503
|
+
return new Promise((resolve, reject) => {
|
|
1504
|
+
const jumpClient = new Client();
|
|
1505
|
+
let settled = false;
|
|
1506
|
+
jumpClient.on("error", (error) => {
|
|
1507
|
+
if (settled == false) {
|
|
1508
|
+
settled = true;
|
|
1509
|
+
jumpClient.destroy();
|
|
1510
|
+
reject(new Error(`jump host ${endpoint}: ${error.message}`));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
_logger.error(error, "jump host connection error after ready");
|
|
1514
|
+
});
|
|
1515
|
+
jumpClient.on("close", () => {
|
|
1516
|
+
_logger.debug("jump host connection closed");
|
|
1517
|
+
proxy?.close();
|
|
1518
|
+
if (settled == false) {
|
|
1519
|
+
settled = true;
|
|
1520
|
+
reject(new Error(`jump host ${endpoint}: connection closed before the handshake completed`));
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
jumpClient.once("ready", () => {
|
|
1524
|
+
if (settled == true) {
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
jumpClient.forwardOut("127.0.0.1", 0, profile.host, profile.port, (error, stream) => {
|
|
1528
|
+
if (settled == true) {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
settled = true;
|
|
1532
|
+
if (error != null) {
|
|
1533
|
+
jumpClient.end();
|
|
1534
|
+
reject(new Error(`jump host ${endpoint} refused the forward to ${profile.host}:${profile.port}: ${error.message}`));
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
_logger.info("jump host connected, target channel forwarded");
|
|
1538
|
+
resolve({
|
|
1539
|
+
sock: stream,
|
|
1540
|
+
close: () => {
|
|
1541
|
+
jumpClient.end();
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
jumpClient.connect(jumpConfig);
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
async function openTransport(profile, onJumpHostKeySeen) {
|
|
1550
|
+
let proxy = null;
|
|
1551
|
+
if (profile.proxyCommand != null) {
|
|
1552
|
+
proxy = openProxyCommand(profile, profile.jump ?? profile);
|
|
1185
1553
|
}
|
|
1186
|
-
if (profile.
|
|
1187
|
-
|
|
1554
|
+
if (profile.jump == null) {
|
|
1555
|
+
return proxy;
|
|
1188
1556
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1557
|
+
try {
|
|
1558
|
+
const jumpTransport = await openJumpTransport(profile, profile.jump, proxy, onJumpHostKeySeen);
|
|
1559
|
+
return jumpTransport;
|
|
1560
|
+
} catch (ex) {
|
|
1561
|
+
proxy?.close();
|
|
1562
|
+
throw ex;
|
|
1191
1563
|
}
|
|
1192
|
-
return connectConfig;
|
|
1193
1564
|
}
|
|
1194
1565
|
function buildRemoteCommand(command, cwd, trackCwd) {
|
|
1195
1566
|
const lines = [];
|
|
@@ -1231,6 +1602,7 @@ var SshConnection = class {
|
|
|
1231
1602
|
/** Set when the peer closed a previously established connection. */
|
|
1232
1603
|
this.droppedAfterReady = false;
|
|
1233
1604
|
this.observedFingerprint = null;
|
|
1605
|
+
this.observedJumpFingerprint = null;
|
|
1234
1606
|
}
|
|
1235
1607
|
get isConnected() {
|
|
1236
1608
|
return this.client != null;
|
|
@@ -1239,6 +1611,10 @@ var SshConnection = class {
|
|
|
1239
1611
|
get hostKeyFingerprint() {
|
|
1240
1612
|
return this.observedFingerprint;
|
|
1241
1613
|
}
|
|
1614
|
+
/** Host key the jump host presented; null without a jump host or before its handshake. */
|
|
1615
|
+
get jumpHostKeyFingerprint() {
|
|
1616
|
+
return this.observedJumpFingerprint;
|
|
1617
|
+
}
|
|
1242
1618
|
/** True when a connection was established and then lost, so shell state is gone. */
|
|
1243
1619
|
get wasDropped() {
|
|
1244
1620
|
return this.droppedAfterReady;
|
|
@@ -1246,8 +1622,11 @@ var SshConnection = class {
|
|
|
1246
1622
|
/**
|
|
1247
1623
|
* Opens the connection, or joins the in-flight attempt when one is already running.
|
|
1248
1624
|
*
|
|
1625
|
+
* With a proxy command or a jump host the carrier is opened first and handed to ssh2
|
|
1626
|
+
* as the socket; it is torn down with the target connection, whichever side closes.
|
|
1627
|
+
*
|
|
1249
1628
|
* @throws {Error} If the connection was closed, authentication fails, the host is
|
|
1250
|
-
* unreachable,
|
|
1629
|
+
* unreachable, the handshake times out, or the carrier cannot be opened.
|
|
1251
1630
|
*/
|
|
1252
1631
|
async connect() {
|
|
1253
1632
|
if (this.disposed == true) {
|
|
@@ -1270,43 +1649,66 @@ var SshConnection = class {
|
|
|
1270
1649
|
const connectConfig = buildConnectConfig(this.profile, (fingerprint) => {
|
|
1271
1650
|
this.observedFingerprint = fingerprint;
|
|
1272
1651
|
});
|
|
1273
|
-
this.connecting =
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1652
|
+
this.connecting = (async () => {
|
|
1653
|
+
const transport = await openTransport(this.profile, (fingerprint) => {
|
|
1654
|
+
this.observedJumpFingerprint = fingerprint;
|
|
1655
|
+
});
|
|
1656
|
+
if (this.disposed == true) {
|
|
1657
|
+
transport?.close();
|
|
1658
|
+
throw new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`);
|
|
1659
|
+
}
|
|
1660
|
+
if (transport != null) {
|
|
1661
|
+
connectConfig.sock = transport.sock;
|
|
1662
|
+
}
|
|
1663
|
+
await new Promise((resolve, reject) => {
|
|
1664
|
+
const client = new Client();
|
|
1665
|
+
this.pendingClient = client;
|
|
1666
|
+
let settled = false;
|
|
1667
|
+
client.on("error", (error) => {
|
|
1668
|
+
if (settled == false) {
|
|
1669
|
+
settled = true;
|
|
1670
|
+
this.pendingClient = null;
|
|
1671
|
+
client.destroy();
|
|
1672
|
+
reject(error);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
if (this.client !== client) {
|
|
1676
|
+
_logger.debug("ssh connection error during teardown", { error_message: error.message });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
_logger.error(error, "ssh connection error after ready");
|
|
1680
|
+
});
|
|
1681
|
+
client.on("close", () => {
|
|
1682
|
+
transport?.close();
|
|
1683
|
+
if (this.client === client) {
|
|
1684
|
+
_logger.debug("ssh connection closed by the peer");
|
|
1685
|
+
this.client = null;
|
|
1686
|
+
this.droppedAfterReady = true;
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (settled == false) {
|
|
1690
|
+
settled = true;
|
|
1691
|
+
this.pendingClient = null;
|
|
1692
|
+
reject(new Error("connection closed before the handshake completed"));
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
client.once("ready", () => {
|
|
1696
|
+
if (settled == true) {
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1279
1699
|
settled = true;
|
|
1280
1700
|
this.pendingClient = null;
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
this.client = null;
|
|
1291
|
-
this.droppedAfterReady = true;
|
|
1292
|
-
}
|
|
1293
|
-
});
|
|
1294
|
-
client.once("ready", () => {
|
|
1295
|
-
if (settled == true) {
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
settled = true;
|
|
1299
|
-
this.pendingClient = null;
|
|
1300
|
-
if (this.disposed == true) {
|
|
1301
|
-
client.destroy();
|
|
1302
|
-
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
this.client = client;
|
|
1306
|
-
resolve();
|
|
1701
|
+
if (this.disposed == true) {
|
|
1702
|
+
client.destroy();
|
|
1703
|
+
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
this.client = client;
|
|
1707
|
+
resolve();
|
|
1708
|
+
});
|
|
1709
|
+
client.connect(connectConfig);
|
|
1307
1710
|
});
|
|
1308
|
-
|
|
1309
|
-
});
|
|
1711
|
+
})();
|
|
1310
1712
|
try {
|
|
1311
1713
|
await this.connecting;
|
|
1312
1714
|
_logger.info("ssh connection established");
|
|
@@ -1908,7 +2310,7 @@ async function writeRemoteFile(connection, args) {
|
|
|
1908
2310
|
}
|
|
1909
2311
|
async function uploadFile(connection, localPath, remotePath) {
|
|
1910
2312
|
requirePermittedPath(connection, remotePath, "write");
|
|
1911
|
-
const localStats =
|
|
2313
|
+
const localStats = fs7.statSync(localPath);
|
|
1912
2314
|
if (localStats.isFile() == false) {
|
|
1913
2315
|
throw new Error(`local path is not a file: ${localPath}`);
|
|
1914
2316
|
}
|
|
@@ -1945,7 +2347,7 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
1945
2347
|
});
|
|
1946
2348
|
});
|
|
1947
2349
|
});
|
|
1948
|
-
const localStats =
|
|
2350
|
+
const localStats = fs7.statSync(localPath);
|
|
1949
2351
|
const _logger = $logger.child({
|
|
1950
2352
|
context: "downloadFile",
|
|
1951
2353
|
profile: connection.profile.name,
|
|
@@ -1955,147 +2357,1667 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
1955
2357
|
_logger.info("file downloaded", { bytes: localStats.size });
|
|
1956
2358
|
return localStats.size;
|
|
1957
2359
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2360
|
+
|
|
2361
|
+
// src/modules/manage/connection-check.ts
|
|
2362
|
+
function describeAuth(profile) {
|
|
2363
|
+
if (profile.privateKeyPath != null) {
|
|
2364
|
+
return `key ${profile.privateKeyPath}`;
|
|
1962
2365
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
return void 0;
|
|
2366
|
+
if (profile.password != null) {
|
|
2367
|
+
return "password";
|
|
1966
2368
|
}
|
|
1967
|
-
return
|
|
2369
|
+
return "ssh agent";
|
|
1968
2370
|
}
|
|
1969
|
-
function
|
|
1970
|
-
const
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2371
|
+
async function runConnectionCheck(profile, output) {
|
|
2372
|
+
const endpoint = `${profile.username}@${profile.host}:${profile.port}`;
|
|
2373
|
+
const auth = describeAuth(profile);
|
|
2374
|
+
output.write(`Checking ${profile.name} \u2192 ${endpoint}
|
|
2375
|
+
`);
|
|
2376
|
+
if (profile.proxyCommand != null) {
|
|
2377
|
+
output.write(` via proxy command: ${profile.proxyCommand}
|
|
2378
|
+
`);
|
|
1977
2379
|
}
|
|
1978
|
-
if (
|
|
1979
|
-
|
|
2380
|
+
if (profile.jump != null) {
|
|
2381
|
+
output.write(` via jump host: ${profile.jump.username}@${profile.jump.host}:${profile.jump.port}
|
|
2382
|
+
`);
|
|
1980
2383
|
}
|
|
1981
|
-
|
|
2384
|
+
output.write("\n");
|
|
2385
|
+
const connection = new SshConnection(profile);
|
|
2386
|
+
const startedAt = Date.now();
|
|
2387
|
+
let connected = false;
|
|
2388
|
+
let detail = "";
|
|
2389
|
+
try {
|
|
2390
|
+
await connection.connect();
|
|
2391
|
+
connected = true;
|
|
2392
|
+
} catch (ex) {
|
|
2393
|
+
detail = String(ex);
|
|
2394
|
+
if (ex instanceof Error) {
|
|
2395
|
+
detail = ex.message;
|
|
2396
|
+
}
|
|
2397
|
+
} finally {
|
|
2398
|
+
connection.disconnect();
|
|
2399
|
+
}
|
|
2400
|
+
const durationMs = Date.now() - startedAt;
|
|
2401
|
+
if (connected == true) {
|
|
2402
|
+
output.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
|
|
2403
|
+
`);
|
|
2404
|
+
} else {
|
|
2405
|
+
output.write(` FAILED ${endpoint} (${auth})
|
|
2406
|
+
`);
|
|
2407
|
+
output.write(` ${detail}
|
|
2408
|
+
`);
|
|
2409
|
+
}
|
|
2410
|
+
if (connection.hostKeyFingerprint != null) {
|
|
2411
|
+
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
|
|
2412
|
+
if (profile.hostKeyFingerprint != null) {
|
|
2413
|
+
pinNote = "pinned";
|
|
2414
|
+
}
|
|
2415
|
+
output.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
|
|
2416
|
+
`);
|
|
2417
|
+
}
|
|
2418
|
+
if (connection.jumpHostKeyFingerprint != null) {
|
|
2419
|
+
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.JUMP_HOST_KEY} to it to detect a changed key`;
|
|
2420
|
+
if (profile.jump?.hostKeyFingerprint != null) {
|
|
2421
|
+
pinNote = "pinned";
|
|
2422
|
+
}
|
|
2423
|
+
output.write(` jump host key ${connection.jumpHostKeyFingerprint} (${pinNote})
|
|
2424
|
+
`);
|
|
2425
|
+
}
|
|
2426
|
+
return {
|
|
2427
|
+
connected,
|
|
2428
|
+
hostKeyFingerprint: connection.hostKeyFingerprint,
|
|
2429
|
+
jumpHostKeyFingerprint: connection.jumpHostKeyFingerprint
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// src/modules/manage/entry.ts
|
|
2434
|
+
var MANAGED_ENV_NAMES = [
|
|
2435
|
+
SINGLE_HOST_ENV.HOST,
|
|
2436
|
+
SINGLE_HOST_ENV.PORT,
|
|
2437
|
+
SINGLE_HOST_ENV.USER,
|
|
2438
|
+
SINGLE_HOST_ENV.KEY,
|
|
2439
|
+
SINGLE_HOST_ENV.PASSPHRASE,
|
|
2440
|
+
SINGLE_HOST_ENV.PASSWORD,
|
|
2441
|
+
SINGLE_HOST_ENV.DESCRIPTION,
|
|
2442
|
+
SINGLE_HOST_ENV.JUMP_HOST,
|
|
2443
|
+
SINGLE_HOST_ENV.JUMP_PORT,
|
|
2444
|
+
SINGLE_HOST_ENV.JUMP_USER,
|
|
2445
|
+
SINGLE_HOST_ENV.PROXY_COMMAND,
|
|
2446
|
+
SINGLE_HOST_ENV.READONLY,
|
|
2447
|
+
SINGLE_HOST_ENV.ALLOWED_PATHS,
|
|
2448
|
+
SINGLE_HOST_ENV.HOST_KEY,
|
|
2449
|
+
SINGLE_HOST_ENV.JUMP_HOST_KEY
|
|
2450
|
+
];
|
|
2451
|
+
var JUMP_ENV_PREFIX = "SSH_JUMP_";
|
|
2452
|
+
var SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,48}$/;
|
|
2453
|
+
function isOurEntry(entry) {
|
|
2454
|
+
if (entry.args.includes(PACKAGE_NAME) == true) {
|
|
2455
|
+
return true;
|
|
2456
|
+
}
|
|
2457
|
+
const commandBase = entry.command.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
2458
|
+
return commandBase === SERVER_NAME || commandBase.startsWith(`${SERVER_NAME}.`) == true;
|
|
2459
|
+
}
|
|
2460
|
+
function launchCommand() {
|
|
2461
|
+
if (process.platform === "win32") {
|
|
2462
|
+
return { command: "cmd", args: ["/c", "npx", "-y", PACKAGE_NAME] };
|
|
2463
|
+
}
|
|
2464
|
+
return { command: "npx", args: ["-y", PACKAGE_NAME] };
|
|
2465
|
+
}
|
|
2466
|
+
function answersToEntry(answers) {
|
|
2467
|
+
const env = {};
|
|
2468
|
+
env[SINGLE_HOST_ENV.HOST] = answers.host;
|
|
2469
|
+
if (answers.port !== DEFAULT_SSH_PORT) {
|
|
2470
|
+
env[SINGLE_HOST_ENV.PORT] = String(answers.port);
|
|
2471
|
+
}
|
|
2472
|
+
env[SINGLE_HOST_ENV.USER] = answers.username;
|
|
2473
|
+
if (answers.auth === "key" && answers.keyPath != null) {
|
|
2474
|
+
env[SINGLE_HOST_ENV.KEY] = answers.keyPath;
|
|
2475
|
+
if (answers.passphrase != null && answers.passphrase !== "") {
|
|
2476
|
+
env[SINGLE_HOST_ENV.PASSPHRASE] = answers.passphrase;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (answers.auth === "password" && answers.password != null) {
|
|
2480
|
+
env[SINGLE_HOST_ENV.PASSWORD] = answers.password;
|
|
2481
|
+
}
|
|
2482
|
+
if (answers.hostKeyFingerprint != null) {
|
|
2483
|
+
env[SINGLE_HOST_ENV.HOST_KEY] = answers.hostKeyFingerprint;
|
|
2484
|
+
}
|
|
2485
|
+
if (answers.description != null && answers.description !== "") {
|
|
2486
|
+
env[SINGLE_HOST_ENV.DESCRIPTION] = answers.description;
|
|
2487
|
+
}
|
|
2488
|
+
if (answers.jumpHost != null) {
|
|
2489
|
+
env[SINGLE_HOST_ENV.JUMP_HOST] = answers.jumpHost;
|
|
2490
|
+
if (answers.jumpPort != null && answers.jumpPort !== DEFAULT_SSH_PORT) {
|
|
2491
|
+
env[SINGLE_HOST_ENV.JUMP_PORT] = String(answers.jumpPort);
|
|
2492
|
+
}
|
|
2493
|
+
if (answers.jumpUser != null && answers.jumpUser !== answers.username) {
|
|
2494
|
+
env[SINGLE_HOST_ENV.JUMP_USER] = answers.jumpUser;
|
|
2495
|
+
}
|
|
2496
|
+
if (answers.jumpHostKeyFingerprint != null) {
|
|
2497
|
+
env[SINGLE_HOST_ENV.JUMP_HOST_KEY] = answers.jumpHostKeyFingerprint;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
if (answers.proxyCommand != null && answers.proxyCommand !== "") {
|
|
2501
|
+
env[SINGLE_HOST_ENV.PROXY_COMMAND] = answers.proxyCommand;
|
|
2502
|
+
}
|
|
2503
|
+
if (answers.readonly == true) {
|
|
2504
|
+
env[SINGLE_HOST_ENV.READONLY] = "true";
|
|
2505
|
+
}
|
|
2506
|
+
if (answers.allowedPaths.length > 0) {
|
|
2507
|
+
env[SINGLE_HOST_ENV.ALLOWED_PATHS] = answers.allowedPaths.join(",");
|
|
2508
|
+
}
|
|
2509
|
+
for (const [name, value] of Object.entries(answers.extraEnv)) {
|
|
2510
|
+
if (MANAGED_ENV_NAMES.includes(name) == true) {
|
|
2511
|
+
continue;
|
|
2512
|
+
}
|
|
2513
|
+
if (answers.jumpHost == null && name.startsWith(JUMP_ENV_PREFIX) == true) {
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2516
|
+
env[name] = value;
|
|
2517
|
+
}
|
|
2518
|
+
const launch = launchCommand();
|
|
2519
|
+
return {
|
|
2520
|
+
command: launch.command,
|
|
2521
|
+
args: launch.args,
|
|
2522
|
+
env
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
function readPort(value) {
|
|
2526
|
+
if (value == null) {
|
|
2527
|
+
return DEFAULT_SSH_PORT;
|
|
2528
|
+
}
|
|
2529
|
+
const parsed = Number(value);
|
|
2530
|
+
if (Number.isInteger(parsed) == false || parsed <= 0) {
|
|
2531
|
+
return DEFAULT_SSH_PORT;
|
|
2532
|
+
}
|
|
2533
|
+
return parsed;
|
|
2534
|
+
}
|
|
2535
|
+
function isTruthy(value) {
|
|
2536
|
+
if (value == null) {
|
|
2537
|
+
return false;
|
|
2538
|
+
}
|
|
2539
|
+
return TRUTHY_ENV_VALUES.includes(value.trim().toLowerCase());
|
|
2540
|
+
}
|
|
2541
|
+
function entryToAnswers(name, entry) {
|
|
2542
|
+
const env = entry.env;
|
|
2543
|
+
const host = env[SINGLE_HOST_ENV.HOST] ?? "";
|
|
2544
|
+
const username = env[SINGLE_HOST_ENV.USER] ?? "";
|
|
2545
|
+
let auth = "agent";
|
|
2546
|
+
if (env[SINGLE_HOST_ENV.KEY] != null) {
|
|
2547
|
+
auth = "key";
|
|
2548
|
+
} else if (env[SINGLE_HOST_ENV.PASSWORD] != null) {
|
|
2549
|
+
auth = "password";
|
|
2550
|
+
}
|
|
2551
|
+
let allowedPaths = [];
|
|
2552
|
+
const allowedPathsValue = env[SINGLE_HOST_ENV.ALLOWED_PATHS];
|
|
2553
|
+
if (allowedPathsValue != null) {
|
|
2554
|
+
allowedPaths = allowedPathsValue.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
2555
|
+
}
|
|
2556
|
+
const extraEnv = {};
|
|
2557
|
+
for (const [variable, value] of Object.entries(env)) {
|
|
2558
|
+
if (MANAGED_ENV_NAMES.includes(variable) == false) {
|
|
2559
|
+
extraEnv[variable] = value;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
const answers = {
|
|
2563
|
+
name,
|
|
2564
|
+
host,
|
|
2565
|
+
port: readPort(env[SINGLE_HOST_ENV.PORT]),
|
|
2566
|
+
username,
|
|
2567
|
+
auth,
|
|
2568
|
+
keyPath: env[SINGLE_HOST_ENV.KEY],
|
|
2569
|
+
passphrase: env[SINGLE_HOST_ENV.PASSPHRASE],
|
|
2570
|
+
password: env[SINGLE_HOST_ENV.PASSWORD],
|
|
2571
|
+
description: env[SINGLE_HOST_ENV.DESCRIPTION],
|
|
2572
|
+
proxyCommand: env[SINGLE_HOST_ENV.PROXY_COMMAND],
|
|
2573
|
+
readonly: isTruthy(env[SINGLE_HOST_ENV.READONLY]),
|
|
2574
|
+
allowedPaths,
|
|
2575
|
+
hostKeyFingerprint: env[SINGLE_HOST_ENV.HOST_KEY],
|
|
2576
|
+
extraEnv
|
|
2577
|
+
};
|
|
2578
|
+
const jumpHost = env[SINGLE_HOST_ENV.JUMP_HOST];
|
|
2579
|
+
if (jumpHost != null) {
|
|
2580
|
+
answers.jumpHost = jumpHost;
|
|
2581
|
+
answers.jumpPort = readPort(env[SINGLE_HOST_ENV.JUMP_PORT]);
|
|
2582
|
+
answers.jumpUser = env[SINGLE_HOST_ENV.JUMP_USER] ?? username;
|
|
2583
|
+
answers.jumpHostKeyFingerprint = env[SINGLE_HOST_ENV.JUMP_HOST_KEY];
|
|
2584
|
+
}
|
|
2585
|
+
return answers;
|
|
2586
|
+
}
|
|
2587
|
+
function summarizeEntry(entry) {
|
|
2588
|
+
const env = entry.env;
|
|
2589
|
+
const host = env[SINGLE_HOST_ENV.HOST] ?? "?";
|
|
2590
|
+
const port = env[SINGLE_HOST_ENV.PORT] ?? String(DEFAULT_SSH_PORT);
|
|
2591
|
+
const username = env[SINGLE_HOST_ENV.USER] ?? "?";
|
|
2592
|
+
let auth = "agent";
|
|
2593
|
+
if (env[SINGLE_HOST_ENV.KEY] != null) {
|
|
2594
|
+
auth = "key";
|
|
2595
|
+
} else if (env[SINGLE_HOST_ENV.PASSWORD] != null) {
|
|
2596
|
+
auth = "password";
|
|
2597
|
+
}
|
|
2598
|
+
const notes = [];
|
|
2599
|
+
if (env[SINGLE_HOST_ENV.JUMP_HOST] != null) {
|
|
2600
|
+
notes.push(`jump ${env[SINGLE_HOST_ENV.JUMP_HOST]}`);
|
|
2601
|
+
}
|
|
2602
|
+
if (env[SINGLE_HOST_ENV.PROXY_COMMAND] != null) {
|
|
2603
|
+
notes.push("proxy");
|
|
2604
|
+
}
|
|
2605
|
+
if (isTruthy(env[SINGLE_HOST_ENV.READONLY]) == true) {
|
|
2606
|
+
notes.push("readonly");
|
|
2607
|
+
}
|
|
2608
|
+
if (env[SINGLE_HOST_ENV.ALLOWED_PATHS] != null) {
|
|
2609
|
+
notes.push(`paths ${env[SINGLE_HOST_ENV.ALLOWED_PATHS]}`);
|
|
2610
|
+
}
|
|
2611
|
+
if (env[SINGLE_HOST_ENV.HOST_KEY] != null) {
|
|
2612
|
+
notes.push("pinned");
|
|
2613
|
+
}
|
|
2614
|
+
return {
|
|
2615
|
+
endpoint: `${username}@${host}:${port}`,
|
|
2616
|
+
auth,
|
|
2617
|
+
notes: notes.join(", ")
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// src/modules/manage/prompter.ts
|
|
2622
|
+
var PromptClosedError = class extends Error {
|
|
2623
|
+
constructor() {
|
|
2624
|
+
super("input closed before the prompt was answered");
|
|
2625
|
+
this.name = "PromptClosedError";
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
var Prompter = class {
|
|
2629
|
+
constructor(input, output) {
|
|
2630
|
+
this.input = input;
|
|
2631
|
+
this.output = output;
|
|
2632
|
+
this.lineBuffer = "";
|
|
2633
|
+
this.pendingLines = [];
|
|
2634
|
+
this.waiter = null;
|
|
2635
|
+
this.inputEnded = false;
|
|
2636
|
+
this.secretMode = false;
|
|
2637
|
+
this.secretBuffer = "";
|
|
2638
|
+
input.setEncoding("utf8");
|
|
2639
|
+
input.on("data", (chunk) => {
|
|
2640
|
+
this.onData(chunk);
|
|
2641
|
+
});
|
|
2642
|
+
input.on("end", () => {
|
|
2643
|
+
this.inputEnded = true;
|
|
2644
|
+
if (this.waiter != null) {
|
|
2645
|
+
const waiter = this.waiter;
|
|
2646
|
+
this.waiter = null;
|
|
2647
|
+
waiter(null);
|
|
2648
|
+
}
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
/** Stops reading so the process can exit once the command is done. */
|
|
2652
|
+
close() {
|
|
2653
|
+
this.input.pause();
|
|
2654
|
+
}
|
|
2655
|
+
write(text) {
|
|
2656
|
+
this.output.write(text);
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Free-text prompt. Enter accepts `defaultValue` when one is shown.
|
|
2660
|
+
*
|
|
2661
|
+
* @throws {PromptClosedError} If the input closes before a line arrives.
|
|
2662
|
+
*/
|
|
2663
|
+
async ask(question, defaultValue) {
|
|
2664
|
+
let suffix = "";
|
|
2665
|
+
if (defaultValue != null && defaultValue !== "") {
|
|
2666
|
+
suffix = ` [${defaultValue}]`;
|
|
2667
|
+
}
|
|
2668
|
+
this.output.write(`${question}${suffix}: `);
|
|
2669
|
+
const line = await this.readLine();
|
|
2670
|
+
if (line == null) {
|
|
2671
|
+
throw new PromptClosedError();
|
|
2672
|
+
}
|
|
2673
|
+
const trimmed = line.trim();
|
|
2674
|
+
if (trimmed === "" && defaultValue != null) {
|
|
2675
|
+
return defaultValue;
|
|
2676
|
+
}
|
|
2677
|
+
return trimmed;
|
|
2678
|
+
}
|
|
2679
|
+
/** Like `ask`, re-asking until something non-empty is entered. */
|
|
2680
|
+
async askRequired(question, defaultValue) {
|
|
2681
|
+
for (; ; ) {
|
|
2682
|
+
const answer = await this.ask(question, defaultValue);
|
|
2683
|
+
if (answer !== "") {
|
|
2684
|
+
return answer;
|
|
2685
|
+
}
|
|
2686
|
+
this.output.write(" A value is required.\n");
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
async askYesNo(question, defaultValue) {
|
|
2690
|
+
let hint = "[y/N]";
|
|
2691
|
+
if (defaultValue == true) {
|
|
2692
|
+
hint = "[Y/n]";
|
|
2693
|
+
}
|
|
2694
|
+
for (; ; ) {
|
|
2695
|
+
this.output.write(`${question} ${hint}: `);
|
|
2696
|
+
const line = await this.readLine();
|
|
2697
|
+
if (line == null) {
|
|
2698
|
+
throw new PromptClosedError();
|
|
2699
|
+
}
|
|
2700
|
+
const normalized = line.trim().toLowerCase();
|
|
2701
|
+
if (normalized === "") {
|
|
2702
|
+
return defaultValue;
|
|
2703
|
+
}
|
|
2704
|
+
if (normalized === "y" || normalized === "yes") {
|
|
2705
|
+
return true;
|
|
2706
|
+
}
|
|
2707
|
+
if (normalized === "n" || normalized === "no") {
|
|
2708
|
+
return false;
|
|
2709
|
+
}
|
|
2710
|
+
this.output.write(" Answer y or n.\n");
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
/** Lists the choices one per line and accepts a key, case-insensitively. */
|
|
2714
|
+
async askChoice(question, choices, defaultKey) {
|
|
2715
|
+
this.output.write(`${question}
|
|
2716
|
+
`);
|
|
2717
|
+
for (const choice of choices) {
|
|
2718
|
+
this.output.write(` ${choice.key}) ${choice.label}
|
|
2719
|
+
`);
|
|
2720
|
+
}
|
|
2721
|
+
for (; ; ) {
|
|
2722
|
+
const answer = await this.ask("Choice", defaultKey);
|
|
2723
|
+
const matched = choices.find((choice) => choice.key.toLowerCase() === answer.toLowerCase());
|
|
2724
|
+
if (matched != null) {
|
|
2725
|
+
return matched.key;
|
|
2726
|
+
}
|
|
2727
|
+
this.output.write(` Pick one of ${choices.map((choice) => choice.key).join(", ")}.
|
|
2728
|
+
`);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
|
|
2733
|
+
* caller says a value already exists.
|
|
2734
|
+
*/
|
|
2735
|
+
async askSecret(question) {
|
|
2736
|
+
const rawModeStream = this.input;
|
|
2737
|
+
if (rawModeStream.isTTY != true || rawModeStream.setRawMode == null) {
|
|
2738
|
+
const answer = await this.ask(question);
|
|
2739
|
+
return answer;
|
|
2740
|
+
}
|
|
2741
|
+
this.output.write(`${question}: `);
|
|
2742
|
+
this.secretBuffer = "";
|
|
2743
|
+
this.secretMode = true;
|
|
2744
|
+
rawModeStream.setRawMode(true);
|
|
2745
|
+
try {
|
|
2746
|
+
const line = await this.readLine();
|
|
2747
|
+
if (line == null) {
|
|
2748
|
+
throw new PromptClosedError();
|
|
2749
|
+
}
|
|
2750
|
+
return line;
|
|
2751
|
+
} finally {
|
|
2752
|
+
rawModeStream.setRawMode(false);
|
|
2753
|
+
this.secretMode = false;
|
|
2754
|
+
this.output.write("\n");
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
readLine() {
|
|
2758
|
+
const queued = this.pendingLines.shift();
|
|
2759
|
+
if (queued != null) {
|
|
2760
|
+
return Promise.resolve(queued);
|
|
2761
|
+
}
|
|
2762
|
+
if (this.inputEnded == true) {
|
|
2763
|
+
return Promise.resolve(null);
|
|
2764
|
+
}
|
|
2765
|
+
return new Promise((resolve) => {
|
|
2766
|
+
this.waiter = resolve;
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
deliver(line) {
|
|
2770
|
+
if (this.waiter != null) {
|
|
2771
|
+
const waiter = this.waiter;
|
|
2772
|
+
this.waiter = null;
|
|
2773
|
+
waiter(line);
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
this.pendingLines.push(line);
|
|
2777
|
+
}
|
|
2778
|
+
onData(chunk) {
|
|
2779
|
+
if (this.secretMode == true) {
|
|
2780
|
+
for (const character of chunk) {
|
|
2781
|
+
if (character === "\r" || character === "\n") {
|
|
2782
|
+
const secret = this.secretBuffer;
|
|
2783
|
+
this.secretBuffer = "";
|
|
2784
|
+
this.deliver(secret);
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
if (character === "") {
|
|
2788
|
+
this.output.write("\n");
|
|
2789
|
+
process.exit(130);
|
|
2790
|
+
}
|
|
2791
|
+
if (character === "\x7F" || character === "\b") {
|
|
2792
|
+
this.secretBuffer = this.secretBuffer.slice(0, -1);
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
this.secretBuffer += character;
|
|
2796
|
+
}
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
this.lineBuffer += chunk;
|
|
2800
|
+
let newlineIndex = this.lineBuffer.indexOf("\n");
|
|
2801
|
+
while (newlineIndex >= 0) {
|
|
2802
|
+
const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
2803
|
+
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
|
|
2804
|
+
this.deliver(line);
|
|
2805
|
+
newlineIndex = this.lineBuffer.indexOf("\n");
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
var DEFAULT_KEY_CANDIDATES = ["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"];
|
|
2810
|
+
var CLEAR_ANSWER = "-";
|
|
2811
|
+
async function askPort(prompter, question, current) {
|
|
2812
|
+
for (; ; ) {
|
|
2813
|
+
const answer = await prompter.ask(question, String(current));
|
|
2814
|
+
const parsed = Number(answer);
|
|
2815
|
+
if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
|
|
2816
|
+
return parsed;
|
|
2817
|
+
}
|
|
2818
|
+
prompter.write(" Enter a port number between 1 and 65535.\n");
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
async function askOptional(prompter, question, current) {
|
|
2822
|
+
const answer = await prompter.ask(question, current);
|
|
2823
|
+
if (answer === "" || answer === CLEAR_ANSWER) {
|
|
2824
|
+
return void 0;
|
|
2825
|
+
}
|
|
2826
|
+
return answer;
|
|
2827
|
+
}
|
|
2828
|
+
async function askOptionalSecret(prompter, question, current) {
|
|
2829
|
+
let suffix = " (empty for none)";
|
|
2830
|
+
if (current != null) {
|
|
2831
|
+
suffix = ` [keep current, ${CLEAR_ANSWER} to clear]`;
|
|
2832
|
+
}
|
|
2833
|
+
const answer = await prompter.askSecret(`${question}${suffix}`);
|
|
2834
|
+
if (answer === CLEAR_ANSWER) {
|
|
2835
|
+
return void 0;
|
|
2836
|
+
}
|
|
2837
|
+
if (answer === "") {
|
|
2838
|
+
return current;
|
|
2839
|
+
}
|
|
2840
|
+
return answer;
|
|
2841
|
+
}
|
|
2842
|
+
async function askHostQuestions(prompter, existing) {
|
|
2843
|
+
if (existing != null) {
|
|
2844
|
+
prompter.write(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
|
|
2845
|
+
|
|
2846
|
+
`);
|
|
2847
|
+
}
|
|
2848
|
+
const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
|
|
2849
|
+
const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
|
|
2850
|
+
const username = await prompter.askRequired("Login user", existing?.username);
|
|
2851
|
+
let name = "";
|
|
2852
|
+
const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
2853
|
+
for (; ; ) {
|
|
2854
|
+
name = await prompter.askRequired("Server name the agent sees", defaultName);
|
|
2855
|
+
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
2856
|
+
break;
|
|
2857
|
+
}
|
|
2858
|
+
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
2859
|
+
}
|
|
2860
|
+
const auth = await prompter.askChoice(
|
|
2861
|
+
"Authentication",
|
|
2862
|
+
[
|
|
2863
|
+
{ key: "key", label: "private key file (recommended \u2014 only the path is stored)" },
|
|
2864
|
+
{ key: "password", label: "password (stored in the config file in plain text)" },
|
|
2865
|
+
{ key: "agent", label: "ssh agent (SSH_AUTH_SOCK / OpenSSH agent pipe)" }
|
|
2866
|
+
],
|
|
2867
|
+
existing?.auth ?? "key"
|
|
2868
|
+
);
|
|
2869
|
+
let keyPath;
|
|
2870
|
+
let passphrase;
|
|
2871
|
+
let password;
|
|
2872
|
+
if (auth === "key") {
|
|
2873
|
+
let defaultKeyPath = existing?.keyPath;
|
|
2874
|
+
if (defaultKeyPath == null) {
|
|
2875
|
+
defaultKeyPath = DEFAULT_KEY_CANDIDATES.find((candidate) => fs7.existsSync(expandHomePath(candidate)) == true);
|
|
2876
|
+
}
|
|
2877
|
+
for (; ; ) {
|
|
2878
|
+
keyPath = await prompter.askRequired("Private key path", defaultKeyPath);
|
|
2879
|
+
if (fs7.existsSync(expandHomePath(keyPath)) == true) {
|
|
2880
|
+
break;
|
|
2881
|
+
}
|
|
2882
|
+
prompter.write(` ${expandHomePath(keyPath)} does not exist.
|
|
2883
|
+
`);
|
|
2884
|
+
const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
|
|
2885
|
+
if (useAnyway == true) {
|
|
2886
|
+
break;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
passphrase = await askOptionalSecret(prompter, "Key passphrase", existing?.passphrase);
|
|
2890
|
+
}
|
|
2891
|
+
if (auth === "password") {
|
|
2892
|
+
prompter.write(" The password is written into the config file as plain text. A key file is safer.\n");
|
|
2893
|
+
password = await askOptionalSecret(prompter, "Password", existing?.password);
|
|
2894
|
+
if (password == null) {
|
|
2895
|
+
prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
let jumpHost;
|
|
2899
|
+
let jumpPort;
|
|
2900
|
+
let jumpUser;
|
|
2901
|
+
const viaJump = await prompter.askYesNo("Reach it through a jump host (ssh -J)?", existing?.jumpHost != null);
|
|
2902
|
+
if (viaJump == true) {
|
|
2903
|
+
jumpHost = await prompter.askRequired("Jump host", existing?.jumpHost);
|
|
2904
|
+
jumpPort = await askPort(prompter, "Jump host port", existing?.jumpPort ?? DEFAULT_SSH_PORT);
|
|
2905
|
+
jumpUser = await prompter.askRequired("Jump host user", existing?.jumpUser ?? username);
|
|
2906
|
+
}
|
|
2907
|
+
let proxyCommand;
|
|
2908
|
+
const viaProxy = await prompter.askYesNo("Start a proxy command for the first hop (e.g. cloudflared)?", existing?.proxyCommand != null);
|
|
2909
|
+
if (viaProxy == true) {
|
|
2910
|
+
prompter.write(" %h / %p / %r expand to the first hop's host, port and user.\n");
|
|
2911
|
+
proxyCommand = await prompter.askRequired("Proxy command", existing?.proxyCommand ?? "cloudflared access ssh --hostname %h");
|
|
2912
|
+
}
|
|
2913
|
+
const description = await askOptional(prompter, "Description shown to the agent (what the host is for)", existing?.description);
|
|
2914
|
+
const readonly = await prompter.askYesNo("Read-only? (rejects writes, redirections, uploads)", existing?.readonly ?? false);
|
|
2915
|
+
let currentAllowedPaths;
|
|
2916
|
+
if (existing != null && existing.allowedPaths.length > 0) {
|
|
2917
|
+
currentAllowedPaths = existing.allowedPaths.join(",");
|
|
2918
|
+
}
|
|
2919
|
+
let allowedPaths = [];
|
|
2920
|
+
const allowedPathsAnswer = await askOptional(
|
|
2921
|
+
prompter,
|
|
2922
|
+
"Restrict file tools to these path prefixes (comma-separated, empty = unrestricted)",
|
|
2923
|
+
currentAllowedPaths
|
|
2924
|
+
);
|
|
2925
|
+
if (allowedPathsAnswer != null) {
|
|
2926
|
+
allowedPaths = allowedPathsAnswer.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
2927
|
+
}
|
|
2928
|
+
let resolvedAuth = auth;
|
|
2929
|
+
if (auth === "password" && password == null) {
|
|
2930
|
+
resolvedAuth = "agent";
|
|
2931
|
+
}
|
|
2932
|
+
const answers = {
|
|
2933
|
+
name,
|
|
2934
|
+
host,
|
|
2935
|
+
port,
|
|
2936
|
+
username,
|
|
2937
|
+
auth: resolvedAuth,
|
|
2938
|
+
keyPath,
|
|
2939
|
+
passphrase,
|
|
2940
|
+
password,
|
|
2941
|
+
description,
|
|
2942
|
+
jumpHost,
|
|
2943
|
+
jumpPort,
|
|
2944
|
+
jumpUser,
|
|
2945
|
+
proxyCommand,
|
|
2946
|
+
readonly,
|
|
2947
|
+
allowedPaths,
|
|
2948
|
+
extraEnv: existing?.extraEnv ?? {}
|
|
2949
|
+
};
|
|
2950
|
+
if (existing != null && existing.host === host && existing.port === port) {
|
|
2951
|
+
answers.hostKeyFingerprint = existing.hostKeyFingerprint;
|
|
2952
|
+
}
|
|
2953
|
+
if (existing != null && existing.jumpHost === jumpHost && existing.jumpPort === jumpPort) {
|
|
2954
|
+
answers.jumpHostKeyFingerprint = existing.jumpHostKeyFingerprint;
|
|
2955
|
+
}
|
|
2956
|
+
return answers;
|
|
2957
|
+
}
|
|
2958
|
+
function toMcpServerEntry(value) {
|
|
2959
|
+
if (typeof value !== "object" || value == null) {
|
|
2960
|
+
return null;
|
|
2961
|
+
}
|
|
2962
|
+
const record = value;
|
|
2963
|
+
if (typeof record.command !== "string") {
|
|
2964
|
+
return null;
|
|
2965
|
+
}
|
|
2966
|
+
let args = [];
|
|
2967
|
+
if (Array.isArray(record.args) == true) {
|
|
2968
|
+
args = record.args.filter((item) => typeof item === "string");
|
|
2969
|
+
}
|
|
2970
|
+
const env = {};
|
|
2971
|
+
if (typeof record.env === "object" && record.env != null) {
|
|
2972
|
+
for (const [key, item] of Object.entries(record.env)) {
|
|
2973
|
+
if (typeof item === "string") {
|
|
2974
|
+
env[key] = item;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
return { command: record.command, args, env };
|
|
2979
|
+
}
|
|
2980
|
+
function readMcpServersJson(document) {
|
|
2981
|
+
if (typeof document !== "object" || document == null || Array.isArray(document) == true) {
|
|
2982
|
+
throw new Error("the config file is not a JSON object");
|
|
2983
|
+
}
|
|
2984
|
+
const entries = {};
|
|
2985
|
+
const servers = document.mcpServers;
|
|
2986
|
+
if (typeof servers !== "object" || servers == null) {
|
|
2987
|
+
return entries;
|
|
2988
|
+
}
|
|
2989
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
2990
|
+
const entry = toMcpServerEntry(value);
|
|
2991
|
+
if (entry != null) {
|
|
2992
|
+
entries[name] = entry;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
return entries;
|
|
2996
|
+
}
|
|
2997
|
+
function readDocument(filePath) {
|
|
2998
|
+
if (fs7.existsSync(filePath) == false) {
|
|
2999
|
+
return {};
|
|
3000
|
+
}
|
|
3001
|
+
const text = fs7.readFileSync(filePath, "utf8");
|
|
3002
|
+
if (text.trim() === "") {
|
|
3003
|
+
return {};
|
|
3004
|
+
}
|
|
3005
|
+
let document;
|
|
3006
|
+
try {
|
|
3007
|
+
document = JSON.parse(text);
|
|
3008
|
+
} catch (ex) {
|
|
3009
|
+
let detail = String(ex);
|
|
3010
|
+
if (ex instanceof Error) {
|
|
3011
|
+
detail = ex.message;
|
|
3012
|
+
}
|
|
3013
|
+
throw new Error(`${filePath} is not valid JSON: ${detail}`);
|
|
3014
|
+
}
|
|
3015
|
+
if (typeof document !== "object" || document == null || Array.isArray(document) == true) {
|
|
3016
|
+
throw new Error(`${filePath} is not a JSON object`);
|
|
3017
|
+
}
|
|
3018
|
+
return document;
|
|
3019
|
+
}
|
|
3020
|
+
function createJsonFileTarget(options) {
|
|
3021
|
+
const { id, label, filePath } = options;
|
|
3022
|
+
function writeDocument(document) {
|
|
3023
|
+
fs7.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
3024
|
+
fs7.writeFileSync(filePath, `${JSON.stringify(document, null, 4)}
|
|
3025
|
+
`, "utf8");
|
|
3026
|
+
}
|
|
3027
|
+
return {
|
|
3028
|
+
id,
|
|
3029
|
+
label,
|
|
3030
|
+
location: filePath,
|
|
3031
|
+
detected: fs7.existsSync(filePath),
|
|
3032
|
+
readEntries() {
|
|
3033
|
+
const document = readDocument(filePath);
|
|
3034
|
+
return readMcpServersJson(document);
|
|
3035
|
+
},
|
|
3036
|
+
writeEntry(name, entry) {
|
|
3037
|
+
const document = readDocument(filePath);
|
|
3038
|
+
let servers = document.mcpServers;
|
|
3039
|
+
if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
|
|
3040
|
+
servers = {};
|
|
3041
|
+
}
|
|
3042
|
+
servers[name] = entry;
|
|
3043
|
+
document.mcpServers = servers;
|
|
3044
|
+
writeDocument(document);
|
|
3045
|
+
return { status: "written", detail: filePath };
|
|
3046
|
+
},
|
|
3047
|
+
removeEntry(name) {
|
|
3048
|
+
const document = readDocument(filePath);
|
|
3049
|
+
const servers = document.mcpServers;
|
|
3050
|
+
if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
|
|
3051
|
+
return { status: "written", detail: filePath };
|
|
3052
|
+
}
|
|
3053
|
+
delete servers[name];
|
|
3054
|
+
writeDocument(document);
|
|
3055
|
+
return { status: "written", detail: filePath };
|
|
3056
|
+
}
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// src/modules/manage/targets/claude-code.ts
|
|
3061
|
+
var CLAUDE_CODE_CONFIG_PATH = path.join(os4.homedir(), ".claude.json");
|
|
3062
|
+
var CMD_META_CHARACTER_PATTERN = /([()\][%!^"`<>&|;, *?])/g;
|
|
3063
|
+
function toClaudeAddJsonPayload(entry) {
|
|
3064
|
+
return JSON.stringify({ type: "stdio", command: entry.command, args: entry.args, env: entry.env });
|
|
3065
|
+
}
|
|
3066
|
+
function formatClaudeAddCommand(name, entry) {
|
|
3067
|
+
const payload = toClaudeAddJsonPayload(entry);
|
|
3068
|
+
return `claude mcp add-json ${name} '${payload}' -s user`;
|
|
3069
|
+
}
|
|
3070
|
+
function escapeCmdArgument(argument) {
|
|
3071
|
+
let escaped = argument.replace(/(\\*)"/g, '$1$1\\"');
|
|
3072
|
+
escaped = escaped.replace(/(\\*)$/, "$1$1");
|
|
3073
|
+
escaped = `"${escaped}"`;
|
|
3074
|
+
escaped = escaped.replace(CMD_META_CHARACTER_PATTERN, "^$1");
|
|
3075
|
+
escaped = escaped.replace(CMD_META_CHARACTER_PATTERN, "^$1");
|
|
3076
|
+
return escaped;
|
|
3077
|
+
}
|
|
3078
|
+
function runCliCommand(executable, args) {
|
|
3079
|
+
const isBatchShim = /\.(cmd|bat)$/i.test(executable) == true;
|
|
3080
|
+
if (process.platform !== "win32" || isBatchShim == false) {
|
|
3081
|
+
const result2 = spawnSync(executable, args, { encoding: "utf8", windowsHide: true });
|
|
3082
|
+
return result2;
|
|
3083
|
+
}
|
|
3084
|
+
const commandLine = [executable.replace(CMD_META_CHARACTER_PATTERN, "^$1"), ...args.map(escapeCmdArgument)].join(" ");
|
|
3085
|
+
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${commandLine}"`], {
|
|
3086
|
+
encoding: "utf8",
|
|
3087
|
+
windowsHide: true,
|
|
3088
|
+
windowsVerbatimArguments: true
|
|
3089
|
+
});
|
|
3090
|
+
return result;
|
|
3091
|
+
}
|
|
3092
|
+
function runClaude(executable, args) {
|
|
3093
|
+
const result = runCliCommand(executable, args);
|
|
3094
|
+
if (result.error != null) {
|
|
3095
|
+
return { status: "unavailable", detail: `could not run claude: ${result.error.message}` };
|
|
3096
|
+
}
|
|
3097
|
+
if (result.status !== 0) {
|
|
3098
|
+
let output = (result.stderr ?? "").trim();
|
|
3099
|
+
if (output === "") {
|
|
3100
|
+
output = (result.stdout ?? "").trim();
|
|
3101
|
+
}
|
|
3102
|
+
return { status: "unavailable", detail: `claude exited with ${result.status}: ${output}` };
|
|
3103
|
+
}
|
|
3104
|
+
return { status: "written", detail: CLAUDE_CODE_CONFIG_PATH };
|
|
3105
|
+
}
|
|
3106
|
+
function readClaudeEntries() {
|
|
3107
|
+
if (fs7.existsSync(CLAUDE_CODE_CONFIG_PATH) == false) {
|
|
3108
|
+
return {};
|
|
3109
|
+
}
|
|
3110
|
+
const text = fs7.readFileSync(CLAUDE_CODE_CONFIG_PATH, "utf8");
|
|
3111
|
+
let document;
|
|
3112
|
+
try {
|
|
3113
|
+
document = JSON.parse(text);
|
|
3114
|
+
} catch (ex) {
|
|
3115
|
+
let detail = String(ex);
|
|
3116
|
+
if (ex instanceof Error) {
|
|
3117
|
+
detail = ex.message;
|
|
3118
|
+
}
|
|
3119
|
+
throw new Error(`${CLAUDE_CODE_CONFIG_PATH} is not valid JSON: ${detail}`);
|
|
3120
|
+
}
|
|
3121
|
+
return readMcpServersJson(document);
|
|
3122
|
+
}
|
|
3123
|
+
function createClaudeCodeTarget() {
|
|
3124
|
+
const executable = findExecutable("claude");
|
|
3125
|
+
return {
|
|
3126
|
+
id: "claude-code",
|
|
3127
|
+
label: "Claude Code (user scope)",
|
|
3128
|
+
location: CLAUDE_CODE_CONFIG_PATH,
|
|
3129
|
+
detected: executable != null || fs7.existsSync(CLAUDE_CODE_CONFIG_PATH) == true,
|
|
3130
|
+
readEntries: readClaudeEntries,
|
|
3131
|
+
writeEntry(name, entry) {
|
|
3132
|
+
if (executable == null) {
|
|
3133
|
+
return {
|
|
3134
|
+
status: "unavailable",
|
|
3135
|
+
detail: `the claude CLI is not on PATH \u2014 run this yourself:
|
|
3136
|
+
${formatClaudeAddCommand(name, entry)}`
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
const previous = readClaudeEntries()[name];
|
|
3140
|
+
if (previous != null) {
|
|
3141
|
+
const removed = runClaude(executable, ["mcp", "remove", name, "-s", "user"]);
|
|
3142
|
+
if (removed.status === "unavailable") {
|
|
3143
|
+
return removed;
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
const added = runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(entry), "-s", "user"]);
|
|
3147
|
+
if (added.status === "unavailable" && previous != null) {
|
|
3148
|
+
runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(previous), "-s", "user"]);
|
|
3149
|
+
return { status: "unavailable", detail: `${added.detail}
|
|
3150
|
+
the previous entry was restored` };
|
|
3151
|
+
}
|
|
3152
|
+
return added;
|
|
3153
|
+
},
|
|
3154
|
+
removeEntry(name) {
|
|
3155
|
+
if (executable == null) {
|
|
3156
|
+
return {
|
|
3157
|
+
status: "unavailable",
|
|
3158
|
+
detail: `the claude CLI is not on PATH \u2014 run this yourself:
|
|
3159
|
+
claude mcp remove ${name} -s user`
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
return runClaude(executable, ["mcp", "remove", name, "-s", "user"]);
|
|
3163
|
+
}
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
// src/modules/manage/targets/toml-subset.ts
|
|
3168
|
+
var BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
3169
|
+
var ANY_HEADER_PATTERN = /^\s*\[/;
|
|
3170
|
+
var SERVER_HEADER_PATTERN = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*((?:\.[^\]]*)?)\]\s*(?:#.*)?$/;
|
|
3171
|
+
var KEY_VALUE_PATTERN = /^\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*=\s*(.*)$/;
|
|
3172
|
+
var OWNED_TABLE_KEYS = ["command", "args", "env"];
|
|
3173
|
+
var TomlValueParser = class {
|
|
3174
|
+
constructor(text) {
|
|
3175
|
+
this.text = text;
|
|
3176
|
+
this.position = 0;
|
|
3177
|
+
}
|
|
3178
|
+
parse() {
|
|
3179
|
+
const value = this.parseValue();
|
|
3180
|
+
return value;
|
|
3181
|
+
}
|
|
3182
|
+
skipWhitespace() {
|
|
3183
|
+
while (this.position < this.text.length && /\s/.test(this.text[this.position]) == true) {
|
|
3184
|
+
this.position += 1;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
parseValue() {
|
|
3188
|
+
this.skipWhitespace();
|
|
3189
|
+
const current = this.text[this.position];
|
|
3190
|
+
if (current === '"') {
|
|
3191
|
+
return this.parseBasicString();
|
|
3192
|
+
}
|
|
3193
|
+
if (current === "'") {
|
|
3194
|
+
return this.parseLiteralString();
|
|
3195
|
+
}
|
|
3196
|
+
if (current === "[") {
|
|
3197
|
+
return this.parseArray();
|
|
3198
|
+
}
|
|
3199
|
+
if (current === "{") {
|
|
3200
|
+
return this.parseInlineTable();
|
|
3201
|
+
}
|
|
3202
|
+
throw new Error(`unsupported toml value at offset ${this.position}`);
|
|
3203
|
+
}
|
|
3204
|
+
parseBasicString() {
|
|
3205
|
+
this.position += 1;
|
|
3206
|
+
let result = "";
|
|
3207
|
+
while (this.position < this.text.length) {
|
|
3208
|
+
const current = this.text[this.position];
|
|
3209
|
+
if (current === '"') {
|
|
3210
|
+
this.position += 1;
|
|
3211
|
+
return result;
|
|
3212
|
+
}
|
|
3213
|
+
if (current !== "\\") {
|
|
3214
|
+
result += current;
|
|
3215
|
+
this.position += 1;
|
|
3216
|
+
continue;
|
|
3217
|
+
}
|
|
3218
|
+
const escaped = this.text[this.position + 1];
|
|
3219
|
+
this.position += 2;
|
|
3220
|
+
if (escaped === "n") {
|
|
3221
|
+
result += "\n";
|
|
3222
|
+
} else if (escaped === "t") {
|
|
3223
|
+
result += " ";
|
|
3224
|
+
} else if (escaped === "r") {
|
|
3225
|
+
result += "\r";
|
|
3226
|
+
} else if (escaped === "u" || escaped === "U") {
|
|
3227
|
+
let length = 4;
|
|
3228
|
+
if (escaped === "U") {
|
|
3229
|
+
length = 8;
|
|
3230
|
+
}
|
|
3231
|
+
const hex = this.text.slice(this.position, this.position + length);
|
|
3232
|
+
result += String.fromCodePoint(Number.parseInt(hex, 16));
|
|
3233
|
+
this.position += length;
|
|
3234
|
+
} else {
|
|
3235
|
+
result += escaped;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
throw new Error("unterminated toml string");
|
|
3239
|
+
}
|
|
3240
|
+
parseLiteralString() {
|
|
3241
|
+
const end = this.text.indexOf("'", this.position + 1);
|
|
3242
|
+
if (end < 0) {
|
|
3243
|
+
throw new Error("unterminated toml literal string");
|
|
3244
|
+
}
|
|
3245
|
+
const result = this.text.slice(this.position + 1, end);
|
|
3246
|
+
this.position = end + 1;
|
|
3247
|
+
return result;
|
|
3248
|
+
}
|
|
3249
|
+
parseArray() {
|
|
3250
|
+
this.position += 1;
|
|
3251
|
+
const items = [];
|
|
3252
|
+
for (; ; ) {
|
|
3253
|
+
this.skipWhitespace();
|
|
3254
|
+
if (this.position >= this.text.length) {
|
|
3255
|
+
throw new Error("unterminated toml array");
|
|
3256
|
+
}
|
|
3257
|
+
if (this.text[this.position] === "]") {
|
|
3258
|
+
this.position += 1;
|
|
3259
|
+
return items;
|
|
3260
|
+
}
|
|
3261
|
+
items.push(this.parseValue());
|
|
3262
|
+
this.skipWhitespace();
|
|
3263
|
+
if (this.text[this.position] === ",") {
|
|
3264
|
+
this.position += 1;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
parseInlineTable() {
|
|
3269
|
+
this.position += 1;
|
|
3270
|
+
const table = {};
|
|
3271
|
+
for (; ; ) {
|
|
3272
|
+
this.skipWhitespace();
|
|
3273
|
+
if (this.position >= this.text.length) {
|
|
3274
|
+
throw new Error("unterminated toml inline table");
|
|
3275
|
+
}
|
|
3276
|
+
if (this.text[this.position] === "}") {
|
|
3277
|
+
this.position += 1;
|
|
3278
|
+
return table;
|
|
3279
|
+
}
|
|
3280
|
+
let key;
|
|
3281
|
+
if (this.text[this.position] === '"') {
|
|
3282
|
+
key = this.parseBasicString();
|
|
3283
|
+
} else {
|
|
3284
|
+
const match = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.position));
|
|
3285
|
+
if (match == null) {
|
|
3286
|
+
throw new Error(`invalid inline table key at offset ${this.position}`);
|
|
3287
|
+
}
|
|
3288
|
+
key = match[0];
|
|
3289
|
+
this.position += key.length;
|
|
3290
|
+
}
|
|
3291
|
+
this.skipWhitespace();
|
|
3292
|
+
if (this.text[this.position] !== "=") {
|
|
3293
|
+
throw new Error(`expected '=' at offset ${this.position}`);
|
|
3294
|
+
}
|
|
3295
|
+
this.position += 1;
|
|
3296
|
+
table[key] = this.parseValue();
|
|
3297
|
+
this.skipWhitespace();
|
|
3298
|
+
if (this.text[this.position] === ",") {
|
|
3299
|
+
this.position += 1;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
function unquoteKey(rawKey) {
|
|
3305
|
+
if (rawKey.startsWith('"') == false) {
|
|
3306
|
+
return rawKey;
|
|
3307
|
+
}
|
|
3308
|
+
const parsed = new TomlValueParser(rawKey).parse();
|
|
3309
|
+
return parsed;
|
|
3310
|
+
}
|
|
3311
|
+
function bracketBalance(text) {
|
|
3312
|
+
let balance = 0;
|
|
3313
|
+
let quote = null;
|
|
3314
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3315
|
+
const current = text[index];
|
|
3316
|
+
if (quote != null) {
|
|
3317
|
+
if (current === "\\" && quote === '"') {
|
|
3318
|
+
index += 1;
|
|
3319
|
+
} else if (current === quote) {
|
|
3320
|
+
quote = null;
|
|
3321
|
+
}
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
if (current === '"' || current === "'") {
|
|
3325
|
+
quote = current;
|
|
3326
|
+
} else if (current === "#") {
|
|
3327
|
+
break;
|
|
3328
|
+
} else if (current === "[" || current === "{") {
|
|
3329
|
+
balance += 1;
|
|
3330
|
+
} else if (current === "]" || current === "}") {
|
|
3331
|
+
balance -= 1;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return balance;
|
|
3335
|
+
}
|
|
3336
|
+
function stripComment(text) {
|
|
3337
|
+
let quote = null;
|
|
3338
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3339
|
+
const current = text[index];
|
|
3340
|
+
if (quote != null) {
|
|
3341
|
+
if (current === "\\" && quote === '"') {
|
|
3342
|
+
index += 1;
|
|
3343
|
+
} else if (current === quote) {
|
|
3344
|
+
quote = null;
|
|
3345
|
+
}
|
|
3346
|
+
continue;
|
|
3347
|
+
}
|
|
3348
|
+
if (current === '"' || current === "'") {
|
|
3349
|
+
quote = current;
|
|
3350
|
+
} else if (current === "#") {
|
|
3351
|
+
return text.slice(0, index);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
return text;
|
|
3355
|
+
}
|
|
3356
|
+
function readStatements(body) {
|
|
3357
|
+
const statements = [];
|
|
3358
|
+
let index = 0;
|
|
3359
|
+
while (index < body.length) {
|
|
3360
|
+
const match = KEY_VALUE_PATTERN.exec(body[index]);
|
|
3361
|
+
if (match == null) {
|
|
3362
|
+
index += 1;
|
|
3363
|
+
continue;
|
|
3364
|
+
}
|
|
3365
|
+
const start = index;
|
|
3366
|
+
let rawValue = stripComment(match[2]);
|
|
3367
|
+
let balance = bracketBalance(rawValue);
|
|
3368
|
+
index += 1;
|
|
3369
|
+
while (balance > 0 && index < body.length) {
|
|
3370
|
+
const continuation = stripComment(body[index]);
|
|
3371
|
+
rawValue += `
|
|
3372
|
+
${continuation}`;
|
|
3373
|
+
balance += bracketBalance(continuation);
|
|
3374
|
+
index += 1;
|
|
3375
|
+
}
|
|
3376
|
+
statements.push({ key: unquoteKey(match[1]), rawValue, start, end: index });
|
|
3377
|
+
}
|
|
3378
|
+
return statements;
|
|
3379
|
+
}
|
|
3380
|
+
function splitSections(lines) {
|
|
3381
|
+
const sections = [{ header: null, serverName: null, subPath: "", body: [] }];
|
|
3382
|
+
let balance = 0;
|
|
3383
|
+
for (const line of lines) {
|
|
3384
|
+
const current = sections[sections.length - 1];
|
|
3385
|
+
if (balance > 0) {
|
|
3386
|
+
current.body.push(line);
|
|
3387
|
+
balance += bracketBalance(stripComment(line));
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
if (ANY_HEADER_PATTERN.test(line) == true) {
|
|
3391
|
+
const serverMatch = SERVER_HEADER_PATTERN.exec(line);
|
|
3392
|
+
let serverName = null;
|
|
3393
|
+
let subPath = "";
|
|
3394
|
+
if (serverMatch != null) {
|
|
3395
|
+
serverName = unquoteKey(serverMatch[1]);
|
|
3396
|
+
subPath = serverMatch[2].replace(/^\./, "").replace(/\s+/g, "");
|
|
3397
|
+
}
|
|
3398
|
+
sections.push({ header: line, serverName, subPath, body: [] });
|
|
3399
|
+
continue;
|
|
3400
|
+
}
|
|
3401
|
+
current.body.push(line);
|
|
3402
|
+
const keyValueMatch = KEY_VALUE_PATTERN.exec(line);
|
|
3403
|
+
if (keyValueMatch != null) {
|
|
3404
|
+
balance = bracketBalance(stripComment(keyValueMatch[2]));
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
return sections;
|
|
3408
|
+
}
|
|
3409
|
+
function parseStatementValue(statement) {
|
|
3410
|
+
try {
|
|
3411
|
+
const value = new TomlValueParser(statement.rawValue).parse();
|
|
3412
|
+
return value;
|
|
3413
|
+
} catch {
|
|
3414
|
+
return null;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
function parseMcpServersToml(text) {
|
|
3418
|
+
const sections = splitSections(text.split(/\r?\n/));
|
|
3419
|
+
const entries = {};
|
|
3420
|
+
const envTables = {};
|
|
3421
|
+
for (const section of sections) {
|
|
3422
|
+
if (section.serverName == null) {
|
|
3423
|
+
continue;
|
|
3424
|
+
}
|
|
3425
|
+
if (section.subPath === "env") {
|
|
3426
|
+
const envTable = {};
|
|
3427
|
+
for (const statement of readStatements(section.body)) {
|
|
3428
|
+
const value = parseStatementValue(statement);
|
|
3429
|
+
if (typeof value === "string") {
|
|
3430
|
+
envTable[statement.key] = value;
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
envTables[section.serverName] = envTable;
|
|
3434
|
+
continue;
|
|
3435
|
+
}
|
|
3436
|
+
if (section.subPath !== "") {
|
|
3437
|
+
continue;
|
|
3438
|
+
}
|
|
3439
|
+
let command = null;
|
|
3440
|
+
let args = [];
|
|
3441
|
+
const env = {};
|
|
3442
|
+
for (const statement of readStatements(section.body)) {
|
|
3443
|
+
const value = parseStatementValue(statement);
|
|
3444
|
+
if (statement.key === "command" && typeof value === "string") {
|
|
3445
|
+
command = value;
|
|
3446
|
+
} else if (statement.key === "args" && Array.isArray(value) == true) {
|
|
3447
|
+
args = value.filter((item) => typeof item === "string");
|
|
3448
|
+
} else if (statement.key === "env" && value != null && typeof value === "object" && Array.isArray(value) == false) {
|
|
3449
|
+
for (const [key, item] of Object.entries(value)) {
|
|
3450
|
+
if (typeof item === "string") {
|
|
3451
|
+
env[key] = item;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
if (command != null) {
|
|
3457
|
+
entries[section.serverName] = { command, args, env };
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
for (const [name, envTable] of Object.entries(envTables)) {
|
|
3461
|
+
if (entries[name] != null) {
|
|
3462
|
+
Object.assign(entries[name].env, envTable);
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
return entries;
|
|
3466
|
+
}
|
|
3467
|
+
function quoteTomlString(value) {
|
|
3468
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
3469
|
+
return `"${escaped}"`;
|
|
3470
|
+
}
|
|
3471
|
+
function formatTomlKey(key) {
|
|
3472
|
+
if (BARE_KEY_PATTERN.test(key) == true) {
|
|
3473
|
+
return key;
|
|
3474
|
+
}
|
|
3475
|
+
return quoteTomlString(key);
|
|
3476
|
+
}
|
|
3477
|
+
function renderOwnedLines(entry) {
|
|
3478
|
+
return [
|
|
3479
|
+
`command = ${quoteTomlString(entry.command)}`,
|
|
3480
|
+
`args = [${entry.args.map((argument) => quoteTomlString(argument)).join(", ")}]`
|
|
3481
|
+
];
|
|
3482
|
+
}
|
|
3483
|
+
function renderEnvLines(entry) {
|
|
3484
|
+
return Object.entries(entry.env).map(([key, value]) => `${formatTomlKey(key)} = ${quoteTomlString(value)}`);
|
|
3485
|
+
}
|
|
3486
|
+
function renderMcpServerTable(name, entry) {
|
|
3487
|
+
const tableKey = formatTomlKey(name);
|
|
3488
|
+
const lines = [`[mcp_servers.${tableKey}]`, ...renderOwnedLines(entry)];
|
|
3489
|
+
const envLines = renderEnvLines(entry);
|
|
3490
|
+
if (envLines.length > 0) {
|
|
3491
|
+
lines.push("");
|
|
3492
|
+
lines.push(`[mcp_servers.${tableKey}.env]`);
|
|
3493
|
+
lines.push(...envLines);
|
|
3494
|
+
}
|
|
3495
|
+
return `${lines.join("\n")}
|
|
3496
|
+
`;
|
|
3497
|
+
}
|
|
3498
|
+
function isBlank(line) {
|
|
3499
|
+
return line.trim() === "";
|
|
3500
|
+
}
|
|
3501
|
+
function joinSections(sections, eol) {
|
|
3502
|
+
const lines = [];
|
|
3503
|
+
for (const section of sections) {
|
|
3504
|
+
if (section.header != null) {
|
|
3505
|
+
lines.push(section.header);
|
|
3506
|
+
}
|
|
3507
|
+
lines.push(...section.body);
|
|
3508
|
+
}
|
|
3509
|
+
return lines.join(eol);
|
|
3510
|
+
}
|
|
3511
|
+
function writeMcpServerToml(text, name, entry) {
|
|
3512
|
+
let eol = "\n";
|
|
3513
|
+
if (text.includes("\r\n") == true) {
|
|
3514
|
+
eol = "\r\n";
|
|
3515
|
+
}
|
|
3516
|
+
const sections = splitSections(text.split(/\r?\n/));
|
|
3517
|
+
const isOwn = (section) => section.serverName === name;
|
|
3518
|
+
if (entry == null) {
|
|
3519
|
+
const kept = sections.filter((section) => isOwn(section) == false);
|
|
3520
|
+
const removedText = joinSections(kept, eol);
|
|
3521
|
+
return removedText;
|
|
3522
|
+
}
|
|
3523
|
+
const tableKey = formatTomlKey(name);
|
|
3524
|
+
const envLines = renderEnvLines(entry);
|
|
3525
|
+
const tableIndex = sections.findIndex((section) => isOwn(section) == true && section.subPath === "");
|
|
3526
|
+
if (tableIndex < 0) {
|
|
3527
|
+
const withoutStrayEnv = sections.filter((section) => (isOwn(section) == true && section.subPath === "env") == false);
|
|
3528
|
+
let prefix = joinSections(withoutStrayEnv, eol);
|
|
3529
|
+
if (prefix.trim() === "") {
|
|
3530
|
+
prefix = "";
|
|
3531
|
+
} else {
|
|
3532
|
+
prefix = prefix.replace(/(\r?\n)*$/, "");
|
|
3533
|
+
prefix = `${prefix}${eol}${eol}`;
|
|
3534
|
+
}
|
|
3535
|
+
const block = renderMcpServerTable(name, entry).replace(/\n/g, eol);
|
|
3536
|
+
return `${prefix}${block}`;
|
|
3537
|
+
}
|
|
3538
|
+
const table = sections[tableIndex];
|
|
3539
|
+
const ownedLineIndexes = /* @__PURE__ */ new Set();
|
|
3540
|
+
for (const statement of readStatements(table.body)) {
|
|
3541
|
+
if (OWNED_TABLE_KEYS.includes(statement.key) == true) {
|
|
3542
|
+
for (let index = statement.start; index < statement.end; index += 1) {
|
|
3543
|
+
ownedLineIndexes.add(index);
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
const userLines = table.body.filter((_line, index) => ownedLineIndexes.has(index) == false);
|
|
3548
|
+
let trailingBlankCount = 0;
|
|
3549
|
+
while (trailingBlankCount < userLines.length && isBlank(userLines[userLines.length - 1 - trailingBlankCount]) == true) {
|
|
3550
|
+
trailingBlankCount += 1;
|
|
3551
|
+
}
|
|
3552
|
+
const trailingBlanks = userLines.slice(userLines.length - trailingBlankCount);
|
|
3553
|
+
const userContent = userLines.slice(0, userLines.length - trailingBlankCount);
|
|
3554
|
+
const rebuilt = [];
|
|
3555
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
3556
|
+
const section = sections[index];
|
|
3557
|
+
if (isOwn(section) == true && section.subPath === "env") {
|
|
3558
|
+
continue;
|
|
3559
|
+
}
|
|
3560
|
+
if (index !== tableIndex) {
|
|
3561
|
+
rebuilt.push(section);
|
|
3562
|
+
continue;
|
|
3563
|
+
}
|
|
3564
|
+
if (envLines.length === 0) {
|
|
3565
|
+
rebuilt.push({ ...section, body: [...renderOwnedLines(entry), ...userContent, ...trailingBlanks] });
|
|
3566
|
+
continue;
|
|
3567
|
+
}
|
|
3568
|
+
rebuilt.push({ ...section, body: [...renderOwnedLines(entry), ...userContent, ""] });
|
|
3569
|
+
rebuilt.push({
|
|
3570
|
+
header: `[mcp_servers.${tableKey}.env]`,
|
|
3571
|
+
serverName: name,
|
|
3572
|
+
subPath: "env",
|
|
3573
|
+
body: [...envLines, ...trailingBlanks]
|
|
3574
|
+
});
|
|
3575
|
+
}
|
|
3576
|
+
const rebuiltText = joinSections(rebuilt, eol);
|
|
3577
|
+
return rebuiltText;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
// src/modules/manage/targets/codex.ts
|
|
3581
|
+
var CODEX_CONFIG_PATH = path.join(os4.homedir(), ".codex", "config.toml");
|
|
3582
|
+
function createCodexTarget() {
|
|
3583
|
+
function readText() {
|
|
3584
|
+
if (fs7.existsSync(CODEX_CONFIG_PATH) == false) {
|
|
3585
|
+
return "";
|
|
3586
|
+
}
|
|
3587
|
+
return fs7.readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
3588
|
+
}
|
|
3589
|
+
function writeText(text) {
|
|
3590
|
+
fs7.mkdirSync(path.dirname(CODEX_CONFIG_PATH), { recursive: true });
|
|
3591
|
+
fs7.writeFileSync(CODEX_CONFIG_PATH, text, "utf8");
|
|
3592
|
+
return { status: "written", detail: CODEX_CONFIG_PATH };
|
|
3593
|
+
}
|
|
3594
|
+
return {
|
|
3595
|
+
id: "codex",
|
|
3596
|
+
label: "Codex CLI",
|
|
3597
|
+
location: CODEX_CONFIG_PATH,
|
|
3598
|
+
detected: findExecutable("codex") != null || fs7.existsSync(CODEX_CONFIG_PATH) == true,
|
|
3599
|
+
readEntries() {
|
|
3600
|
+
const text = readText();
|
|
3601
|
+
return parseMcpServersToml(text);
|
|
3602
|
+
},
|
|
3603
|
+
writeEntry(name, entry) {
|
|
3604
|
+
const text = readText();
|
|
3605
|
+
const updated = writeMcpServerToml(text, name, entry);
|
|
3606
|
+
return writeText(updated);
|
|
3607
|
+
},
|
|
3608
|
+
removeEntry(name) {
|
|
3609
|
+
const text = readText();
|
|
3610
|
+
const updated = writeMcpServerToml(text, name, null);
|
|
3611
|
+
return writeText(updated);
|
|
3612
|
+
}
|
|
3613
|
+
};
|
|
1982
3614
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
3615
|
+
|
|
3616
|
+
// src/modules/manage/targets/index.ts
|
|
3617
|
+
function claudeDesktopConfigPath() {
|
|
3618
|
+
if (process.platform === "win32") {
|
|
3619
|
+
const appData = process.env.APPDATA ?? path.join(os4.homedir(), "AppData", "Roaming");
|
|
3620
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
1987
3621
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
3622
|
+
if (process.platform === "darwin") {
|
|
3623
|
+
return path.join(os4.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1991
3624
|
}
|
|
1992
|
-
return
|
|
3625
|
+
return path.join(os4.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
1993
3626
|
}
|
|
1994
|
-
function
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
3627
|
+
function listConfigTargets() {
|
|
3628
|
+
const targets = [
|
|
3629
|
+
createClaudeCodeTarget(),
|
|
3630
|
+
createCodexTarget(),
|
|
3631
|
+
createJsonFileTarget({ id: "claude-desktop", label: "Claude Desktop", filePath: claudeDesktopConfigPath() }),
|
|
3632
|
+
createJsonFileTarget({ id: "cursor", label: "Cursor", filePath: path.join(os4.homedir(), ".cursor", "mcp.json") })
|
|
3633
|
+
];
|
|
3634
|
+
const detected = targets.filter((target) => target.detected == true);
|
|
3635
|
+
const others = targets.filter((target) => target.detected == false);
|
|
3636
|
+
return [...detected, ...others];
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
// src/modules/manage/index.ts
|
|
3640
|
+
var SECRET_ENV_NAMES = [
|
|
3641
|
+
SINGLE_HOST_ENV.PASSWORD,
|
|
3642
|
+
SINGLE_HOST_ENV.PASSPHRASE,
|
|
3643
|
+
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
3644
|
+
SINGLE_HOST_ENV.JUMP_PASSPHRASE
|
|
3645
|
+
];
|
|
3646
|
+
function errorMessage(error) {
|
|
3647
|
+
if (error instanceof Error) {
|
|
3648
|
+
return error.message;
|
|
1998
3649
|
}
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
3650
|
+
return String(error);
|
|
3651
|
+
}
|
|
3652
|
+
function maskSecrets(entry) {
|
|
3653
|
+
const env = {};
|
|
3654
|
+
for (const [name, value] of Object.entries(entry.env)) {
|
|
3655
|
+
if (SECRET_ENV_NAMES.includes(name) == true) {
|
|
3656
|
+
env[name] = "********";
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
env[name] = value;
|
|
2002
3660
|
}
|
|
2003
|
-
return
|
|
3661
|
+
return { ...entry, env };
|
|
2004
3662
|
}
|
|
2005
|
-
function
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
3663
|
+
function formatForTarget(target, name, entry) {
|
|
3664
|
+
if (target.id === "codex") {
|
|
3665
|
+
return renderMcpServerTable(name, entry);
|
|
3666
|
+
}
|
|
3667
|
+
return `"${name}": ${JSON.stringify(entry, null, 4)}
|
|
3668
|
+
`;
|
|
3669
|
+
}
|
|
3670
|
+
function indent(text) {
|
|
3671
|
+
return text.replace(/\n$/, "").split("\n").map((line) => ` ${line}`).join("\n");
|
|
3672
|
+
}
|
|
3673
|
+
function reportWrite(output, result, action) {
|
|
3674
|
+
if (result.status === "written") {
|
|
3675
|
+
output.write(` ${action} \u2014 ${result.detail}
|
|
3676
|
+
`);
|
|
3677
|
+
output.write(" Restart the client (or reconnect it from its MCP menu) to pick up the change.\n\n");
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
output.write(` Not ${action.toLowerCase()}: ${result.detail}
|
|
3681
|
+
|
|
3682
|
+
`);
|
|
3683
|
+
}
|
|
3684
|
+
function profileFromEntry(output, entry) {
|
|
3685
|
+
try {
|
|
3686
|
+
const profile = loadHostProfile(entry.env);
|
|
3687
|
+
if (profile == null) {
|
|
3688
|
+
output.write(" The entry has no host.\n");
|
|
3689
|
+
}
|
|
3690
|
+
return profile;
|
|
3691
|
+
} catch (ex) {
|
|
3692
|
+
output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
|
|
3693
|
+
`);
|
|
3694
|
+
return null;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
async function verifyAnswers(prompter, output, initial) {
|
|
3698
|
+
let answers = initial;
|
|
3699
|
+
for (; ; ) {
|
|
3700
|
+
const testNow = await prompter.askYesNo("Test the connection now?", true);
|
|
3701
|
+
if (testNow == false) {
|
|
3702
|
+
return answers;
|
|
3703
|
+
}
|
|
3704
|
+
output.write("\n");
|
|
3705
|
+
const profile = profileFromEntry(output, answersToEntry(answers));
|
|
3706
|
+
if (profile != null) {
|
|
3707
|
+
const result = await runConnectionCheck(profile, output);
|
|
3708
|
+
output.write("\n");
|
|
3709
|
+
if (result.connected == true) {
|
|
3710
|
+
if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
|
|
3711
|
+
const pin = await prompter.askYesNo(`Pin the host key ${result.hostKeyFingerprint}? (a changed key then aborts the connection)`, true);
|
|
3712
|
+
if (pin == true) {
|
|
3713
|
+
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
|
|
3717
|
+
const pin = await prompter.askYesNo(`Pin the jump host key ${result.jumpHostKeyFingerprint}?`, true);
|
|
3718
|
+
if (pin == true) {
|
|
3719
|
+
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
return answers;
|
|
3723
|
+
}
|
|
3724
|
+
const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
|
|
3725
|
+
if (hostKeyChanged == true) {
|
|
3726
|
+
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3727
|
+
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
3728
|
+
if (repin == true) {
|
|
3729
|
+
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
|
|
3730
|
+
continue;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
|
|
3734
|
+
if (jumpKeyChanged == true) {
|
|
3735
|
+
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3736
|
+
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
3737
|
+
if (repin == true) {
|
|
3738
|
+
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
|
|
3739
|
+
continue;
|
|
3740
|
+
}
|
|
2015
3741
|
}
|
|
2016
|
-
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
2017
3742
|
}
|
|
2018
|
-
|
|
3743
|
+
const next = await prompter.askChoice(
|
|
3744
|
+
"What now?",
|
|
3745
|
+
[
|
|
3746
|
+
{ key: "r", label: "retry the check" },
|
|
3747
|
+
{ key: "e", label: "edit the answers" },
|
|
3748
|
+
{ key: "w", label: "write it anyway" },
|
|
3749
|
+
{ key: "c", label: "cancel" }
|
|
3750
|
+
],
|
|
3751
|
+
"e"
|
|
3752
|
+
);
|
|
3753
|
+
if (next === "c") {
|
|
3754
|
+
return null;
|
|
3755
|
+
}
|
|
3756
|
+
if (next === "w") {
|
|
3757
|
+
return answers;
|
|
3758
|
+
}
|
|
3759
|
+
if (next === "e") {
|
|
3760
|
+
output.write("\n");
|
|
3761
|
+
answers = await askHostQuestions(prompter, answers);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
2019
3764
|
}
|
|
2020
|
-
function
|
|
2021
|
-
|
|
2022
|
-
if (
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
3765
|
+
async function addOrEditHost(prompter, output, target, entries, existingName) {
|
|
3766
|
+
let existing;
|
|
3767
|
+
if (existingName != null) {
|
|
3768
|
+
existing = entryToAnswers(existingName, entries[existingName]);
|
|
3769
|
+
output.write(`
|
|
3770
|
+
Editing ${existingName}
|
|
3771
|
+
`);
|
|
3772
|
+
} else {
|
|
3773
|
+
output.write("\nAdding a host\n");
|
|
3774
|
+
}
|
|
3775
|
+
const asked = await askHostQuestions(prompter, existing);
|
|
3776
|
+
output.write("\n");
|
|
3777
|
+
const answers = await verifyAnswers(prompter, output, asked);
|
|
3778
|
+
if (answers == null) {
|
|
3779
|
+
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
if (answers.name !== existingName && entries[answers.name] != null) {
|
|
3783
|
+
const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
|
|
3784
|
+
if (replace == false) {
|
|
3785
|
+
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
3786
|
+
return;
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
const entry = answersToEntry(answers);
|
|
3790
|
+
output.write(`
|
|
3791
|
+
This goes into ${target.location}:
|
|
3792
|
+
|
|
3793
|
+
`);
|
|
3794
|
+
output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
|
|
3795
|
+
|
|
3796
|
+
`);
|
|
3797
|
+
const confirmed = await prompter.askYesNo("Write it?", true);
|
|
3798
|
+
if (confirmed == false) {
|
|
3799
|
+
output.write(" Not written. The print view (p) gives the same entry ready to paste.\n\n");
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
const written = target.writeEntry(answers.name, entry);
|
|
3803
|
+
reportWrite(output, written, "Written");
|
|
3804
|
+
if (written.status === "written" && existingName != null && existingName !== answers.name) {
|
|
3805
|
+
const removed = target.removeEntry(existingName);
|
|
3806
|
+
if (removed.status === "unavailable") {
|
|
3807
|
+
output.write(` The old name ${existingName} is still there: ${removed.detail}
|
|
3808
|
+
|
|
3809
|
+
`);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
async function checkHost(prompter, output, target, name, entry) {
|
|
3814
|
+
output.write("\n");
|
|
3815
|
+
const profile = profileFromEntry(output, entry);
|
|
3816
|
+
if (profile == null) {
|
|
3817
|
+
output.write("\n");
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
const result = await runConnectionCheck(profile, output);
|
|
3821
|
+
output.write("\n");
|
|
3822
|
+
if (result.connected == false) {
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
const env = { ...entry.env };
|
|
3826
|
+
let changed = false;
|
|
3827
|
+
if (result.hostKeyFingerprint != null && env[SINGLE_HOST_ENV.HOST_KEY] == null) {
|
|
3828
|
+
const pin = await prompter.askYesNo(`Pin the host key ${result.hostKeyFingerprint}?`, true);
|
|
3829
|
+
if (pin == true) {
|
|
3830
|
+
env[SINGLE_HOST_ENV.HOST_KEY] = result.hostKeyFingerprint;
|
|
3831
|
+
changed = true;
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
if (result.jumpHostKeyFingerprint != null && env[SINGLE_HOST_ENV.JUMP_HOST_KEY] == null) {
|
|
3835
|
+
const pin = await prompter.askYesNo(`Pin the jump host key ${result.jumpHostKeyFingerprint}?`, true);
|
|
3836
|
+
if (pin == true) {
|
|
3837
|
+
env[SINGLE_HOST_ENV.JUMP_HOST_KEY] = result.jumpHostKeyFingerprint;
|
|
3838
|
+
changed = true;
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
if (changed == true) {
|
|
3842
|
+
const written = target.writeEntry(name, { ...entry, env });
|
|
3843
|
+
reportWrite(output, written, "Pinned");
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
function printHost(output, name, entry) {
|
|
3847
|
+
output.write('\n JSON \u2014 Claude Desktop, Cursor, .mcp.json (inside "mcpServers"):\n\n');
|
|
3848
|
+
output.write(`${indent(`"${name}": ${JSON.stringify(entry, null, 4)}`)}
|
|
3849
|
+
|
|
3850
|
+
`);
|
|
3851
|
+
output.write(" TOML \u2014 Codex (~/.codex/config.toml):\n\n");
|
|
3852
|
+
output.write(`${indent(renderMcpServerTable(name, entry))}
|
|
3853
|
+
|
|
3854
|
+
`);
|
|
3855
|
+
output.write(" Claude Code CLI (bash / zsh / Git Bash):\n\n");
|
|
3856
|
+
output.write(`${indent(formatClaudeAddCommand(name, entry))}
|
|
3857
|
+
|
|
3858
|
+
`);
|
|
3859
|
+
}
|
|
3860
|
+
async function pickHost(prompter, output, names) {
|
|
3861
|
+
if (names.length === 0) {
|
|
3862
|
+
output.write(" No hosts yet \u2014 add one with a.\n\n");
|
|
2027
3863
|
return null;
|
|
2028
3864
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
3865
|
+
if (names.length === 1) {
|
|
3866
|
+
return names[0];
|
|
2032
3867
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
3868
|
+
for (; ; ) {
|
|
3869
|
+
const answer = await prompter.ask("Which host (number or name, empty to go back)");
|
|
3870
|
+
if (answer === "") {
|
|
3871
|
+
return null;
|
|
3872
|
+
}
|
|
3873
|
+
const index = Number(answer);
|
|
3874
|
+
if (Number.isInteger(index) == true && index >= 1 && index <= names.length) {
|
|
3875
|
+
return names[index - 1];
|
|
3876
|
+
}
|
|
3877
|
+
if (names.includes(answer) == true) {
|
|
3878
|
+
return answer;
|
|
3879
|
+
}
|
|
3880
|
+
output.write(` No host "${answer}".
|
|
3881
|
+
`);
|
|
2038
3882
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
policy
|
|
2061
|
-
};
|
|
2062
|
-
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
2063
|
-
if (keyPath != null) {
|
|
2064
|
-
const expandedKeyPath = expandHomePath(keyPath);
|
|
2065
|
-
profile.privateKeyPath = expandedKeyPath;
|
|
2066
|
-
if (fs.existsSync(expandedKeyPath) == false) {
|
|
2067
|
-
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
2068
|
-
private_key_path: expandedKeyPath
|
|
2069
|
-
});
|
|
3883
|
+
}
|
|
3884
|
+
function renderHostList(output, target, entries) {
|
|
3885
|
+
const names = Object.keys(entries).filter((name) => isOurEntry(entries[name]) == true);
|
|
3886
|
+
const otherCount = Object.keys(entries).length - names.length;
|
|
3887
|
+
output.write(`
|
|
3888
|
+
${target.label} \u2014 ${target.location}
|
|
3889
|
+
|
|
3890
|
+
`);
|
|
3891
|
+
if (names.length === 0) {
|
|
3892
|
+
output.write(" No SSH hosts registered here yet.\n");
|
|
3893
|
+
} else {
|
|
3894
|
+
const rows = names.map((name, index) => {
|
|
3895
|
+
const summary = summarizeEntry(entries[name]);
|
|
3896
|
+
return [String(index + 1), name, summary.endpoint, summary.auth, summary.notes];
|
|
3897
|
+
});
|
|
3898
|
+
const header = ["#", "Server", "Endpoint", "Auth", "Notes"];
|
|
3899
|
+
const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column].length)));
|
|
3900
|
+
for (const row of [header, ...rows]) {
|
|
3901
|
+
const line = row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
|
|
3902
|
+
output.write(` ${line}
|
|
3903
|
+
`);
|
|
2070
3904
|
}
|
|
2071
3905
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
return
|
|
3906
|
+
if (otherCount > 0) {
|
|
3907
|
+
output.write(`
|
|
3908
|
+
${otherCount} other MCP server(s) in this file are not managed here.
|
|
3909
|
+
`);
|
|
3910
|
+
}
|
|
3911
|
+
output.write("\n");
|
|
3912
|
+
return names;
|
|
2079
3913
|
}
|
|
2080
|
-
function
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
3914
|
+
async function runConfigCommand(options) {
|
|
3915
|
+
const { input, output } = options;
|
|
3916
|
+
const targets = options.targets ?? listConfigTargets();
|
|
3917
|
+
let target = targets[0];
|
|
3918
|
+
if (options.targetId != null) {
|
|
3919
|
+
const requested = targets.find((candidate) => candidate.id === options.targetId);
|
|
3920
|
+
if (requested == null) {
|
|
3921
|
+
output.write(`Unknown client "${options.targetId}". One of: ${targets.map((candidate) => candidate.id).join(", ")}
|
|
3922
|
+
`);
|
|
3923
|
+
return 1;
|
|
3924
|
+
}
|
|
3925
|
+
target = requested;
|
|
3926
|
+
}
|
|
3927
|
+
const prompter = new Prompter(input, output);
|
|
3928
|
+
try {
|
|
3929
|
+
for (; ; ) {
|
|
3930
|
+
let entries = {};
|
|
3931
|
+
let names = [];
|
|
3932
|
+
let readable = true;
|
|
3933
|
+
try {
|
|
3934
|
+
entries = target.readEntries();
|
|
3935
|
+
names = renderHostList(output, target, entries);
|
|
3936
|
+
} catch (ex) {
|
|
3937
|
+
readable = false;
|
|
3938
|
+
output.write(`
|
|
3939
|
+
${target.label} \u2014 ${target.location}
|
|
3940
|
+
|
|
3941
|
+
Cannot read it: ${errorMessage(ex)}
|
|
3942
|
+
Fix the file by hand, or switch to another client.
|
|
3943
|
+
|
|
3944
|
+
`);
|
|
3945
|
+
}
|
|
3946
|
+
let menu = " t) switch client q) quit";
|
|
3947
|
+
if (readable == true && names.length > 0) {
|
|
3948
|
+
menu = " a) add e) edit r) remove c) check p) print t) switch client q) quit";
|
|
3949
|
+
} else if (readable == true) {
|
|
3950
|
+
menu = " a) add t) switch client q) quit";
|
|
3951
|
+
}
|
|
3952
|
+
output.write(`${menu}
|
|
3953
|
+
`);
|
|
3954
|
+
const action = (await prompter.ask("Choice")).toLowerCase();
|
|
3955
|
+
if (action === "q" || action === "quit") {
|
|
3956
|
+
return 0;
|
|
3957
|
+
}
|
|
3958
|
+
if (action === "t") {
|
|
3959
|
+
const picked = await prompter.askChoice(
|
|
3960
|
+
"Which client?",
|
|
3961
|
+
targets.map((candidate) => {
|
|
3962
|
+
let mark = "";
|
|
3963
|
+
if (candidate.detected == true) {
|
|
3964
|
+
mark = " (detected)";
|
|
3965
|
+
}
|
|
3966
|
+
return { key: candidate.id, label: `${candidate.label} \u2014 ${candidate.location}${mark}` };
|
|
3967
|
+
}),
|
|
3968
|
+
target.id
|
|
3969
|
+
);
|
|
3970
|
+
target = targets.find((candidate) => candidate.id === picked) ?? target;
|
|
3971
|
+
continue;
|
|
3972
|
+
}
|
|
3973
|
+
if (readable == false) {
|
|
3974
|
+
output.write(" Not available while the file cannot be read.\n");
|
|
3975
|
+
continue;
|
|
3976
|
+
}
|
|
3977
|
+
try {
|
|
3978
|
+
if (action === "a") {
|
|
3979
|
+
await addOrEditHost(prompter, output, target, entries);
|
|
3980
|
+
continue;
|
|
3981
|
+
}
|
|
3982
|
+
if (action === "e" || action === "r" || action === "c" || action === "p") {
|
|
3983
|
+
const name = await pickHost(prompter, output, names);
|
|
3984
|
+
if (name == null) {
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
if (action === "e") {
|
|
3988
|
+
await addOrEditHost(prompter, output, target, entries, name);
|
|
3989
|
+
} else if (action === "r") {
|
|
3990
|
+
const remove = await prompter.askYesNo(`Remove ${name} from ${target.location}?`, false);
|
|
3991
|
+
if (remove == true) {
|
|
3992
|
+
reportWrite(output, target.removeEntry(name), "Removed");
|
|
3993
|
+
}
|
|
3994
|
+
} else if (action === "c") {
|
|
3995
|
+
await checkHost(prompter, output, target, name, entries[name]);
|
|
3996
|
+
} else {
|
|
3997
|
+
printHost(output, name, entries[name]);
|
|
3998
|
+
}
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
output.write(` Unknown choice "${action}".
|
|
4002
|
+
`);
|
|
4003
|
+
} catch (ex) {
|
|
4004
|
+
if (ex instanceof PromptClosedError) {
|
|
4005
|
+
throw ex;
|
|
4006
|
+
}
|
|
4007
|
+
output.write(` Failed: ${errorMessage(ex)}
|
|
4008
|
+
|
|
4009
|
+
`);
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
} catch (ex) {
|
|
4013
|
+
if (ex instanceof PromptClosedError) {
|
|
4014
|
+
output.write("\n");
|
|
4015
|
+
return 0;
|
|
4016
|
+
}
|
|
4017
|
+
throw ex;
|
|
4018
|
+
} finally {
|
|
4019
|
+
prompter.close();
|
|
2086
4020
|
}
|
|
2087
|
-
return {
|
|
2088
|
-
name: profile.name,
|
|
2089
|
-
host: profile.host,
|
|
2090
|
-
port: profile.port,
|
|
2091
|
-
username: profile.username,
|
|
2092
|
-
description: profile.description,
|
|
2093
|
-
authMethod,
|
|
2094
|
-
readonly: profile.policy.readonly,
|
|
2095
|
-
allowSudo: profile.policy.allowSudo,
|
|
2096
|
-
allowedPaths: profile.policy.allowedPaths,
|
|
2097
|
-
allowCommands: profile.policy.allowCommands
|
|
2098
|
-
};
|
|
2099
4021
|
}
|
|
2100
4022
|
|
|
2101
4023
|
// src/modules/tools/format.ts
|
|
@@ -2136,6 +4058,16 @@ function formatHostProfile(profile) {
|
|
|
2136
4058
|
if (summary.description != null) {
|
|
2137
4059
|
lines.push(` ${summary.description}`);
|
|
2138
4060
|
}
|
|
4061
|
+
const routeParts = [];
|
|
4062
|
+
if (summary.viaProxyCommand == true) {
|
|
4063
|
+
routeParts.push("proxy command");
|
|
4064
|
+
}
|
|
4065
|
+
if (summary.jump != null) {
|
|
4066
|
+
routeParts.push(`jump host ${summary.jump.username}@${summary.jump.host}:${summary.jump.port}`);
|
|
4067
|
+
}
|
|
4068
|
+
if (routeParts.length > 0) {
|
|
4069
|
+
lines.push(` via ${routeParts.join(" \u2192 ")}`);
|
|
4070
|
+
}
|
|
2139
4071
|
if (summary.allowedPaths.length > 0) {
|
|
2140
4072
|
lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
|
|
2141
4073
|
}
|
|
@@ -2516,7 +4448,7 @@ ${file.content}`);
|
|
|
2516
4448
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2517
4449
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2518
4450
|
requirePermittedLocalPath(resolvedLocalPath, "read");
|
|
2519
|
-
if (
|
|
4451
|
+
if (fs7.existsSync(resolvedLocalPath) == false) {
|
|
2520
4452
|
return textResult(`local file not found: ${resolvedLocalPath}`);
|
|
2521
4453
|
}
|
|
2522
4454
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
@@ -2556,7 +4488,7 @@ ${file.content}`);
|
|
|
2556
4488
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2557
4489
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2558
4490
|
requirePermittedLocalPath(resolvedLocalPath, "write");
|
|
2559
|
-
|
|
4491
|
+
fs7.mkdirSync(path.dirname(resolvedLocalPath), { recursive: true });
|
|
2560
4492
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
2561
4493
|
return await downloadFile(connection, remotePath, resolvedLocalPath);
|
|
2562
4494
|
});
|
|
@@ -2780,8 +4712,23 @@ function createSshMcpServer() {
|
|
|
2780
4712
|
|
|
2781
4713
|
// src/cli.ts
|
|
2782
4714
|
function parseArguments(argv) {
|
|
2783
|
-
const parsed = { showHelp: false, showVersion: false, checkOnly: false };
|
|
2784
|
-
|
|
4715
|
+
const parsed = { showHelp: false, showVersion: false, checkOnly: false, configMode: false };
|
|
4716
|
+
let rest = argv;
|
|
4717
|
+
if (argv[0] === "config") {
|
|
4718
|
+
parsed.configMode = true;
|
|
4719
|
+
rest = argv.slice(1);
|
|
4720
|
+
}
|
|
4721
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
4722
|
+
const argument = rest[index];
|
|
4723
|
+
if (argument === "--target") {
|
|
4724
|
+
parsed.targetId = rest[index + 1];
|
|
4725
|
+
index += 1;
|
|
4726
|
+
continue;
|
|
4727
|
+
}
|
|
4728
|
+
if (argument.startsWith("--target=") == true) {
|
|
4729
|
+
parsed.targetId = argument.slice("--target=".length);
|
|
4730
|
+
continue;
|
|
4731
|
+
}
|
|
2785
4732
|
if (argument === "--help" || argument === "-h") {
|
|
2786
4733
|
parsed.showHelp = true;
|
|
2787
4734
|
continue;
|
|
@@ -2802,10 +4749,18 @@ var HELP_TEXT = [
|
|
|
2802
4749
|
"",
|
|
2803
4750
|
"Usage:",
|
|
2804
4751
|
" akms-mcp-ssh [--check]",
|
|
4752
|
+
" akms-mcp-ssh config [--target <client>]",
|
|
4753
|
+
"",
|
|
4754
|
+
"Commands:",
|
|
4755
|
+
" config Manage the SSH hosts registered in an MCP client's config \u2014",
|
|
4756
|
+
" list, add, edit, remove, check, print. Asks plain questions,",
|
|
4757
|
+
" so the SSH_* names below never have to be typed.",
|
|
2805
4758
|
"",
|
|
2806
4759
|
"Options:",
|
|
2807
4760
|
" --check Connect to the configured host, report the result, and exit.",
|
|
2808
4761
|
" Prints the host key fingerprint so it can be pinned.",
|
|
4762
|
+
" --target <id> config: claude-code | codex | claude-desktop | cursor",
|
|
4763
|
+
" (default: the first one installed)",
|
|
2809
4764
|
" -v, --version Print the version and exit",
|
|
2810
4765
|
" -h, --help Print this help and exit",
|
|
2811
4766
|
"",
|
|
@@ -2820,6 +4775,11 @@ var HELP_TEXT = [
|
|
|
2820
4775
|
` ${SINGLE_HOST_ENV.NAME} Alias shown to the model; defaults to the hostname`,
|
|
2821
4776
|
` ${SINGLE_HOST_ENV.DESCRIPTION} Shown to the model \u2014 say what the host is for`,
|
|
2822
4777
|
` ${SINGLE_HOST_ENV.CWD} Directory new sessions start in`,
|
|
4778
|
+
` ${SINGLE_HOST_ENV.PROXY_COMMAND} Command carrying the SSH stream on its stdin/stdout (OpenSSH ProxyCommand);`,
|
|
4779
|
+
" %h / %p / %r expand to the first hop's host, port and user",
|
|
4780
|
+
` ${SINGLE_HOST_ENV.JUMP_HOST} Jump host the target is reached through (OpenSSH ProxyJump / -J)`,
|
|
4781
|
+
` ${SINGLE_HOST_ENV.JUMP_PORT} / ${SINGLE_HOST_ENV.JUMP_USER} / ${SINGLE_HOST_ENV.JUMP_KEY} / ${SINGLE_HOST_ENV.JUMP_PASSPHRASE} / ${SINGLE_HOST_ENV.JUMP_PASSWORD} / ${SINGLE_HOST_ENV.JUMP_HOST_KEY}`,
|
|
4782
|
+
` Jump host connection; user and key default to ${SINGLE_HOST_ENV.USER} / ${SINGLE_HOST_ENV.KEY}`,
|
|
2823
4783
|
` ${SINGLE_HOST_ENV.READONLY} Reject write commands, redirections, uploads (default false)`,
|
|
2824
4784
|
` ${SINGLE_HOST_ENV.ALLOW_SUDO} Allow sudo / su / doas (default true)`,
|
|
2825
4785
|
` ${SINGLE_HOST_ENV.ALLOW_COMMANDS} Comma-separated allow-list of binaries`,
|
|
@@ -2831,60 +4791,14 @@ var HELP_TEXT = [
|
|
|
2831
4791
|
"",
|
|
2832
4792
|
"The server speaks MCP over stdio; run it from an MCP client, not interactively."
|
|
2833
4793
|
].join("\n");
|
|
2834
|
-
function
|
|
2835
|
-
if (profile.privateKeyPath != null) {
|
|
2836
|
-
return `key ${profile.privateKeyPath}`;
|
|
2837
|
-
}
|
|
2838
|
-
if (profile.password != null) {
|
|
2839
|
-
return "password";
|
|
2840
|
-
}
|
|
2841
|
-
return "ssh agent";
|
|
2842
|
-
}
|
|
2843
|
-
async function runConnectionCheck(profile) {
|
|
4794
|
+
async function runCheckCommand(profile) {
|
|
2844
4795
|
if (profile == null) {
|
|
2845
|
-
process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}
|
|
4796
|
+
process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh config
|
|
2846
4797
|
`);
|
|
2847
4798
|
return 1;
|
|
2848
4799
|
}
|
|
2849
|
-
const
|
|
2850
|
-
|
|
2851
|
-
process.stdout.write(`Checking ${profile.name} \u2192 ${endpoint}
|
|
2852
|
-
|
|
2853
|
-
`);
|
|
2854
|
-
const connection = new SshConnection(profile);
|
|
2855
|
-
const startedAt = Date.now();
|
|
2856
|
-
let connected = false;
|
|
2857
|
-
let detail = "";
|
|
2858
|
-
try {
|
|
2859
|
-
await connection.connect();
|
|
2860
|
-
connected = true;
|
|
2861
|
-
} catch (ex) {
|
|
2862
|
-
detail = String(ex);
|
|
2863
|
-
if (ex instanceof Error) {
|
|
2864
|
-
detail = ex.message;
|
|
2865
|
-
}
|
|
2866
|
-
} finally {
|
|
2867
|
-
connection.disconnect();
|
|
2868
|
-
}
|
|
2869
|
-
const durationMs = Date.now() - startedAt;
|
|
2870
|
-
if (connected == true) {
|
|
2871
|
-
process.stdout.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
|
|
2872
|
-
`);
|
|
2873
|
-
} else {
|
|
2874
|
-
process.stdout.write(` FAILED ${endpoint} (${auth})
|
|
2875
|
-
`);
|
|
2876
|
-
process.stdout.write(` ${detail}
|
|
2877
|
-
`);
|
|
2878
|
-
}
|
|
2879
|
-
if (connection.hostKeyFingerprint != null) {
|
|
2880
|
-
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
|
|
2881
|
-
if (profile.hostKeyFingerprint != null) {
|
|
2882
|
-
pinNote = "pinned";
|
|
2883
|
-
}
|
|
2884
|
-
process.stdout.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
|
|
2885
|
-
`);
|
|
2886
|
-
}
|
|
2887
|
-
if (connected == true) {
|
|
4800
|
+
const result = await runConnectionCheck(profile, process.stdout);
|
|
4801
|
+
if (result.connected == true) {
|
|
2888
4802
|
return 0;
|
|
2889
4803
|
}
|
|
2890
4804
|
return 1;
|
|
@@ -2902,12 +4816,23 @@ async function main() {
|
|
|
2902
4816
|
`);
|
|
2903
4817
|
return;
|
|
2904
4818
|
}
|
|
4819
|
+
if (cliArguments.configMode == true) {
|
|
4820
|
+
if (process.env[LOG_LEVEL_ENV] == null) {
|
|
4821
|
+
process.env[LOG_LEVEL_ENV] = "warn";
|
|
4822
|
+
}
|
|
4823
|
+
const exitCode = await runConfigCommand({
|
|
4824
|
+
input: process.stdin,
|
|
4825
|
+
output: process.stdout,
|
|
4826
|
+
targetId: cliArguments.targetId
|
|
4827
|
+
});
|
|
4828
|
+
process.exit(exitCode);
|
|
4829
|
+
}
|
|
2905
4830
|
if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
|
|
2906
|
-
process.env[LOG_LEVEL_ENV] = "
|
|
4831
|
+
process.env[LOG_LEVEL_ENV] = "warn";
|
|
2907
4832
|
}
|
|
2908
4833
|
const instance = createSshMcpServer();
|
|
2909
4834
|
if (cliArguments.checkOnly == true) {
|
|
2910
|
-
const exitCode = await
|
|
4835
|
+
const exitCode = await runCheckCommand(instance.profile);
|
|
2911
4836
|
instance.shutdown();
|
|
2912
4837
|
process.exit(exitCode);
|
|
2913
4838
|
}
|