dsh-remote 0.8.41 → 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
@@ -40,6 +40,7 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
40
40
  import { searchRemote } from './search.js'
41
41
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
42
42
  import { TaskManager } from './tasks.js'
43
+ import { DeployTasks } from './deploy-tasks.js'
43
44
  import { ForwardManager } from './forwards.js'
44
45
  import { WebAttach } from './web-attach.js'
45
46
  import {
@@ -51,7 +52,8 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
51
52
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
52
53
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
53
54
  import { registerHttpTransports } from './http-transport.js'
54
- import { SshPool } from './pool.js'
55
+ import { SshPool, ActivePool, createPool } from './pool.js'
56
+ import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
55
57
  import { createFsRoutes } from './routes-fs.js'
56
58
  import { listDirStructured, removeRemoteTree } from './remote-fs.js'
57
59
 
@@ -109,6 +111,17 @@ export const Config = z.object({
109
111
  useAgent: z.boolean().default(false),
110
112
  /** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
111
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(''),
112
125
  /** Jump host / bastion: connect through this machine first. All fields have
113
126
  * defaults so this works on schemastery versions without `.optional()`; an
114
127
  * empty `host` means "no jump host". */
@@ -315,7 +328,9 @@ function encodeText(s, enc) {
315
328
  // ── apply ─────────────────────────────────────────────────────────────────
316
329
 
317
330
  export async function apply(ctx, config) {
318
- 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 })
319
334
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
320
335
 
321
336
  migrateLegacyData()
@@ -394,6 +409,50 @@ export async function apply(ctx, config) {
394
409
  }
395
410
  }
396
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
+
397
456
  /** The alias a machine was saved as ('' for a machine with literal values). */
398
457
  const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
399
458
 
@@ -481,6 +540,9 @@ export async function apply(ctx, config) {
481
540
  passphrase: c.passphrase, workspace: c.workspace,
482
541
  useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
483
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,
484
546
  })
485
547
 
486
548
  /** Fingerprint of the machine record last applied to the shared pool config.
@@ -512,6 +574,17 @@ export async function apply(ctx, config) {
512
574
  // OLD connection alive across a machine switch (issue #25 hazard).
513
575
  const previous = targetFields(config)
514
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
+ }
515
588
  pool.setTarget(targetFields(config), previous)
516
589
  lastAppliedMachine = appliedFingerprint()
517
590
  }
@@ -595,28 +668,36 @@ export async function apply(ctx, config) {
595
668
  // effect for an existing session instead of dialing the stale address.
596
669
  const rec = machineRecordFor(target)
597
670
  const eff = rec ? withSshConfig(rec) : null
598
- const host = (eff && eff.host) || target.host
599
- const port = Number((eff && eff.port) || target.port) || 22
600
- 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')
601
677
  const existing = machinePools.get(key)
602
678
  if (existing) {
603
679
  syncTunables(existing.config)
604
- // Re-point a live pool whose resolved identity moved (setTarget closes the
605
- // connection, so only do it when something actually changed).
606
- if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
607
- existing.setTarget({ host, port, username })
608
- }
609
- // Re-read credentials and per-machine policy from the registry: editing a
610
- // machine in the settings page must reach a pool created earlier, and a
611
- // 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.
612
686
  if (eff) {
613
687
  existing.config.password = eff.password || ''
614
- existing.config.privateKeyPath = eff.privateKeyPath || ''
688
+ existing.config.privateKeyPath = t.aliasNative ? '' : (eff.privateKeyPath || '')
615
689
  existing.config.passphrase = eff.passphrase || ''
616
690
  existing.config.useAgent = !!eff.useAgent
617
691
  existing.config.keyboardInteractive = !!eff.keyboardInteractive
618
- existing.config.proxy = eff.proxy || undefined
692
+ existing.config.proxy = t.aliasNative ? undefined : (eff.proxy || undefined)
619
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 })
620
701
  }
621
702
  return existing
622
703
  }
@@ -626,17 +707,22 @@ export async function apply(ctx, config) {
626
707
  port,
627
708
  username,
628
709
  password: (eff && eff.password) || '',
629
- privateKeyPath: (eff && eff.privateKeyPath) || '',
710
+ privateKeyPath: t.aliasNative ? '' : ((eff && eff.privateKeyPath) || ''),
630
711
  passphrase: (eff && eff.passphrase) || '',
631
712
  useAgent: eff ? !!eff.useAgent : !!config.useAgent,
632
713
  keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
633
- proxy: (eff && eff.proxy) || undefined,
714
+ proxy: t.aliasNative ? undefined : ((eff && eff.proxy) || undefined),
634
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,
635
721
  // Identity-pinned pools never carry the shared active workspace: a
636
722
  // session passes its own mirror-resolved remote path explicitly.
637
723
  workspace: '',
638
724
  })
639
- const created = new SshPool(poolConfig, { knownHostsFile })
725
+ const created = new ActivePool(poolConfig, { knownHostsFile })
640
726
  // Keychain-backed passwords resolve against THIS pool's machine record.
641
727
  created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
642
728
  machinePools.set(key, created)
@@ -849,6 +935,10 @@ export async function apply(ctx, config) {
849
935
  // starting a second remote server. Lifecycle is owned by the plugin fiber, so
850
936
  // unloading the plugin tears every tunnel down.
851
937
  const webAttaches = new Map() // machineId → WebAttach
938
+ // Deploy tasks run on the HOST, detached from any request: closing the browser
939
+ // window mid-install must not lose the progress, and a re-opened page must be
940
+ // able to re-attach and see the live state (high-availability requirement).
941
+ const deployTasks = new DeployTasks()
852
942
  ctx.effect(() => () => {
853
943
  for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
854
944
  webAttaches.clear()
@@ -956,6 +1046,69 @@ export async function apply(ctx, config) {
956
1046
  * without consulting the mutable active-machine state, so a concurrent
957
1047
  * session switching machines cannot redirect this session's commands. */
958
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
+ }
959
1112
  if (config.autoPush && wsPath()) {
960
1113
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
961
1114
  }
@@ -1345,7 +1498,7 @@ export async function apply(ctx, config) {
1345
1498
  defineTool({
1346
1499
  name: 'rw_connect',
1347
1500
  description:
1348
- '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.',
1349
1502
  parameters: {
1350
1503
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1351
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.' },
@@ -1357,6 +1510,8 @@ export async function apply(ctx, config) {
1357
1510
  useAgent: { type: 'boolean', description: 'Authenticate via the local SSH agent (SSH_AUTH_SOCK) instead of password/key' },
1358
1511
  keyboardInteractive: { type: 'boolean', description: 'Enable keyboard-interactive auth (OTP/动态码 prompts answered with the configured password)' },
1359
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"' },
1360
1515
  name: { type: 'string', description: 'Display name for a newly saved machine (default: the host)' },
1361
1516
  useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
1362
1517
  save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
@@ -1398,10 +1553,17 @@ export async function apply(ctx, config) {
1398
1553
  if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
1399
1554
  if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
1400
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)
1401
1561
  if (useSshConfig) rec.useSshConfig = true
1402
1562
  // An alias connection must dial the RESOLVED identity (~/.ssh/config),
1403
- // 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.
1404
1565
  const eff = useSshConfig ? withSshConfig(rec) : rec
1566
+ const ct = connectTargetFor(eff)
1405
1567
  if (args.save !== false) {
1406
1568
  // Upsert into the registry and make it the current machine so the
1407
1569
  // settings UI and the tools always agree on who is active.
@@ -1434,15 +1596,19 @@ export async function apply(ctx, config) {
1434
1596
  // cached client — same reconnect-probe reasoning as /connect).
1435
1597
  pool.invalidate()
1436
1598
  pool.setTarget({
1437
- host: eff.host, port: Number(eff.port) || port || 22, username: eff.username || user,
1438
- 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,
1439
1603
  // Explicit (non-undefined) values so a stale passphrase/agent flag
1440
1604
  // from a previous target cannot leak into this connection.
1441
1605
  passphrase: eff.passphrase || '',
1442
1606
  useAgent: !!rec.useAgent,
1443
1607
  keyboardInteractive: !!rec.keyboardInteractive,
1444
1608
  hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
1445
- proxy: eff.proxy,
1609
+ proxy: ct.proxy,
1610
+ backend: rec.backend || config.backend,
1611
+ sshBinary: rec.sshBinary !== undefined ? rec.sshBinary : config.sshBinary,
1446
1612
  workspace: config.workspace,
1447
1613
  })
1448
1614
  }
@@ -1487,13 +1653,18 @@ export async function apply(ctx, config) {
1487
1653
  if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
1488
1654
  if (m.useAgent) auth.push('agent')
1489
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)')
1490
1661
  let via = ''
1491
1662
  if (m.useSshConfig) {
1492
1663
  const eff = withSshConfig(m)
1493
1664
  via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
1494
1665
  }
1495
1666
  const ws = m.workspace ? ` workspace=${m.workspace}` : ''
1496
- 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}`
1497
1668
  })
1498
1669
  return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
1499
1670
  },
@@ -2565,6 +2736,27 @@ export async function apply(ctx, config) {
2565
2736
  }
2566
2737
  },
2567
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
+ },
2568
2760
  {
2569
2761
  kind: 'exact',
2570
2762
  path: '/dsh-remote/connect',
@@ -2781,6 +2973,10 @@ export async function apply(ctx, config) {
2781
2973
  hostKeyMode: body.hostKeyMode || '',
2782
2974
  useAgent: !!body.useAgent,
2783
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(),
2784
2980
  proxy: body.proxy && body.proxy.host ? {
2785
2981
  host: String(body.proxy.host),
2786
2982
  port: Number(body.proxy.port) || 22,
@@ -2875,6 +3071,8 @@ export async function apply(ctx, config) {
2875
3071
  body = parseJsonObject(await readBody(req))
2876
3072
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
2877
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 })
2878
3076
  const aliasTarget = body.useSshConfig
2879
3077
  ? resolveMachineSshConfig({
2880
3078
  host: String(body.host || ''),
@@ -2885,15 +3083,20 @@ export async function apply(ctx, config) {
2885
3083
  useSshConfig: true,
2886
3084
  }, { text: sshConfigText() })
2887
3085
  : null
2888
- 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({
2889
3091
  ...config,
2890
- host: aliasTarget ? aliasTarget.host : String(body.host || config.host),
2891
- port: aliasTarget ? aliasTarget.port : (Number(body.port) || config.port),
2892
- 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),
2893
3096
  password: String(body.password || ''),
2894
- privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3097
+ privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
2895
3098
  passphrase: String(body.passphrase || ''),
2896
- proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
3099
+ proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
2897
3100
  host: String(body.proxy.host),
2898
3101
  port: Number(body.proxy.port) || 22,
2899
3102
  username: String(body.proxy.username || ''),
@@ -3076,10 +3279,15 @@ export async function apply(ctx, config) {
3076
3279
  const q = new URL(req.url, 'http://localhost').searchParams
3077
3280
  const id = q.get('machineId') || ''
3078
3281
  const attach = id ? webAttaches.get(id) : undefined
3282
+ // Deploy tasks ride along so a REOPENED window sees an in-flight
3283
+ // install immediately, without a separate poll's first tick.
3284
+ const deploy = id ? deployTasks.forMachine(id) : null
3079
3285
  return sendJson(res, 200, {
3080
3286
  ok: true,
3081
3287
  attach: attach ? attach.describe() : null,
3082
3288
  attaches: [...webAttaches.values()].map((a) => a.describe()),
3289
+ deploys: deployTasks.list(),
3290
+ deploy: deploy ? deployTasks.describe(deploy) : null,
3083
3291
  })
3084
3292
  }
3085
3293
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
@@ -3124,75 +3332,113 @@ export async function apply(ctx, config) {
3124
3332
  })
3125
3333
  }
3126
3334
 
3127
- // install
3335
+ // install — runs as a HOST-SIDE BACKGROUND TASK.
3336
+ //
3337
+ // It used to be one long HTTP request; npm can legitimately take
3338
+ // minutes, and closing the tab mid-deploy lost the response AND
3339
+ // every trace of the progress — the user could not tell whether it
3340
+ // succeeded, and a second click started a CONCURRENT npm install
3341
+ // into the same prefix. Now the work runs detached from the request
3342
+ // lifecycle, every step's outcome is recorded as it happens, and
3343
+ // the UI re-attaches by polling install-status (or by clicking the
3344
+ // button again, which resumes rather than restarts).
3128
3345
  if (body.confirm !== true) {
3129
3346
  return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
3130
3347
  }
3131
- const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3132
- const facts = parseProbe(pout && pout.stdout)
3133
- const verdict = judgeProbe(facts, { installedCommand })
3134
- if (!verdict.canAutoInstall) {
3135
- const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3136
- audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
3137
- return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
3348
+ // RESUME: a running/queued task for this machine is returned as-is.
3349
+ // One deploy per machine at a time — two concurrent `npm --prefix`
3350
+ // into the same directory corrupt the tree.
3351
+ const already = deployTasks.forMachine(machineId)
3352
+ if (already && (already.status === 'queued' || already.status === 'running') && body.force !== true) {
3353
+ return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
3138
3354
  }
3139
- const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3140
- // A registry the user set explicitly wins; otherwise the remote's own
3141
- // npm config is left alone, which is what an internal mirror needs.
3142
- // `--registry` is only added when configured — overriding a working
3143
- // corporate mirror with the public one would be a regression.
3144
- const registry = String(body.registry || config.webInstallRegistry || '').trim()
3145
- // Proxy comes from the remote's own environment; the operator may
3146
- // override it per-install when the inherited one is wrong.
3147
- const env = body.proxy && typeof body.proxy === 'object'
3148
- ? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
3149
- : undefined
3150
- const plan = buildInstallPlan({
3151
- facts,
3152
- prefix: installPrefix,
3153
- version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
3154
- registry,
3155
- env,
3156
- })
3157
- const results = []
3158
- for (const step of plan.steps) {
3159
- let res2
3160
- try {
3161
- res2 = await target.exec(step.command, {
3162
- timeoutMs: step.timeoutMs || 120000,
3163
- // Per-step env exists for the proxy override (P4); pool.exec
3164
- // passes it through to the remote process.
3165
- ...(step.env ? { env: step.env } : {}),
3166
- })
3167
- } catch (err) {
3168
- results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
3169
- break
3170
- }
3171
- const r = stepResult(step, res2)
3172
- results.push(r)
3173
- if (!r.ok) break
3174
- }
3175
- const ok = results.length === plan.steps.length && results.every((r) => r.ok)
3176
- if (ok) {
3177
- // Persist so the attach flow uses the working build from now on.
3178
- persistWebAttachCommand(machine, plan.command)
3179
- audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
3180
- return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
3355
+ if (already && body.force !== true && already.status !== 'done' && already.status !== 'failed' && already.status !== 'cancelled') {
3356
+ return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
3181
3357
  }
3182
- const failed = results.find((r) => !r.ok)
3183
- audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3184
- // A failure that looks like a network/registry problem gets the AI
3185
- // escape hatch suggested, since that is precisely the long tail the
3186
- // deterministic path cannot classify.
3187
- const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3188
- return sendJson(res, 500, {
3189
- ok: false,
3190
- error: `安装失败于「${failed ? failed.title : '未知步骤'}」`,
3191
- steps: results,
3192
- prefix: installPrefix,
3193
- suggestInvestigate: true,
3194
- ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
3358
+ const { task } = deployTasks.start(machineId, `deploy ${machine.host}`, async (ctx) => {
3359
+ // Everything below is the SAME plan as before, but progress goes
3360
+ // to the task (visible while it runs) instead of one response.
3361
+ const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3362
+ const facts = parseProbe(pout && pout.stdout)
3363
+ const verdict = judgeProbe(facts, { installedCommand })
3364
+ if (!verdict.canAutoInstall) {
3365
+ const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3366
+ audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
3367
+ throw new Error(`无法自动安装:${why || '远端缺少前提条件'}`)
3368
+ }
3369
+ const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3370
+ // A registry the user set explicitly wins; otherwise the remote's
3371
+ // own npm config is left alone (internal mirrors keep working).
3372
+ const registry = String(body.registry || config.webInstallRegistry || '').trim()
3373
+ const env = body.proxy && typeof body.proxy === 'object'
3374
+ ? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
3375
+ : undefined
3376
+ const plan = buildInstallPlan({
3377
+ facts,
3378
+ prefix: installPrefix,
3379
+ version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
3380
+ registry,
3381
+ env,
3382
+ })
3383
+ ctx.progress({ totalSteps: plan.steps.length, prefix: installPrefix })
3384
+ const results = []
3385
+ for (const step of plan.steps) {
3386
+ if (ctx.cancelled) break
3387
+ task.current = step.title
3388
+ ctx.progress({ doneSteps: results.length })
3389
+ let res2
3390
+ try {
3391
+ res2 = await target.exec(step.command, {
3392
+ timeoutMs: step.timeoutMs || 120000,
3393
+ ...(step.env ? { env: step.env } : {}),
3394
+ })
3395
+ } catch (err) {
3396
+ results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
3397
+ break
3398
+ }
3399
+ const r = stepResult(step, res2)
3400
+ results.push(r)
3401
+ task.steps = [...results]
3402
+ if (!r.ok) break
3403
+ }
3404
+ task.steps = [...results]
3405
+ const ok = results.length === plan.steps.length && results.every((r) => r.ok)
3406
+ if (ok) {
3407
+ // Persist so the attach flow uses the working build from now on.
3408
+ persistWebAttachCommand(machine, plan.command)
3409
+ audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
3410
+ return { ok: true, steps: results, command: plan.command, prefix: installPrefix }
3411
+ }
3412
+ const failed = results.find((r) => !r.ok)
3413
+ audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3414
+ const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3415
+ const err = new Error(`安装失败于「${failed ? failed.title : '未知步骤'}」`)
3416
+ err.detail = {
3417
+ steps: results,
3418
+ prefix: installPrefix,
3419
+ suggestInvestigate: true,
3420
+ ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
3421
+ }
3422
+ throw err
3195
3423
  })
3424
+ // Answer immediately: the task object IS the handle the UI polls.
3425
+ return sendJson(res, 200, { ok: true, started: true, task: deployTasks.describe(task) })
3426
+ }
3427
+
3428
+ // install-status: re-attach to a deploy at ANY time (window reopened,
3429
+ // page refreshed). Also carried by GET /dsh-remote/web-attach.
3430
+ if (action === 'install-status') {
3431
+ const t = deployTasks.forMachine(machineId)
3432
+ if (!t) return sendJson(res, 200, { ok: true, task: null })
3433
+ return sendJson(res, 200, { ok: true, task: deployTasks.describe(t) })
3434
+ }
3435
+
3436
+ // install-cancel: cooperative cancel between steps.
3437
+ if (action === 'install-cancel') {
3438
+ const t = deployTasks.forMachine(machineId)
3439
+ if (!t) return sendJson(res, 200, { ok: true, cancelled: false })
3440
+ const did = deployTasks.cancel(machineId)
3441
+ return sendJson(res, 200, { ok: true, cancelled: did, task: deployTasks.describe(t) })
3196
3442
  }
3197
3443
 
3198
3444
  if (action === 'open') {