@linxin666/dsh-ssh 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
- import { closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
1
+ import { closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { Client } from "ssh2";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
6
+ import { spawn } from "node:child_process";
7
+ import { Duplex } from "node:stream";
6
8
  import { createServer } from "node:net";
7
9
  import { unlink } from "node:fs/promises";
8
10
  import { randomBytes } from "node:crypto";
@@ -1058,6 +1060,136 @@ function dshHome() {
1058
1060
  return resolveDshHome();
1059
1061
  }
1060
1062
  //#endregion
1063
+ //#region src/ssh-config.ts
1064
+ /**
1065
+ * Minimal `~/.ssh/config` reader for the one-shot host import: Host blocks with
1066
+ * `Include` expansion and explicit skip reasons. Values stay raw strings; the
1067
+ * store maps them onto a HostPayload.
1068
+ *
1069
+ * Deliberate simplifications (documented in the package README):
1070
+ * - `Match` blocks are skipped — their options are conditional and must never
1071
+ * merge into the Host block above them.
1072
+ * - A multi-pattern `Host a b` line only exposes its first pattern.
1073
+ * - Include pathnames accept `~`, globs, and several whitespace-separated
1074
+ * pathnames, but not environment variables, `%` tokens, or quoting.
1075
+ * - Relative Include pathnames resolve against the directory of the config
1076
+ * file the import started from (OpenSSH: `~/.ssh` for a user config).
1077
+ * - A file that already contributed lines is never read twice, which doubles
1078
+ * as the Include cycle guard.
1079
+ */
1080
+ /** Include recursion guard: a cycle or a runaway chain stops here. */
1081
+ const MAX_INCLUDE_DEPTH = 16;
1082
+ /** The only line shape ssh_config knows: `Keyword value...` (case-insensitive). */
1083
+ const LINE_RE = /^([A-Za-z0-9_-]+)\s+(.+)$/;
1084
+ /** Expand a leading `~` the way OpenSSH does for user configuration files. */
1085
+ function expandTilde(path) {
1086
+ if (path === "~") return homedir();
1087
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
1088
+ return path;
1089
+ }
1090
+ /** Turn one glob segment into a matcher (`*` and `?` only). */
1091
+ function segmentMatcher(segment) {
1092
+ const escaped = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1093
+ return new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
1094
+ }
1095
+ /**
1096
+ * Expand a segment list against the filesystem. Wildcards match whole segments
1097
+ * only; matches are sorted so a glob import stays deterministic.
1098
+ */
1099
+ function globSegments(prefix, segments) {
1100
+ const [head, ...rest] = segments;
1101
+ if (head === void 0) return [prefix];
1102
+ if (!/[*?]/.test(head)) return globSegments(join(prefix, head), rest);
1103
+ let names;
1104
+ try {
1105
+ names = readdirSync(prefix);
1106
+ } catch {
1107
+ return [];
1108
+ }
1109
+ const matcher = segmentMatcher(head);
1110
+ return names.filter((name) => matcher.test(name)).sort().flatMap((name) => globSegments(join(prefix, name), rest));
1111
+ }
1112
+ /** Resolve one Include pathname into the existing regular files it names. */
1113
+ function resolveInclude(spec, baseDir) {
1114
+ const expanded = expandTilde(spec);
1115
+ const full = isAbsolute(expanded) ? expanded : join(baseDir, expanded);
1116
+ if (!/[*?]/.test(full)) return existsSync(full) ? [full] : [];
1117
+ const root = /^([A-Za-z]:[\\/]|[\\/])/.exec(full)?.[1] ?? "";
1118
+ const segments = full.slice(root.length).split(/[\\/]+/).filter((part) => part !== "");
1119
+ return globSegments(root === "" ? "." : root, segments).filter((path) => {
1120
+ try {
1121
+ return statSync(path).isFile();
1122
+ } catch {
1123
+ return false;
1124
+ }
1125
+ });
1126
+ }
1127
+ /** Read one file into lines, splicing every `Include` in place. */
1128
+ function readLines(file, baseDir, visited, depth) {
1129
+ if (depth > MAX_INCLUDE_DEPTH) return [];
1130
+ let key;
1131
+ try {
1132
+ key = realpathSync(file);
1133
+ } catch {
1134
+ return [];
1135
+ }
1136
+ if (visited.has(key)) return [];
1137
+ visited.add(key);
1138
+ let text;
1139
+ try {
1140
+ text = readFileSync(key, "utf8");
1141
+ } catch {
1142
+ return [];
1143
+ }
1144
+ const out = [];
1145
+ for (const raw of text.split(/\r?\n/)) {
1146
+ const match = LINE_RE.exec(raw.trim());
1147
+ if (match === null || match[1].toLowerCase() !== "include") {
1148
+ out.push(raw);
1149
+ continue;
1150
+ }
1151
+ for (const spec of match[2].trim().split(/\s+/).filter((part) => part !== "")) for (const included of resolveInclude(spec, baseDir)) out.push(...readLines(included, baseDir, visited, depth + 1));
1152
+ }
1153
+ return out;
1154
+ }
1155
+ /**
1156
+ * Parse one ssh_config file (Include expanded) into Host blocks plus the
1157
+ * `Match` blocks that were deliberately skipped.
1158
+ */
1159
+ function readSshConfigBlocks(configPath) {
1160
+ const blocks = [];
1161
+ const skipped = [];
1162
+ const lines = readLines(configPath, dirname(configPath), /* @__PURE__ */ new Set(), 0);
1163
+ let current;
1164
+ for (const raw of lines) {
1165
+ const match = LINE_RE.exec(raw.trim());
1166
+ if (match === null) continue;
1167
+ const key = match[1].toLowerCase();
1168
+ const value = match[2].trim();
1169
+ if (key === "host") {
1170
+ current = {
1171
+ pattern: value,
1172
+ props: {}
1173
+ };
1174
+ blocks.push(current);
1175
+ continue;
1176
+ }
1177
+ if (key === "match") {
1178
+ skipped.push({
1179
+ name: "Match " + value,
1180
+ reason: "match"
1181
+ });
1182
+ current = void 0;
1183
+ continue;
1184
+ }
1185
+ if (current !== void 0) current.props[key] = value;
1186
+ }
1187
+ return {
1188
+ blocks,
1189
+ skipped
1190
+ };
1191
+ }
1192
+ //#endregion
1061
1193
  //#region src/store.ts
1062
1194
  /**
1063
1195
  * Host config store: one JSON file (`$DSH_HOME/dsh-ssh.json`, defaulting
@@ -1093,6 +1225,10 @@ function validateHostPayload(payload) {
1093
1225
  }
1094
1226
  if (p.port !== void 0 && (typeof p.port !== "number" || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) return "port must be an integer in 1..65535";
1095
1227
  if (p.proxyJump !== void 0 && (!Array.isArray(p.proxyJump) || p.proxyJump.some((x) => typeof x !== "string" || x === ""))) return "proxyJump must be an array of alias strings";
1228
+ if (p.proxyCommand !== void 0 && typeof p.proxyCommand !== "string") return "proxyCommand must be a string when provided";
1229
+ const proxyCommand = typeof p.proxyCommand === "string" ? normalizeProxyCommand(p.proxyCommand) : void 0;
1230
+ const proxyJump = Array.isArray(p.proxyJump) ? p.proxyJump : [];
1231
+ if (proxyCommand !== void 0 && proxyJump.length > 0) return "proxyCommand and proxyJump cannot be combined — pick one transport per host";
1096
1232
  if (p.tags !== void 0 && (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string"))) return "tags must be an array of strings";
1097
1233
  }
1098
1234
  /** Alias grammar: letters/digits plus dots, hyphens, underscores (IP/domain aliases included). */
@@ -1138,6 +1274,7 @@ var HostStore = class {
1138
1274
  auth: entry.auth.kind,
1139
1275
  keyReady,
1140
1276
  proxyJump: [...entry.proxyJump],
1277
+ ...entry.proxyCommand !== void 0 ? { proxyCommand: entry.proxyCommand } : {},
1141
1278
  ...entry.description !== void 0 ? { description: entry.description } : {},
1142
1279
  ...entry.environment !== void 0 ? { environment: entry.environment } : {},
1143
1280
  tags: [...entry.tags],
@@ -1171,6 +1308,7 @@ var HostStore = class {
1171
1308
  agentPath: payload.auth.kind === "agent" ? normalizeAgentPath(payload.auth.agentPath) : void 0
1172
1309
  },
1173
1310
  proxyJump: [...payload.proxyJump ?? []],
1311
+ proxyCommand: normalizeProxyCommand(payload.proxyCommand),
1174
1312
  description: payload.description?.trim() || void 0,
1175
1313
  environment: payload.environment?.trim() || void 0,
1176
1314
  tags: [...payload.tags ?? []].map((tag) => tag.trim()).filter((tag) => tag !== ""),
@@ -1191,6 +1329,9 @@ var HostStore = class {
1191
1329
  if (patch.user !== void 0 && (typeof patch.user !== "string" || patch.user.trim() === "")) throw new Error("user is required");
1192
1330
  if (patch.port !== void 0 && (typeof patch.port !== "number" || !Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) throw new Error("port must be an integer in 1..65535");
1193
1331
  if (patch.proxyJump !== void 0 && (!Array.isArray(patch.proxyJump) || patch.proxyJump.some((x) => typeof x !== "string" || x === ""))) throw new Error("proxyJump must be an array of alias strings");
1332
+ if (patch.proxyCommand !== void 0 && typeof patch.proxyCommand !== "string") throw new Error("proxyCommand must be a string when provided");
1333
+ const mergedProxyCommand = patch.proxyCommand !== void 0 ? normalizeProxyCommand(patch.proxyCommand) : entry.proxyCommand;
1334
+ if (mergedProxyCommand !== void 0 && (patch.proxyJump ?? entry.proxyJump).length > 0) throw new Error("proxyCommand and proxyJump cannot be combined — pick one transport per host");
1194
1335
  if (patch.tags !== void 0 && (!Array.isArray(patch.tags) || patch.tags.some((x) => typeof x !== "string"))) throw new Error("tags must be an array of strings");
1195
1336
  if (patch.host !== void 0) entry.host = patch.host.trim();
1196
1337
  if (patch.port !== void 0) entry.port = patch.port;
@@ -1211,6 +1352,7 @@ var HostStore = class {
1211
1352
  };
1212
1353
  }
1213
1354
  if (patch.proxyJump !== void 0) entry.proxyJump = [...patch.proxyJump];
1355
+ if (patch.proxyCommand !== void 0) entry.proxyCommand = mergedProxyCommand;
1214
1356
  if (patch.description !== void 0) entry.description = patch.description.trim() || void 0;
1215
1357
  if (patch.environment !== void 0) entry.environment = patch.environment.trim() || void 0;
1216
1358
  if (patch.tags !== void 0) entry.tags = [...patch.tags].map((tag) => tag.trim()).filter((tag) => tag !== "");
@@ -1229,62 +1371,46 @@ var HostStore = class {
1229
1371
  }
1230
1372
  /**
1231
1373
  * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
1232
- * pattern and a HostName become entries (key auth via IdentityFile, jump
1233
- * hosts via ProxyJump). Existing aliases are skipped.
1374
+ * pattern become entries (a missing HostName falls back to the pattern
1375
+ * itself; key auth via IdentityFile; jump hosts via ProxyJump; ProxyCommand
1376
+ * kept verbatim). `Include` files are expanded, and `Match` blocks,
1377
+ * wildcard patterns, and aliases that already exist are skipped with a
1378
+ * reason instead of silently disappearing.
1234
1379
  * @returns import statistics.
1235
1380
  */
1236
1381
  importFromSshConfig() {
1237
- this.skippedNames = /* @__PURE__ */ new Set();
1382
+ this.skippedBlocks = [];
1238
1383
  const configPath = this.sshConfigOverride ?? sshConfigPath();
1239
1384
  if (!existsSync(configPath)) return {
1240
1385
  parsed: 0,
1241
1386
  added: 0,
1242
1387
  skipped: 0,
1243
- skippedNames: []
1388
+ skippedBlocks: []
1244
1389
  };
1245
- const lines = readFileSync(configPath, "utf8").split(/\r?\n/);
1246
- const blocks = [];
1247
- let current;
1248
- const skip = (name, seen) => {
1249
- if (name !== "" && !seen.has(name)) {
1250
- seen.add(name);
1251
- this.skippedNames.add(name);
1252
- }
1390
+ const { blocks, skipped } = readSshConfigBlocks(configPath);
1391
+ this.skippedBlocks = [...skipped];
1392
+ const skip = (name, reason) => {
1393
+ if (name === "") return;
1394
+ if (this.skippedBlocks.some((block) => block.name === name)) return;
1395
+ this.skippedBlocks.push({
1396
+ name,
1397
+ reason
1398
+ });
1253
1399
  };
1254
- for (const raw of lines) {
1255
- const line = raw.trim();
1256
- if (line === "" || line.startsWith("#")) continue;
1257
- const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line);
1258
- if (match === null) continue;
1259
- const key = match[1].toLowerCase();
1260
- const value = match[2].trim();
1261
- if (key === "host") {
1262
- current = {
1263
- pattern: value,
1264
- props: {}
1265
- };
1266
- blocks.push(current);
1267
- } else if (current !== void 0) current.props[key] = value;
1268
- }
1269
1400
  let added = 0;
1270
1401
  for (const block of blocks) {
1271
1402
  const pattern = block.pattern.split(/\s+/)[0];
1272
1403
  if (pattern.includes("*") || pattern.includes("?")) {
1273
- skip(pattern, this.skippedNames);
1274
- continue;
1275
- }
1276
- const hostName = block.props.hostname;
1277
- if (hostName === void 0 || hostName === "") {
1278
- skip(pattern, this.skippedNames);
1404
+ skip(pattern, "wildcard");
1279
1405
  continue;
1280
1406
  }
1281
1407
  if (this.list().some((entry) => entry.alias === pattern)) {
1282
- skip(pattern, this.skippedNames);
1408
+ skip(pattern, "existing");
1283
1409
  continue;
1284
1410
  }
1285
1411
  const payload = {
1286
1412
  alias: pattern,
1287
- host: hostName,
1413
+ host: block.props.hostname !== void 0 && block.props.hostname !== "" ? block.props.hostname : pattern,
1288
1414
  port: block.props.port !== void 0 ? Number.parseInt(block.props.port, 10) : 22,
1289
1415
  user: block.props.user ?? process.env.USER ?? "root",
1290
1416
  auth: {
@@ -1294,6 +1420,7 @@ var HostStore = class {
1294
1420
  agentPath: block.props.identityagent !== void 0 && block.props.identityagent.toLowerCase() !== "none" ? normalizeAgentPath(block.props.identityagent) : void 0
1295
1421
  },
1296
1422
  proxyJump: block.props.proxyjump !== void 0 ? block.props.proxyjump.split(",").map((hop) => hop.trim()).filter((hop) => hop !== "") : [],
1423
+ proxyCommand: block.props.proxycommand,
1297
1424
  description: block.props.description,
1298
1425
  environment: block.props.environment,
1299
1426
  tags: (block.props.tags ?? "").split(",").map((tag) => tag.trim()).filter((tag) => tag !== ""),
@@ -1303,17 +1430,17 @@ var HostStore = class {
1303
1430
  this.create(payload);
1304
1431
  added += 1;
1305
1432
  } catch {
1306
- skip(pattern, this.skippedNames);
1433
+ skip(pattern, "invalid");
1307
1434
  }
1308
1435
  }
1309
1436
  return {
1310
1437
  parsed: blocks.length,
1311
1438
  added,
1312
- skipped: this.skippedNames.size,
1313
- skippedNames: [...this.skippedNames]
1439
+ skipped: this.skippedBlocks.length,
1440
+ skippedBlocks: [...this.skippedBlocks]
1314
1441
  };
1315
1442
  }
1316
- skippedNames = /* @__PURE__ */ new Set();
1443
+ skippedBlocks = [];
1317
1444
  /**
1318
1445
  * Last parsed store keyed by file identity. list/find ride every acquire
1319
1446
  * and GUI refresh; re-reading and re-parsing the whole file each call is
@@ -1377,6 +1504,16 @@ function normalizeAgentPath(agentPath) {
1377
1504
  }
1378
1505
  return expandHome(trimmed);
1379
1506
  }
1507
+ /**
1508
+ * Normalize a ProxyCommand for storage: trim, and treat an empty value or
1509
+ * OpenSSH's `none` keyword as "no proxy command" (an explicit clear).
1510
+ */
1511
+ function normalizeProxyCommand(raw) {
1512
+ const trimmed = raw?.trim();
1513
+ if (trimmed === void 0 || trimmed === "") return void 0;
1514
+ if (trimmed.toLowerCase() === "none") return void 0;
1515
+ return trimmed;
1516
+ }
1380
1517
  /** Expand a leading `~` in a filesystem path. */
1381
1518
  function expandHome(path) {
1382
1519
  if (path === "~") return homedir();
@@ -1384,6 +1521,207 @@ function expandHome(path) {
1384
1521
  return path;
1385
1522
  }
1386
1523
  //#endregion
1524
+ //#region src/engine/proxy-command.ts
1525
+ /**
1526
+ * OpenSSH-compatible ProxyCommand transport: run the command with the user's
1527
+ * shell and expose its stdio as a Duplex that ssh2 accepts as `sock`.
1528
+ *
1529
+ * The command comes verbatim from the user's own 0600 store file and runs with
1530
+ * the DSH host process's privileges — the same trust the `ssh(1)` client gives
1531
+ * the identical line in `~/.ssh/config`.
1532
+ */
1533
+ /** How long a killed proxy command may linger before SIGKILL (ms). */
1534
+ const KILL_GRACE_MS = 2e3;
1535
+ /** Expand the OpenSSH ProxyCommand tokens; unknown %X sequences stay verbatim. */
1536
+ function expandProxyTokens(command, target) {
1537
+ return command.replace(/%[hprn%]/g, (token) => {
1538
+ switch (token) {
1539
+ case "%h": return target.host;
1540
+ case "%p": return String(target.port);
1541
+ case "%r": return target.user;
1542
+ case "%n": return target.alias;
1543
+ default: return "%";
1544
+ }
1545
+ });
1546
+ }
1547
+ /**
1548
+ * The shell OpenSSH itself would use on this platform: the user's shell with
1549
+ * `-c` on POSIX, cmd.exe on Windows (where the whole command must stay one
1550
+ * verbatim argument, exactly as Node's own `shell: true` does it).
1551
+ */
1552
+ function spawnSpec(command) {
1553
+ if (process.platform === "win32") return {
1554
+ file: process.env.ComSpec ?? "cmd.exe",
1555
+ args: [
1556
+ "/d",
1557
+ "/s",
1558
+ "/c",
1559
+ "\"" + command + "\""
1560
+ ],
1561
+ windowsVerbatimArguments: true
1562
+ };
1563
+ return {
1564
+ file: process.env.SHELL ?? "/bin/sh",
1565
+ args: ["-c", command]
1566
+ };
1567
+ }
1568
+ /**
1569
+ * Start one ProxyCommand and bridge its stdio into a Duplex.
1570
+ *
1571
+ * Failure modes are reported through the stream: a spawn error, or an exit
1572
+ * before the SSH handshake finished, destroys it with the exit status and the
1573
+ * tail of the command's stderr instead of letting ssh2 wait for its own
1574
+ * `readyTimeout`.
1575
+ */
1576
+ function startProxyCommand(rawCommand, target) {
1577
+ const spec = spawnSpec(expandProxyTokens(rawCommand, target));
1578
+ const child = spawn(spec.file, spec.args, {
1579
+ stdio: [
1580
+ "pipe",
1581
+ "pipe",
1582
+ "pipe"
1583
+ ],
1584
+ windowsHide: true,
1585
+ ...spec.windowsVerbatimArguments === true ? { windowsVerbatimArguments: true } : {},
1586
+ ...process.platform === "win32" ? {} : { detached: true }
1587
+ });
1588
+ let stderr = "";
1589
+ child.stderr?.on("data", (chunk) => {
1590
+ stderr = (stderr + chunk.toString("utf8")).slice(-4096);
1591
+ });
1592
+ let killed = false;
1593
+ const kill = () => {
1594
+ if (killed) return;
1595
+ killed = true;
1596
+ const pid = child.pid;
1597
+ const group = process.platform !== "win32" && pid !== void 0;
1598
+ try {
1599
+ if (group) process.kill(-pid, "SIGTERM");
1600
+ else child.kill();
1601
+ } catch {}
1602
+ setTimeout(() => {
1603
+ try {
1604
+ if (group) process.kill(-pid, "SIGKILL");
1605
+ else child.kill("SIGKILL");
1606
+ } catch {}
1607
+ }, KILL_GRACE_MS).unref();
1608
+ };
1609
+ function formatExitError(code, signal, stderrDetail) {
1610
+ const status = signal !== null ? "signal " + signal : "code " + String(code ?? "unknown");
1611
+ const detail = stderrDetail.trim();
1612
+ return /* @__PURE__ */ new Error("ProxyCommand exited (" + status + ")" + (detail === "" ? "" : ": " + detail));
1613
+ }
1614
+ const stream = new Duplex({
1615
+ read() {
1616
+ child.stdout?.resume();
1617
+ },
1618
+ write(chunk, _encoding, callback) {
1619
+ const stdin = child.stdin;
1620
+ if (stream.destroyed || stdin === null || stdin.destroyed) {
1621
+ callback(/* @__PURE__ */ new Error("ProxyCommand stdin is already closed"));
1622
+ return;
1623
+ }
1624
+ stdin.write(chunk, (err) => {
1625
+ if (!err) {
1626
+ callback();
1627
+ return;
1628
+ }
1629
+ if (child.exitCode !== null && child.exitCode !== 0 || child.signalCode !== null) {
1630
+ const exitErr = formatExitError(child.exitCode, child.signalCode, stderr);
1631
+ fail(exitErr);
1632
+ callback(exitErr);
1633
+ return;
1634
+ }
1635
+ let resolved = false;
1636
+ const onExit = (code, signal) => {
1637
+ if (resolved) return;
1638
+ resolved = true;
1639
+ clearTimeout(timer);
1640
+ const exitErr = code !== null && code !== 0 || signal !== null ? formatExitError(code, signal, stderr) : /* @__PURE__ */ new Error("ProxyCommand transport write failed: " + err.message);
1641
+ fail(exitErr);
1642
+ callback(exitErr);
1643
+ };
1644
+ const timer = setTimeout(() => {
1645
+ if (resolved) return;
1646
+ resolved = true;
1647
+ child.removeListener("exit", onExit);
1648
+ const transportErr = /* @__PURE__ */ new Error("ProxyCommand transport write failed: " + err.message);
1649
+ fail(transportErr);
1650
+ callback(transportErr);
1651
+ }, 200);
1652
+ timer.unref();
1653
+ child.once("exit", onExit);
1654
+ });
1655
+ },
1656
+ final(callback) {
1657
+ const stdin = child.stdin;
1658
+ if (stdin === null || stdin.destroyed) {
1659
+ callback();
1660
+ return;
1661
+ }
1662
+ stdin.end((err) => {
1663
+ if (err) callback(/* @__PURE__ */ new Error("ProxyCommand transport close failed: " + err.message));
1664
+ else callback();
1665
+ });
1666
+ },
1667
+ destroy(error, callback) {
1668
+ kill();
1669
+ callback(error);
1670
+ }
1671
+ });
1672
+ stream.on("error", () => {});
1673
+ let failed = false;
1674
+ const fail = (error) => {
1675
+ if (failed || stream.destroyed) return;
1676
+ failed = true;
1677
+ stream.destroy(error);
1678
+ };
1679
+ let stdoutEnded = false;
1680
+ let exited = false;
1681
+ const finishIfClean = () => {
1682
+ if (exited && stdoutEnded && !failed && !stream.destroyed) stream.push(null);
1683
+ };
1684
+ child.stdout?.on("data", (chunk) => {
1685
+ if (stream.push(chunk) === false) child.stdout?.pause();
1686
+ });
1687
+ child.stdout?.on("end", () => {
1688
+ stdoutEnded = true;
1689
+ finishIfClean();
1690
+ });
1691
+ child.stdout?.on("error", (error) => {
1692
+ fail(/* @__PURE__ */ new Error("ProxyCommand stdout error: " + error.message));
1693
+ });
1694
+ child.stdin?.on("error", (error) => {
1695
+ if (child.exitCode !== null && child.exitCode !== 0 || child.signalCode !== null) {
1696
+ fail(formatExitError(child.exitCode, child.signalCode, stderr));
1697
+ return;
1698
+ }
1699
+ const timer = setTimeout(() => {
1700
+ fail(/* @__PURE__ */ new Error("ProxyCommand transport error: " + error.message));
1701
+ }, 200);
1702
+ timer.unref();
1703
+ child.once("exit", (code, signal) => {
1704
+ clearTimeout(timer);
1705
+ if (code !== null && code !== 0 || signal !== null) fail(formatExitError(code, signal, stderr));
1706
+ });
1707
+ });
1708
+ child.on("error", (error) => {
1709
+ fail(/* @__PURE__ */ new Error("ProxyCommand could not start: " + error.message));
1710
+ });
1711
+ child.on("exit", (code, signal) => {
1712
+ exited = true;
1713
+ if (code !== null && code !== 0 || signal !== null) {
1714
+ fail(formatExitError(code, signal, stderr));
1715
+ return;
1716
+ }
1717
+ finishIfClean();
1718
+ });
1719
+ return {
1720
+ stream,
1721
+ child
1722
+ };
1723
+ }
1724
+ //#endregion
1387
1725
  //#region src/engine/connection-pool.ts
1388
1726
  /**
1389
1727
  * Connection pool: per-alias persistent ssh2 connections with multi-hop jump
@@ -1440,6 +1778,10 @@ function connectClient(config, onKeyboardInteractive) {
1440
1778
  const fail = (error) => {
1441
1779
  if (settled) return;
1442
1780
  settled = true;
1781
+ const sock = config.sock;
1782
+ if (sock !== void 0 && typeof sock.destroy === "function") try {
1783
+ sock.destroy();
1784
+ } catch {}
1443
1785
  try {
1444
1786
  client.destroy();
1445
1787
  } catch {}
@@ -1484,34 +1826,127 @@ function appendOutput(target, chunk, maxBytes) {
1484
1826
  }
1485
1827
  target.text += chunk.toString("utf8");
1486
1828
  }
1829
+ /** The ProxyCommand token source for one entry. */
1830
+ function proxyTargetOf(entry) {
1831
+ return {
1832
+ host: entry.host,
1833
+ port: entry.port,
1834
+ user: entry.user,
1835
+ alias: entry.alias
1836
+ };
1837
+ }
1838
+ /**
1839
+ * Parse an OpenSSH jump hop written as an address: `[user@]host[:port]`
1840
+ * (an IPv6 literal must be bracketed). Returns undefined when the value is not
1841
+ * an address at all.
1842
+ */
1843
+ function parseJumpSpec(spec) {
1844
+ const trimmed = spec.trim();
1845
+ if (trimmed === "") return void 0;
1846
+ const at = trimmed.lastIndexOf("@");
1847
+ const user = at >= 0 ? trimmed.slice(0, at) : void 0;
1848
+ const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed;
1849
+ if (rest === "") return void 0;
1850
+ let host = rest;
1851
+ let portText;
1852
+ if (rest.startsWith("[")) {
1853
+ const end = rest.indexOf("]");
1854
+ if (end < 0) return void 0;
1855
+ host = rest.slice(1, end);
1856
+ const tail = rest.slice(end + 1);
1857
+ if (tail.startsWith(":")) portText = tail.slice(1);
1858
+ else if (tail !== "") return void 0;
1859
+ } else {
1860
+ const colon = rest.indexOf(":");
1861
+ if (colon >= 0) {
1862
+ host = rest.slice(0, colon);
1863
+ portText = rest.slice(colon + 1);
1864
+ }
1865
+ }
1866
+ if (host === "") return void 0;
1867
+ let port;
1868
+ if (portText !== void 0) {
1869
+ port = Number(portText);
1870
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return void 0;
1871
+ }
1872
+ return {
1873
+ host,
1874
+ ...port !== void 0 ? { port } : {},
1875
+ ...user !== void 0 && user !== "" ? { user } : {}
1876
+ };
1877
+ }
1878
+ /**
1879
+ * Resolve one ProxyJump spec: an alias configured in this plugin wins, and any
1880
+ * other value is read as an OpenSSH address whose credentials come from the
1881
+ * target entry (an ad-hoc hop has no stored auth of its own).
1882
+ */
1883
+ function resolveHop(engine, entry, spec) {
1884
+ const configured = engine.store.find(spec);
1885
+ if (configured !== void 0) return configured;
1886
+ const parsed = parseJumpSpec(spec);
1887
+ if (parsed === void 0) throw new Error("proxyJump '" + spec + "' is neither a configured alias nor a [user@]host[:port] address");
1888
+ const now = Date.now();
1889
+ return {
1890
+ alias: spec,
1891
+ host: parsed.host,
1892
+ port: parsed.port ?? 22,
1893
+ user: parsed.user ?? process.env.USER ?? entry.user,
1894
+ auth: entry.auth,
1895
+ proxyJump: [],
1896
+ tags: [],
1897
+ createdAt: now,
1898
+ updatedAt: now
1899
+ };
1900
+ }
1487
1901
  /**
1488
1902
  * Build one full jump chain for an entry: hop clients connected through in
1489
1903
  * order, each forwarding a stream to the next destination, ending with the
1490
1904
  * target client. Shared by the pool and standalone shell sessions.
1905
+ *
1906
+ * A ProxyCommand is the transport that reaches its own host, so the entry's
1907
+ * own command seeds the chain when there is no jump chain, and the first hop's
1908
+ * command is used when the hop carries one.
1491
1909
  */
1492
1910
  async function connectChain(engine, entry, onKeyboardInteractive) {
1493
1911
  const hops = [];
1494
- let sock;
1495
1912
  const chain = entry.proxyJump;
1913
+ if (chain.length > 0 && entry.proxyCommand !== void 0) throw new Error("entry '" + entry.alias + "' declares both proxyCommand and proxyJump — pick one transport per host");
1914
+ /** Transports this call started; destroyed on every failure path. */
1915
+ const transports = [];
1916
+ const startTransport = (owner) => {
1917
+ const { stream } = startProxyCommand(owner.proxyCommand, proxyTargetOf(owner));
1918
+ transports.push(stream);
1919
+ return stream;
1920
+ };
1921
+ let sock = entry.proxyCommand !== void 0 ? startTransport(entry) : void 0;
1496
1922
  try {
1497
1923
  for (let index = 0; index < chain.length; index += 1) {
1498
- const hopAlias = chain[index];
1499
- const hop = engine.store.find(hopAlias);
1500
- if (hop === void 0) throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1501
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive);
1924
+ const spec = chain[index];
1925
+ const hop = resolveHop(engine, entry, spec);
1926
+ if (index > 0 && hop.proxyCommand !== void 0) throw new Error("proxyJump hop '" + spec + "' declares a proxyCommand, which is only supported on the first hop");
1927
+ const hopSock = index === 0 && hop.proxyCommand !== void 0 ? startTransport(hop) : sock;
1928
+ let hopClient;
1929
+ try {
1930
+ hopClient = await connectClient(buildConnectConfig(hop, hopSock, engine.opts), onKeyboardInteractive);
1931
+ } catch (error) {
1932
+ throw new Error("proxyJump hop '" + spec + "' (" + hop.user + "@" + hop.host + ":" + hop.port + "): " + (error instanceof Error ? error.message : String(error)));
1933
+ }
1502
1934
  hops.push(hopClient);
1503
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1935
+ const next = index + 1 < chain.length ? resolveHop(engine, entry, chain[index + 1]) : void 0;
1504
1936
  const nextHost = next !== void 0 ? next.host : entry.host;
1505
1937
  const nextPort = next !== void 0 ? next.port : entry.port;
1506
1938
  sock = await new Promise((resolve, reject) => {
1507
1939
  hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1508
- if (error !== void 0) reject(error);
1940
+ if (error !== void 0) reject(/* @__PURE__ */ new Error("proxyJump hop '" + spec + "' could not forward to " + nextHost + ":" + nextPort + ": " + error.message));
1509
1941
  else resolve(stream);
1510
1942
  });
1511
1943
  });
1512
1944
  }
1513
1945
  } catch (error) {
1514
1946
  for (const client of hops) client.end();
1947
+ for (const stream of transports) try {
1948
+ stream.destroy();
1949
+ } catch {}
1515
1950
  throw error;
1516
1951
  }
1517
1952
  let target;
@@ -1523,6 +1958,9 @@ async function connectChain(engine, entry, onKeyboardInteractive) {
1523
1958
  };
1524
1959
  } catch (error) {
1525
1960
  for (const client of hops) client.end();
1961
+ for (const stream of transports) try {
1962
+ stream.destroy();
1963
+ } catch {}
1526
1964
  if (target !== void 0) try {
1527
1965
  target.destroy();
1528
1966
  } catch {}
@@ -2469,7 +2907,8 @@ function makeRoutes(deps) {
2469
2907
  "port",
2470
2908
  "user",
2471
2909
  "auth",
2472
- "proxyJump"
2910
+ "proxyJump",
2911
+ "proxyCommand"
2473
2912
  ].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
2474
2913
  writeJson(res, 200, { host: store.summarize(entry) });
2475
2914
  } catch (error) {
@@ -2949,18 +3388,27 @@ function text(value) {
2949
3388
  text: value
2950
3389
  }];
2951
3390
  }
3391
+ /** Project one summary onto the agent-facing row shape. */
3392
+ function toAgentHostRow(host) {
3393
+ const { proxyCommand, ...rest } = host;
3394
+ return {
3395
+ ...rest,
3396
+ proxyCommandConfigured: proxyCommand !== void 0
3397
+ };
3398
+ }
2952
3399
  /** Host table render shared by list surfaces. */
2953
3400
  function renderHosts(hosts) {
2954
3401
  if (hosts.length === 0) return "no hosts configured";
2955
3402
  return [
2956
- "alias | host | port | user | auth | environment | tags | description",
2957
- "--- | --- | --- | --- | --- | --- | --- | ---",
3403
+ "alias | host | port | user | auth | proxy | environment | tags | description",
3404
+ "--- | --- | --- | --- | --- | --- | --- | --- | ---",
2958
3405
  ...hosts.map((host) => [
2959
3406
  host.alias,
2960
3407
  host.host,
2961
3408
  String(host.port),
2962
3409
  host.user,
2963
3410
  host.auth,
3411
+ host.proxyJump.length > 0 ? "jump:" + host.proxyJump.join(",") : host.proxyCommandConfigured ? "proxy-command" : "-",
2964
3412
  host.environment ?? "-",
2965
3413
  host.tags.length > 0 ? host.tags.join(",") : "-",
2966
3414
  host.description ?? ""
@@ -3043,6 +3491,10 @@ function sshListTool(engine) {
3043
3491
  items: { type: "string" },
3044
3492
  required: true
3045
3493
  },
3494
+ proxyCommandConfigured: {
3495
+ type: "boolean",
3496
+ required: true
3497
+ },
3046
3498
  description: { type: "string" },
3047
3499
  environment: { type: "string" },
3048
3500
  tags: {
@@ -3066,7 +3518,7 @@ function sshListTool(engine) {
3066
3518
  render: (_args, value) => text(renderHosts(value.hosts ?? []))
3067
3519
  },
3068
3520
  async execute(args) {
3069
- return { hosts: engine.list(args.query) };
3521
+ return { hosts: engine.list(args.query).map(toAgentHostRow) };
3070
3522
  }
3071
3523
  });
3072
3524
  }
@@ -3582,7 +4034,7 @@ const DEFAULT_ANNOUNCE = false;
3582
4034
  /** Order of the announcement section within the tool-guidance band. */
3583
4035
  const SECTION_ORDER = 150;
3584
4036
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3585
- const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
4037
+ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥、ProxyJump 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3586
4038
  /**
3587
4039
  * Mount the SSH engine, routes, tools, and announcement.
3588
4040
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.