dsh-remote 0.8.42 → 0.8.44

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
@@ -52,7 +52,8 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
52
52
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
53
53
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
54
54
  import { registerHttpTransports } from './http-transport.js'
55
- import { SshPool } from './pool.js'
55
+ import { SshPool, ActivePool, createPool } from './pool.js'
56
+ import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
56
57
  import { createFsRoutes } from './routes-fs.js'
57
58
  import { listDirStructured, removeRemoteTree } from './remote-fs.js'
58
59
 
@@ -110,6 +111,17 @@ export const Config = z.object({
110
111
  useAgent: z.boolean().default(false),
111
112
  /** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
112
113
  keyboardInteractive: z.boolean().default(false),
114
+ /** SSH transport backend (issue #51):
115
+ * `native` (default) speaks SSH through ssh2 — password / private key /
116
+ * ssh-agent / keyboard-interactive;
117
+ * `openssh` delegates to the LOCAL `ssh` executable, which is the only way to
118
+ * reach hosts that allow GSSAPI/Kerberos (kinit) or that rely on other
119
+ * OpenSSH-only `~/.ssh/config` features (ProxyCommand, PKCS11Provider,
120
+ * ControlMaster, Match blocks). See lib/openssh.js for the trade-offs. */
121
+ backend: z.string().default('native'),
122
+ /** The `ssh` executable the `openssh` backend drives (PATH name or a full
123
+ * path). Ignored by the native backend. */
124
+ sshBinary: z.string().default(''),
113
125
  /** Jump host / bastion: connect through this machine first. All fields have
114
126
  * defaults so this works on schemastery versions without `.optional()`; an
115
127
  * empty `host` means "no jump host". */
@@ -316,7 +328,9 @@ function encodeText(s, enc) {
316
328
  // ── apply ─────────────────────────────────────────────────────────────────
317
329
 
318
330
  export async function apply(ctx, config) {
319
- const pool = new SshPool(config, { knownHostsFile })
331
+ // The ACTIVE pool: a delegating facade, because `backend` is per machine and
332
+ // the plugin must keep ONE long-lived object to hang its hooks on (issue #51).
333
+ const pool = new ActivePool(config, { knownHostsFile })
320
334
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
321
335
 
322
336
  migrateLegacyData()
@@ -395,6 +409,50 @@ export async function apply(ctx, config) {
395
409
  }
396
410
  }
397
411
 
412
+ /**
413
+ * The CONNECT target for a pool, given a machine's RESOLVED connect identity
414
+ * (i.e. a record that already passed through withSshConfig()).
415
+ *
416
+ * Issue #38 resolves an alias machine into concrete host/user/port/key values
417
+ * because the native backend has to dial them itself. Issue #51's
418
+ * system-OpenSSH backend must NOT do that: it hands the name to `ssh`, and ssh
419
+ * matches its `Host` blocks against exactly the name it was given. Passing the
420
+ * resolved HostName instead would stop `Host myalias` blocks from matching and
421
+ * silently drop the very settings (GSSAPIAuthentication=yes, ProxyCommand,
422
+ * PKCS11Provider, Match blocks) that make this backend worth choosing. For an
423
+ * alias machine on that backend the ALIAS is therefore the connect target, no
424
+ * -l/-p/-i of our own is emitted, and ~/.ssh/config stays the single source of
425
+ * truth for that machine (which is also exactly what issue #50 asks for).
426
+ *
427
+ * @param {object} eff - a record after withSshConfig() (`sshAlias` present for
428
+ * alias machines).
429
+ * @returns {{host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, aliasNative: boolean}}
430
+ */
431
+ const connectTargetFor = (eff) => {
432
+ const aliasNative = !!(eff && eff.useSshConfig && isSystemOpenSshBackend(eff))
433
+ if (!aliasNative) {
434
+ return {
435
+ host: String((eff && eff.host) || ''),
436
+ port: Number((eff && eff.port) || 0) || 22,
437
+ username: String((eff && eff.username) || ''),
438
+ privateKeyPath: String((eff && eff.privateKeyPath) || ''),
439
+ proxy: eff && eff.proxy,
440
+ aliasNative: false,
441
+ }
442
+ }
443
+ const alias = String((eff && (eff.sshAlias || eff.host)) || '')
444
+ return {
445
+ // The alias itself; ssh expands HostName/User/Port/IdentityFile and every
446
+ // other directive while matching that Host block.
447
+ host: alias,
448
+ port: 0, // 0 → no -p is emitted, so Port comes from the config
449
+ username: '', // '' → no -l is emitted, so User comes from the config
450
+ privateKeyPath: '', // '' → no -i is emitted, so IdentityFile comes from it
451
+ proxy: undefined, // a ProxyJump written in the config is honoured by ssh
452
+ aliasNative: true,
453
+ }
454
+ }
455
+
398
456
  /** The alias a machine was saved as ('' for a machine with literal values). */
399
457
  const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
400
458
 
@@ -482,6 +540,9 @@ export async function apply(ctx, config) {
482
540
  passphrase: c.passphrase, workspace: c.workspace,
483
541
  useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
484
542
  proxy: c.proxy, hostKeyMode: c.hostKeyMode,
543
+ // Issue #51: the backend is part of the target identity, so a machine switch
544
+ // between backends must be seen as a change by setTarget.
545
+ backend: c.backend, sshBinary: c.sshBinary,
485
546
  })
486
547
 
487
548
  /** Fingerprint of the machine record last applied to the shared pool config.
@@ -513,6 +574,17 @@ export async function apply(ctx, config) {
513
574
  // OLD connection alive across a machine switch (issue #25 hazard).
514
575
  const previous = targetFields(config)
515
576
  applyMachine(config, { ...m, password: pw || m.password || '' })
577
+ // Issue #51: an ssh-config alias on the system-OpenSSH backend connects AS
578
+ // the alias, so ssh — not the plugin — expands HostName/User/Port/
579
+ // IdentityFile/ProxyJump, and the `Host` block keeps matching.
580
+ const t = connectTargetFor(m)
581
+ if (t.aliasNative) {
582
+ config.host = t.host
583
+ config.port = 22
584
+ config.username = ''
585
+ config.privateKeyPath = ''
586
+ config.proxy = undefined
587
+ }
516
588
  pool.setTarget(targetFields(config), previous)
517
589
  lastAppliedMachine = appliedFingerprint()
518
590
  }
@@ -596,28 +668,36 @@ export async function apply(ctx, config) {
596
668
  // effect for an existing session instead of dialing the stale address.
597
669
  const rec = machineRecordFor(target)
598
670
  const eff = rec ? withSshConfig(rec) : null
599
- const host = (eff && eff.host) || target.host
600
- const port = Number((eff && eff.port) || target.port) || 22
601
- const username = (eff && eff.username) || target.username || 'root'
671
+ // Issue #51: on the system-OpenSSH backend an alias machine connects AS its
672
+ // alias so ~/.ssh/config (not the registry) supplies host/user/port/key.
673
+ const t = connectTargetFor(eff || target)
674
+ const host = t.aliasNative ? t.host : ((eff && eff.host) || target.host)
675
+ const port = t.aliasNative ? 0 : (Number((eff && eff.port) || target.port) || 22)
676
+ const username = t.aliasNative ? '' : ((eff && eff.username) || target.username || 'root')
602
677
  const existing = machinePools.get(key)
603
678
  if (existing) {
604
679
  syncTunables(existing.config)
605
- // Re-point a live pool whose resolved identity moved (setTarget closes the
606
- // connection, so only do it when something actually changed).
607
- if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
608
- existing.setTarget({ host, port, username })
609
- }
610
- // Re-read credentials and per-machine policy from the registry: editing a
611
- // machine in the settings page must reach a pool created earlier, and a
612
- // pool holding a stale password would keep failing to reconnect.
680
+ // Credentials and per-machine policy are re-read from the registry BEFORE
681
+ // re-pointing: editing a machine in the settings page must reach a pool
682
+ // created earlier, and a pool holding a stale password would keep failing
683
+ // to reconnect. The backend belongs in this group too — ActivePool picks
684
+ // its delegate class from `config.backend` during setTarget/_sync, so
685
+ // assigning it afterwards would hand the OLD backend the new target.
613
686
  if (eff) {
614
687
  existing.config.password = eff.password || ''
615
- existing.config.privateKeyPath = eff.privateKeyPath || ''
688
+ existing.config.privateKeyPath = t.aliasNative ? '' : (eff.privateKeyPath || '')
616
689
  existing.config.passphrase = eff.passphrase || ''
617
690
  existing.config.useAgent = !!eff.useAgent
618
691
  existing.config.keyboardInteractive = !!eff.keyboardInteractive
619
- existing.config.proxy = eff.proxy || undefined
692
+ existing.config.proxy = t.aliasNative ? undefined : (eff.proxy || undefined)
620
693
  existing.config.hostKeyMode = eff.hostKeyMode || config.hostKeyMode
694
+ existing.config.backend = eff.backend || config.backend
695
+ existing.config.sshBinary = (typeof eff.sshBinary === 'string') ? eff.sshBinary : config.sshBinary
696
+ }
697
+ // Re-point a live pool whose resolved identity moved (setTarget closes the
698
+ // connection, so only do it when something actually changed).
699
+ if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
700
+ existing.setTarget({ host, port, username })
621
701
  }
622
702
  return existing
623
703
  }
@@ -627,17 +707,22 @@ export async function apply(ctx, config) {
627
707
  port,
628
708
  username,
629
709
  password: (eff && eff.password) || '',
630
- privateKeyPath: (eff && eff.privateKeyPath) || '',
710
+ privateKeyPath: t.aliasNative ? '' : ((eff && eff.privateKeyPath) || ''),
631
711
  passphrase: (eff && eff.passphrase) || '',
632
712
  useAgent: eff ? !!eff.useAgent : !!config.useAgent,
633
713
  keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
634
- proxy: (eff && eff.proxy) || undefined,
714
+ proxy: t.aliasNative ? undefined : ((eff && eff.proxy) || undefined),
635
715
  hostKeyMode: (eff && eff.hostKeyMode) || config.hostKeyMode,
716
+ // Issue #51: the transport backend is a per-machine choice — an
717
+ // enterprise Kerberos host needs `openssh` while its neighbours stay on
718
+ // the native backend.
719
+ backend: (eff && eff.backend) || config.backend,
720
+ sshBinary: (eff && typeof eff.sshBinary === 'string') ? eff.sshBinary : config.sshBinary,
636
721
  // Identity-pinned pools never carry the shared active workspace: a
637
722
  // session passes its own mirror-resolved remote path explicitly.
638
723
  workspace: '',
639
724
  })
640
- const created = new SshPool(poolConfig, { knownHostsFile })
725
+ const created = new ActivePool(poolConfig, { knownHostsFile })
641
726
  // Keychain-backed passwords resolve against THIS pool's machine record.
642
727
  created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
643
728
  machinePools.set(key, created)
@@ -961,6 +1046,69 @@ export async function apply(ctx, config) {
961
1046
  * without consulting the mutable active-machine state, so a concurrent
962
1047
  * session switching machines cannot redirect this session's commands. */
963
1048
  const resolveMirrorForLocal = (local) => resolveMirror(local, remoteWorkspacesRoot())
1049
+
1050
+ /**
1051
+ * The whole local-mirror registry with each mirror's recorded remote origin
1052
+ * (issue #49).
1053
+ *
1054
+ * The sidebar's Session rows need to know, for EVERY visible row, whether that
1055
+ * session is a remote one and which host it is bound to. Asking
1056
+ * `/resolve-mirror` per row would issue one HTTP request per row and would
1057
+ * also depend on the host still knowing that session id; instead the client
1058
+ * already holds each row's cwd (the sessions snapshot) and matches it against
1059
+ * this directory list locally, so the whole sidebar costs ONE request.
1060
+ *
1061
+ * Mirrors whose meta is missing/unparsable (or has no `host`) are SKIPPED
1062
+ * rather than guessed at — the same rule `resolveMirror()` applies, and for
1063
+ * the same reason: labelling a session with a host it is not bound to is
1064
+ * worse than showing no badge at all.
1065
+ *
1066
+ * @returns {Array<{dir: string, host: string, port: number, username: string,
1067
+ * remotePath: string, alias: string, name: string}>} one entry per mirror.
1068
+ */
1069
+ const mirrorRegistry = () => {
1070
+ const out = []
1071
+ const root = remoteWorkspacesRoot()
1072
+ if (!existsSync(root)) return out
1073
+ let hostDirs = []
1074
+ try { hostDirs = readdirSync(root) } catch { return out }
1075
+ for (const hostDir of hostDirs) {
1076
+ const hostPath = path.join(root, hostDir)
1077
+ if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1078
+ let entries = []
1079
+ try { entries = readdirSync(hostPath) } catch { continue }
1080
+ for (const entry of entries) {
1081
+ const dir = path.join(hostPath, entry)
1082
+ const metaPath = path.join(dir, '.dsh-remote-meta.json')
1083
+ if (!existsSync(metaPath)) continue
1084
+ try {
1085
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1086
+ if (!meta || !meta.host) continue
1087
+ const origin = {
1088
+ host: String(meta.host),
1089
+ port: Number(meta.port) || 22,
1090
+ username: String(meta.username || ''),
1091
+ alias: String(meta.alias || ''),
1092
+ }
1093
+ const rec = machineRecordFor(origin)
1094
+ out.push({
1095
+ dir: path.resolve(dir),
1096
+ host: origin.host,
1097
+ port: origin.port,
1098
+ username: origin.username,
1099
+ remotePath: String(meta.remotePath || ''),
1100
+ alias: origin.alias,
1101
+ // A saved machine's display name, when this mirror resolves to one:
1102
+ // the UI prefers the friendly name over the raw address in its
1103
+ // tooltip (the row itself shows the address, which is what the
1104
+ // reader asked for).
1105
+ name: rec && rec.name ? String(rec.name) : '',
1106
+ })
1107
+ } catch { /* unparsable meta → not a usable binding */ }
1108
+ }
1109
+ }
1110
+ return out
1111
+ }
964
1112
  if (config.autoPush && wsPath()) {
965
1113
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
966
1114
  }
@@ -1350,7 +1498,7 @@ export async function apply(ctx, config) {
1350
1498
  defineTool({
1351
1499
  name: 'rw_connect',
1352
1500
  description:
1353
- 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. A failed connection is reported as an error message — it never takes the plugin down.',
1501
+ 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
1354
1502
  parameters: {
1355
1503
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1356
1504
  machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
@@ -1362,6 +1510,8 @@ export async function apply(ctx, config) {
1362
1510
  useAgent: { type: 'boolean', description: 'Authenticate via the local SSH agent (SSH_AUTH_SOCK) instead of password/key' },
1363
1511
  keyboardInteractive: { type: 'boolean', description: 'Enable keyboard-interactive auth (OTP/动态码 prompts answered with the configured password)' },
1364
1512
  hostKeyMode: { type: 'string', description: 'Host-key policy for this machine: ask | strict | off (default from config)' },
1513
+ backend: { type: 'string', description: 'SSH transport backend (issue #51): "native" (default) uses the built-in ssh2 client (password / private key / agent / keyboard-interactive); "openssh" drives the LOCAL ssh command, which is the only way to reach hosts that require GSSAPI/Kerberos (log in once with kinit) and which honours the whole of ~/.ssh/config (ProxyCommand, PKCS11Provider, Match blocks). Prefer "openssh" together with useSshConfig=true so the alias and its settings stay the single source of truth.' },
1514
+ sshBinary: { type: 'string', description: 'Path (or PATH name) of the ssh executable the "openssh" backend drives — default "ssh"' },
1365
1515
  name: { type: 'string', description: 'Display name for a newly saved machine (default: the host)' },
1366
1516
  useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
1367
1517
  save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
@@ -1403,10 +1553,17 @@ export async function apply(ctx, config) {
1403
1553
  if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
1404
1554
  if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
1405
1555
  if (args.name) rec.name = String(args.name)
1556
+ // Issue #51: pick the transport backend. `openssh` is what reaches
1557
+ // GSSAPI/Kerberos-only hosts, and with useSshConfig=true the alias is
1558
+ // handed to ssh verbatim so ~/.ssh/config stays the single source.
1559
+ if (args.backend) rec.backend = args.backend === 'openssh' ? 'openssh' : 'native'
1560
+ if (args.sshBinary !== undefined) rec.sshBinary = String(args.sshBinary)
1406
1561
  if (useSshConfig) rec.useSshConfig = true
1407
1562
  // An alias connection must dial the RESOLVED identity (~/.ssh/config),
1408
- // while the registry keeps the alias itself.
1563
+ // while the registry keeps the alias itself — EXCEPT on the
1564
+ // system-OpenSSH backend, where ssh resolves the alias itself.
1409
1565
  const eff = useSshConfig ? withSshConfig(rec) : rec
1566
+ const ct = connectTargetFor(eff)
1410
1567
  if (args.save !== false) {
1411
1568
  // Upsert into the registry and make it the current machine so the
1412
1569
  // settings UI and the tools always agree on who is active.
@@ -1439,15 +1596,19 @@ export async function apply(ctx, config) {
1439
1596
  // cached client — same reconnect-probe reasoning as /connect).
1440
1597
  pool.invalidate()
1441
1598
  pool.setTarget({
1442
- host: eff.host, port: Number(eff.port) || port || 22, username: eff.username || user,
1443
- password: rec.password, privateKeyPath: eff.privateKeyPath || '',
1599
+ // connectTargetFor: on the system-OpenSSH backend an alias machine
1600
+ // dials the alias, not the values it expands to.
1601
+ host: ct.host, port: ct.aliasNative ? 22 : (Number(eff.port) || port || 22), username: ct.aliasNative ? '' : (eff.username || user),
1602
+ password: rec.password, privateKeyPath: ct.privateKeyPath,
1444
1603
  // Explicit (non-undefined) values so a stale passphrase/agent flag
1445
1604
  // from a previous target cannot leak into this connection.
1446
1605
  passphrase: eff.passphrase || '',
1447
1606
  useAgent: !!rec.useAgent,
1448
1607
  keyboardInteractive: !!rec.keyboardInteractive,
1449
1608
  hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
1450
- proxy: eff.proxy,
1609
+ proxy: ct.proxy,
1610
+ backend: rec.backend || config.backend,
1611
+ sshBinary: rec.sshBinary !== undefined ? rec.sshBinary : config.sshBinary,
1451
1612
  workspace: config.workspace,
1452
1613
  })
1453
1614
  }
@@ -1492,13 +1653,18 @@ export async function apply(ctx, config) {
1492
1653
  if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
1493
1654
  if (m.useAgent) auth.push('agent')
1494
1655
  if (m.keyboardInteractive) auth.push('keyboard-interactive')
1656
+ // Issue #51: say which transport will be used — on `openssh` the
1657
+ // credentials may come entirely from ~/.ssh/config (GSSAPI/kinit), so
1658
+ // "auth: NONE" would be actively misleading there.
1659
+ const backend = m.backend === 'openssh' ? 'openssh' : 'native'
1660
+ if (backend === 'openssh') auth.push('openssh backend (~/.ssh/config: GSSAPI/key/agent)')
1495
1661
  let via = ''
1496
1662
  if (m.useSshConfig) {
1497
1663
  const eff = withSshConfig(m)
1498
1664
  via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
1499
1665
  }
1500
1666
  const ws = m.workspace ? ` workspace=${m.workspace}` : ''
1501
- return `- ${m.id} "${m.name || m.host}" ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
1667
+ return `- ${m.id} "${m.name || m.host}" [${backend}] ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
1502
1668
  })
1503
1669
  return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
1504
1670
  },
@@ -2570,6 +2736,27 @@ export async function apply(ctx, config) {
2570
2736
  }
2571
2737
  },
2572
2738
  },
2739
+ {
2740
+ // Every local mirror with its recorded remote origin (issue #49).
2741
+ //
2742
+ // The sidebar paints a "this session is remote" mark on a Session row, so
2743
+ // it needs the same mapping for every visible row. One directory listing
2744
+ // answers all of them: the client matches each row's own cwd (which it
2745
+ // already has in the sessions snapshot) against these mirror dirs, and a
2746
+ // row whose cwd is inside a mirror is a remote session bound to that
2747
+ // mirror's host. Never consults the mutable active machine — a saved
2748
+ // machine that happens to be "current" must not label a local session.
2749
+ kind: 'exact',
2750
+ path: '/dsh-remote/bindings',
2751
+ handler: async (req, res) => {
2752
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
2753
+ try {
2754
+ return sendJson(res, 200, { mirrors: mirrorRegistry() })
2755
+ } catch (err) {
2756
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
2757
+ }
2758
+ },
2759
+ },
2573
2760
  {
2574
2761
  kind: 'exact',
2575
2762
  path: '/dsh-remote/connect',
@@ -2786,6 +2973,10 @@ export async function apply(ctx, config) {
2786
2973
  hostKeyMode: body.hostKeyMode || '',
2787
2974
  useAgent: !!body.useAgent,
2788
2975
  keyboardInteractive: !!body.keyboardInteractive,
2976
+ // Issue #51: the transport backend is per machine. `native` is
2977
+ // the default, so an unknown value is not persisted as-is.
2978
+ backend: body.backend === 'openssh' ? 'openssh' : 'native',
2979
+ sshBinary: String(body.sshBinary || '').trim(),
2789
2980
  proxy: body.proxy && body.proxy.host ? {
2790
2981
  host: String(body.proxy.host),
2791
2982
  port: Number(body.proxy.port) || 22,
@@ -2880,6 +3071,8 @@ export async function apply(ctx, config) {
2880
3071
  body = parseJsonObject(await readBody(req))
2881
3072
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
2882
3073
  // the form holds the alias name, not the host it points at.
3074
+ const probeBackend = String(body.backend || config.backend || 'native')
3075
+ const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
2883
3076
  const aliasTarget = body.useSshConfig
2884
3077
  ? resolveMachineSshConfig({
2885
3078
  host: String(body.host || ''),
@@ -2890,15 +3083,20 @@ export async function apply(ctx, config) {
2890
3083
  useSshConfig: true,
2891
3084
  }, { text: sshConfigText() })
2892
3085
  : null
2893
- const probe = new SshPool({
3086
+ // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
3087
+ // VERBATIM (see connectTargetFor) — resolving it here would stop the
3088
+ // `Host` block from matching, so the ~/.ssh/config settings the probe
3089
+ // is supposed to validate would be silently skipped.
3090
+ const probe = createPool({
2894
3091
  ...config,
2895
- host: aliasTarget ? aliasTarget.host : String(body.host || config.host),
2896
- port: aliasTarget ? aliasTarget.port : (Number(body.port) || config.port),
2897
- username: aliasTarget ? aliasTarget.username : String(body.username || config.username),
3092
+ backend: probeBackend,
3093
+ host: aliasTarget && !probeIsOpenssh ? aliasTarget.host : String(body.host || config.host),
3094
+ port: aliasTarget && !probeIsOpenssh ? aliasTarget.port : (Number(body.port) || config.port),
3095
+ username: aliasTarget && !probeIsOpenssh ? aliasTarget.username : String(body.username || config.username),
2898
3096
  password: String(body.password || ''),
2899
- privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3097
+ privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
2900
3098
  passphrase: String(body.passphrase || ''),
2901
- proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
3099
+ proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
2902
3100
  host: String(body.proxy.host),
2903
3101
  port: Number(body.proxy.port) || 22,
2904
3102
  username: String(body.proxy.username || ''),