dsh-remote 0.8.22 → 0.8.23

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/client.js CHANGED
@@ -1213,9 +1213,25 @@ window.__ModuleLoader__.load({
1213
1213
  const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
1214
1214
  const suggestTimer = React.useRef(null)
1215
1215
  const suggestRef = React.useRef(null)
1216
+ // Autocomplete performance (slow per-keystroke remote scans):
1217
+ // - postedCurrentRef/currentPromiseRef: POST /current once per machine
1218
+ // switch; concurrent callers share ONE in-flight promise and a failed
1219
+ // switch REJECTS (never run /ls — or cache its listing — on the wrong
1220
+ // machine)
1221
+ // - lsCacheRef/lsInflightRef: one /ls per (machine, dir), TTL-cached
1222
+ // - lsGenRef/fetchSeqRef: per-key fetch generation — a cache write from
1223
+ // a superseded fetch (fresh navigation, picker reopen) is dropped
1224
+ // - suggestSeqRef: drop stale suggestion responses (older keystrokes)
1225
+ const postedCurrentRef = React.useRef('')
1226
+ const currentPromiseRef = React.useRef(null)
1227
+ const lsCacheRef = React.useRef(new Map())
1228
+ const lsInflightRef = React.useRef(new Map())
1229
+ const lsGenRef = React.useRef(new Map())
1230
+ const fetchSeqRef = React.useRef(0)
1231
+ const suggestSeqRef = React.useRef(0)
1216
1232
  React.useEffect(() => {
1217
1233
  if (!suggestOpen) return
1218
- const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
1234
+ const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) { suggestSeqRef.current++; setSuggestOpen(false) } }
1219
1235
  document.addEventListener('mousedown', onDown)
1220
1236
  return () => document.removeEventListener('mousedown', onDown)
1221
1237
  }, [suggestOpen])
@@ -1247,26 +1263,125 @@ window.__ModuleLoader__.load({
1247
1263
  return b.replace(/[\\/]+$/, '') + sep + n
1248
1264
  }
1249
1265
 
1250
- const loadLevels = (id, p, toIndex) => {
1251
- if (!id) return
1252
- setLoading(true); setErr('')
1253
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1254
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
1255
- .then((res) => {
1256
- setWin(res && res.platform === 'windows')
1257
- const real = res && res.path ? res.path : (p || '')
1266
+ // Identity of the machine a listing was answered for (the /ls response
1267
+ // echoes it), checked against the machine the fetch INTENDED. The picker
1268
+ // memoizes "machine already active", but the settings page can switch
1269
+ // machines too — validate every listing before trusting the memo. A
1270
+ // machine may show up as its ~/.ssh/config alias OR its resolved host, so
1271
+ // accept either identity; nothing to compare against → accept.
1272
+ const echoMatches = (id, echo) => {
1273
+ if (!echo || !echo.host) return true // older host half without an echo
1274
+ const m = (machines || []).find((x) => x && x.id === id)
1275
+ if (!m) return true
1276
+ const cands = [{ host: String(m.host || ''), port: Number(m.port) || 22, username: String(m.username || '') }]
1277
+ const r = m.sshConfigResolved
1278
+ if (r && r.host) cands.push({ host: String(r.host), port: Number(r.port) || 22, username: String(r.username || m.username || '') })
1279
+ return cands.some((c) => c.host === String(echo.host) && c.port === (Number(echo.port) || 22) && c.username === String(echo.username || ''))
1280
+ }
1281
+
1282
+ // POST /current once per machine switch. The picker used to post it before
1283
+ // EVERY /ls; the host re-applies the machine on that route, which used to
1284
+ // close the live SSH connection — so each autocomplete keystroke paid a
1285
+ // full reconnect. The memo is set only on SUCCESS and concurrent callers
1286
+ // share ONE in-flight promise, so no request can overtake a pending switch
1287
+ // and read the OLD machine's listing. A failed switch REJECTS: the
1288
+ // caller's fetch chain aborts WITHOUT writing the cache.
1289
+ const ensureCurrent = (id) => {
1290
+ if (!id) return Promise.resolve()
1291
+ if (postedCurrentRef.current === id) return Promise.resolve()
1292
+ const inflight = currentPromiseRef.current
1293
+ if (inflight) {
1294
+ if (inflight.id === id) return inflight.promise
1295
+ // Serialize switches: ours must land AFTER any pending one (the host
1296
+ // applies whichever POST arrives last), never race it.
1297
+ return inflight.promise.then(() => ensureCurrent(id), () => ensureCurrent(id))
1298
+ }
1299
+ const p = api('POST', '/dsh-remote/current', { id }).then(() => {
1300
+ postedCurrentRef.current = id
1301
+ })
1302
+ const entry = { id, promise: p }
1303
+ currentPromiseRef.current = entry
1304
+ const clear = () => { if (currentPromiseRef.current === entry) currentPromiseRef.current = null }
1305
+ p.then(clear, clear)
1306
+ return p
1307
+ }
1308
+
1309
+ const LS_TTL_MS = 30000
1310
+ // Cached remote listing per (machine, dir): typing inside one parent
1311
+ // directory used to re-fetch the identical list on every keystroke.
1312
+ // `fresh` bypasses the cache (browse navigation / post-mkdir refresh) and
1313
+ // SUPERSEDES older fetches for the key: a cache write from a superseded
1314
+ // fetch is dropped (it still resolves its own callers), so a pre-mkdir
1315
+ // listing can never overwrite the post-mkdir one. Concurrent identical
1316
+ // requests share one in-flight fetch.
1317
+ const fetchDirList = (id, dirParam, fresh) => {
1318
+ const key = String(id) + '\u0000' + String(dirParam)
1319
+ const hit = lsCacheRef.current.get(key)
1320
+ if (!fresh && hit && Date.now() - hit.at < LS_TTL_MS) return Promise.resolve(hit.node)
1321
+ const inflight = fresh ? null : lsInflightRef.current.get(key)
1322
+ if (inflight) return inflight
1323
+ const gen = ++fetchSeqRef.current
1324
+ lsGenRef.current.set(key, gen)
1325
+ const toNode = (res) => {
1326
+ const real = res && res.path ? res.path : String(dirParam)
1258
1327
  // Server returns [{ type, name, size, mtime, path }] where `path` is
1259
1328
  // the FULL display-form path (C:\Users\dev on Windows) — the client
1260
1329
  // never joins paths itself, so drives and Windows paths just work.
1261
1330
  const list = Array.isArray(res && res.items)
1262
1331
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
1263
1332
  : parseLs(res && res.text)
1264
- const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
1333
+ return {
1334
+ path: real,
1335
+ platform: !!(res && res.platform === 'windows'),
1336
+ dirs: sortItems(list.filter((it) => it.dir)),
1337
+ all: sortItems(list),
1338
+ }
1339
+ }
1340
+ const run = (retried) => ensureCurrent(id)
1341
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(dirParam)))
1342
+ .then((res) => {
1343
+ // External-switch defense: on an identity-echo mismatch the host is
1344
+ // no longer on the machine this fetch intended — bust the memo, force
1345
+ // one re-switch and retry ONCE. A mismatched listing is never cached.
1346
+ if (!echoMatches(id, res && res.machine)) {
1347
+ if (retried) return toNode(res)
1348
+ postedCurrentRef.current = ''
1349
+ return run(true)
1350
+ }
1351
+ const node = toNode(res)
1352
+ if (lsGenRef.current.get(key) === gen) lsCacheRef.current.set(key, { at: Date.now(), node })
1353
+ return node
1354
+ })
1355
+ const p = run(false)
1356
+ .finally(() => { if (lsInflightRef.current.get(key) === p) lsInflightRef.current.delete(key) })
1357
+ lsInflightRef.current.set(key, p)
1358
+ return p
1359
+ }
1360
+
1361
+ // Fresh session on every open: forget everything a previous open left
1362
+ // behind. A stale in-flight response must not repopulate the cache
1363
+ // (generation fence) and a stale suggestion response must not reopen the
1364
+ // dropdown on the now-live tree.
1365
+ const resetPickerFetchState = () => {
1366
+ postedCurrentRef.current = ''
1367
+ lsCacheRef.current.clear()
1368
+ lsInflightRef.current.clear()
1369
+ lsGenRef.current.clear()
1370
+ suggestSeqRef.current++
1371
+ }
1372
+
1373
+ const loadLevels = (id, p, toIndex) => {
1374
+ if (!id) return
1375
+ setLoading(true); setErr('')
1376
+ fetchDirList(id, p || '', true)
1377
+ .then((node) => {
1378
+ setWin(node.platform)
1379
+ const level = { path: node.path, dirs: node.dirs, all: node.all }
1265
1380
  setLevels((prev) => {
1266
1381
  const base = prev && prev.length ? prev.slice() : []
1267
1382
  let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
1268
- if (idx >= base.length) return base.concat([node])
1269
- base[idx] = node
1383
+ if (idx >= base.length) return base.concat([level])
1384
+ base[idx] = level
1270
1385
  return base.slice(0, idx + 1)
1271
1386
  })
1272
1387
  })
@@ -1279,6 +1394,12 @@ window.__ModuleLoader__.load({
1279
1394
  // path — reset the path bar (and the browse state) on every open.
1280
1395
  setPath(''); setLevels(null); setWin(false)
1281
1396
  setLocalPop(null); setNewDirName('')
1397
+ // Fresh session: re-post /current on the first request and forget
1398
+ // everything a previous open left behind (TTL bounds staleness within
1399
+ // one open; reopening should see the live tree) — cached AND in-flight
1400
+ // listings, plus any pending suggestion response.
1401
+ resetPickerFetchState()
1402
+ if (suggestTimer.current) { clearTimeout(suggestTimer.current); suggestTimer.current = null }
1282
1403
  api('GET', '/dsh-remote/machines').then((r) => {
1283
1404
  setMachines(r.machines || [])
1284
1405
  const cur = (r.machines || []).find((m) => m.id === r.currentId)
@@ -1373,11 +1494,38 @@ window.__ModuleLoader__.load({
1373
1494
  if (par) loadLevels(machineId, par, 1)
1374
1495
  }
1375
1496
 
1497
+ // Drop any pending suggestion response and close the dropdown.
1498
+ const killSuggestions = () => {
1499
+ suggestSeqRef.current++
1500
+ setSuggest([])
1501
+ setSuggestOpen(false)
1502
+ }
1503
+
1504
+ // One seq-guarded "fetch listing → derive suggestion paths" pipeline
1505
+ // shared by loadSuggestions (typing: dirs matching a prefix) and
1506
+ // continueSuggest (expanding a picked dir: all its entries) — the
1507
+ // differing parts stay at the call sites as the `pick` parameter. Only
1508
+ // the newest request owns the dropdown: a slow older /ls must never
1509
+ // overwrite the suggestions for the text now in the input. The catch
1510
+ // keeps the seq-guarded empty fallback (a failed machine switch rejects
1511
+ // too, and must leave the dropdown closed and empty).
1512
+ const suggestFrom = (id, dirParam, pick, fresh) => {
1513
+ const seq = ++suggestSeqRef.current
1514
+ return fetchDirList(id, dirParam, fresh).then((node) => {
1515
+ if (seq !== suggestSeqRef.current) return
1516
+ const matches = pick(node).slice(0, 40)
1517
+ setSuggest(matches)
1518
+ setSuggestOpen(!!matches.length)
1519
+ }).catch(() => {
1520
+ if (seq !== suggestSeqRef.current) return
1521
+ setSuggest([]); setSuggestOpen(false)
1522
+ })
1523
+ }
1524
+
1376
1525
  const loadSuggestions = (raw, mid) => {
1377
1526
  const id = mid || machineId
1378
- if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
1379
1527
  const t = String(raw || '').trim()
1380
- if (!t) { setSuggest([]); setSuggestOpen(false); return }
1528
+ if (!id || !t) { killSuggestions(); return }
1381
1529
  // Separator-aware split: `\` when the input looks like a Windows path.
1382
1530
  const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
1383
1531
  const sep = backslash ? '\\' : '/'
@@ -1386,18 +1534,9 @@ window.__ModuleLoader__.load({
1386
1534
  if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
1387
1535
  else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
1388
1536
  else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
1389
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1390
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
1391
- .then((res) => {
1392
- const list = Array.isArray(res && res.items)
1393
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
1394
- : []
1395
- const matches = list
1396
- .filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
1397
- .slice(0, 40).map((it) => it.path)
1398
- setSuggest(matches)
1399
- setSuggestOpen(!!matches.length)
1400
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1537
+ suggestFrom(id, parent || '/', (node) => node.dirs
1538
+ .filter((it) => !lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
1539
+ .map((it) => it.path))
1401
1540
  }
1402
1541
 
1403
1542
  const onPathChange = (raw) => {
@@ -1407,18 +1546,13 @@ window.__ModuleLoader__.load({
1407
1546
  }
1408
1547
 
1409
1548
  const continueSuggest = (dir) => {
1410
- if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
1549
+ if (!machineId || !dir) { killSuggestions(); return }
1411
1550
  setSuggestOpen(false)
1412
- api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
1413
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
1414
- .then((res) => {
1415
- const list = Array.isArray(res && res.items)
1416
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
1417
- : []
1418
- const kids = list.slice(0, 40).map((it) => it.path)
1419
- setSuggest(kids)
1420
- setSuggestOpen(!!kids.length)
1421
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1551
+ const target = String(dir).replace(/[\\/]+$/, '') || '/'
1552
+ // Expanding a just-selected directory is a NAVIGATION step: the caching
1553
+ // spec only sanctions "typing inside one parent directory, local
1554
+ // filtering", so fetch fresh instead of serving up to 30s-stale entries.
1555
+ suggestFrom(machineId, target, (node) => node.all.map((it) => it.path), true)
1422
1556
  }
1423
1557
 
1424
1558
  const selectSuggestion = (s) => {
@@ -1434,21 +1568,34 @@ window.__ModuleLoader__.load({
1434
1568
  const commitPath = (p) => {
1435
1569
  const target = String(p || '').trim()
1436
1570
  if (!target || !machineId || busy) return
1437
- setPopOpen(false); setSuggestOpen(false)
1438
- api('POST', '/dsh-remote/mirror', { path: target }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
1571
+ setPopOpen(false); killSuggestions()
1572
+ // State the machine this pick was made on. The picker acts on the shared
1573
+ // active pool, and another actor (the settings page, or rw_connect in
1574
+ // another session) can switch it while the picker is open — the mirror
1575
+ // would then be created with THAT machine's identity while the user was
1576
+ // looking at this one's tree. The host re-asserts the named machine
1577
+ // instead of trusting whichever happens to be active.
1578
+ ensureCurrent(machineId)
1579
+ .then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId }))
1580
+ .then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || '')))
1581
+ .catch((e) => setErr(String((e && e.message) || e)))
1439
1582
  }
1440
1583
 
1441
1584
  const acceptBrowserPick = (p) => {
1442
1585
  setPath(String(p || ''))
1443
- setSuggestOpen(false)
1586
+ killSuggestions()
1444
1587
  setPopOpen(false)
1445
1588
  }
1446
1589
 
1447
1590
  const pickHome = () => {
1448
- api('POST', '/dsh-remote/home').then((r) => {
1449
- if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1450
- else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1451
- }).catch((e) => setErr(String((e && e.message) || e)))
1591
+ // Same identity contract as commitPath: /home is answered by the active
1592
+ // pool, so name the machine this picker is showing.
1593
+ ensureCurrent(machineId)
1594
+ .then(() => api('POST', '/dsh-remote/home', { machineId }))
1595
+ .then((r) => {
1596
+ if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1597
+ else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1598
+ }).catch((e) => setErr(String((e && e.message) || e)))
1452
1599
  }
1453
1600
 
1454
1601
  const mkdirHere = () => {
@@ -1584,7 +1731,7 @@ window.__ModuleLoader__.load({
1584
1731
  if (mkdirPrompt) return
1585
1732
  if (e.key === 'Escape') {
1586
1733
  if (popOpen) { e.preventDefault(); setPopOpen(false); return }
1587
- if (suggestOpen) { e.preventDefault(); setSuggestOpen(false); return }
1734
+ if (suggestOpen) { e.preventDefault(); suggestSeqRef.current++; setSuggestOpen(false); return }
1588
1735
  if (!busy) { e.preventDefault(); onCancel() }
1589
1736
  return
1590
1737
  }
package/lib/index.js CHANGED
@@ -360,18 +360,47 @@ export async function apply(ctx, config) {
360
360
  return machinePassword(m)
361
361
  }
362
362
 
363
+ /** The identity fields pool.setTarget diffs. Used to snapshot the diff
364
+ * baseline BEFORE applyMachine mutates the shared config in place. */
365
+ const targetFields = (c) => ({
366
+ host: c.host, port: c.port, username: c.username,
367
+ password: c.password, privateKeyPath: c.privateKeyPath,
368
+ passphrase: c.passphrase, workspace: c.workspace,
369
+ useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
370
+ proxy: c.proxy, hostKeyMode: c.hostKeyMode,
371
+ })
372
+
373
+ /** Fingerprint of the machine record last applied to the shared pool config.
374
+ * Re-applying an identical record is a no-op: the picker posts /current before
375
+ * every /ls and used to rewrite the registry + re-read credentials + re-point
376
+ * the pool on every autocomplete keystroke. A record change (settings edit)
377
+ * changes the fingerprint, so the next /current re-applies it. The value is
378
+ * maintained INSIDE applyActiveMachine / clearActiveMachine (not in
379
+ * setCurrent) so every apply path — boot restore, rw_connect save:true,
380
+ * POST /current — keeps the "fingerprint == applied" invariant without extra
381
+ * bookkeeping. */
382
+ let lastAppliedMachine = ''
383
+
384
+ /** Fingerprint of the registry record the shared config is bound to ('' when
385
+ * the binding is ephemeral or no machine is current). */
386
+ const appliedFingerprint = () => {
387
+ if (ephemeral || !store.currentId) return ''
388
+ const i = machineIndex(store.currentId)
389
+ return i >= 0 ? JSON.stringify(machines[i]) : ''
390
+ }
391
+
363
392
  const applyActiveMachine = async () => {
364
393
  const m = activeMachine()
365
394
  if (!m || !m.host) return
366
395
  const pw = await machinePassword(m)
396
+ // Snapshot the diff baseline BEFORE applyMachine mutates the shared config
397
+ // IN PLACE: pool.config IS this config object, so setTarget would otherwise
398
+ // compare the new values against themselves, find them equal and keep the
399
+ // OLD connection alive across a machine switch (issue #25 hazard).
400
+ const previous = targetFields(config)
367
401
  applyMachine(config, { ...m, password: pw || m.password || '' })
368
- pool.setTarget({
369
- host: config.host, port: config.port, username: config.username,
370
- password: config.password, privateKeyPath: config.privateKeyPath,
371
- passphrase: config.passphrase, workspace: config.workspace,
372
- useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
373
- proxy: config.proxy, hostKeyMode: config.hostKeyMode,
374
- })
402
+ pool.setTarget(targetFields(config), previous)
403
+ lastAppliedMachine = appliedFingerprint()
375
404
  }
376
405
 
377
406
  // ── per-machine SSH pools (session-bound) ─────────────────────────────────
@@ -501,6 +530,34 @@ export async function apply(ctx, config) {
501
530
  return created
502
531
  }
503
532
 
533
+ /**
534
+ * Re-assert the ACTIVE machine that a machine-scoped request names.
535
+ *
536
+ * The workspace picker (and the settings page's workspace flow) acts on the
537
+ * shared active pool, but it remembers its own `machineId` locally. Nothing
538
+ * on `/mirror` / `/home` / `/fs` used to carry that identity, so a switch
539
+ * performed by ANOTHER actor between the picker's last fetch and its commit —
540
+ * the settings page's "set as current", or an `rw_connect` in another agent
541
+ * session — silently redirected the commit onto that other machine. Before
542
+ * this PR the picker re-posted /current on every keystroke, which masked the
543
+ * window; the per-keystroke reconnect fix removes that accident. Let the
544
+ * request state its intent so the host can honour it atomically instead.
545
+ *
546
+ * An absent / empty `machineId` keeps the old behaviour (the caller is
547
+ * machine-scoped through the active pool by design). An UNKNOWN id is
548
+ * refused rather than ignored: quietly mirroring onto whichever machine
549
+ * happens to be active is exactly the hazard this closes.
550
+ *
551
+ * @returns {Promise<boolean>} false when the request was already answered.
552
+ */
553
+ const applyRequestedMachine = async (body, res) => {
554
+ const id = String((body && body.machineId) || '').trim()
555
+ if (!id) return true
556
+ if (await setCurrent(id)) return true
557
+ sendJson(res, 404, { ok: false, error: `machine not found: ${id}` })
558
+ return false
559
+ }
560
+
504
561
  const setCurrent = async (id) => {
505
562
  if (!id) {
506
563
  // Explicit "active remote = none": saved machines stay in the registry
@@ -513,6 +570,11 @@ export async function apply(ctx, config) {
513
570
  }
514
571
  const i = machineIndex(id)
515
572
  if (i < 0) return false
573
+ // Idempotent: same machine, no ephemeral override, already applied → keep
574
+ // the registry file, the credentials cache and the live pool untouched.
575
+ // (The fingerprint is maintained by applyActiveMachine itself.)
576
+ const fingerprint = JSON.stringify(machines[i])
577
+ if (store.currentId === id && !ephemeral && config.host && lastAppliedMachine === fingerprint) return true
516
578
  store.currentId = id
517
579
  ephemeral = null
518
580
  saveMachines(machines, id)
@@ -525,7 +587,13 @@ export async function apply(ctx, config) {
525
587
  * tool fallback, but no machine workspace leaks into this session. */
526
588
  const clearActiveMachine = async () => {
527
589
  ephemeral = null
590
+ // Nothing is applied anymore — reset the applied-machine fingerprint too.
591
+ lastAppliedMachine = ''
528
592
  if (!config.host) return
593
+ // Snapshot the diff baseline BEFORE zeroing: pool.config IS this config
594
+ // object, so setTarget would otherwise see an already-zeroed config and
595
+ // keep the old connection alive ("active remote = none" must disconnect).
596
+ const previous = targetFields(config)
529
597
  // Clear host + workspace directly (applyMachine keeps the old workspace
530
598
  // when the incoming value is empty — it must not be used for a full reset).
531
599
  config.host = ''
@@ -533,7 +601,7 @@ export async function apply(ctx, config) {
533
601
  config.username = ''
534
602
  config.password = ''
535
603
  config.workspace = ''
536
- pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
604
+ pool.setTarget({ host: '', port: 22, username: '', workspace: '' }, previous)
537
605
  // Also stop any auto-push watcher bound to the old machine workspace.
538
606
  // (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
539
607
  if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
@@ -1180,9 +1248,17 @@ export async function apply(ctx, config) {
1180
1248
  saveMachines(machines, store.currentId)
1181
1249
  }
1182
1250
  ephemeral = null
1251
+ // rw_connect means "connect now": force a fresh dial even for an
1252
+ // identical target (setTarget alone would reuse a possibly half-open
1253
+ // cached client — same reconnect-probe reasoning as /connect).
1254
+ pool.invalidate()
1183
1255
  await applyActiveMachine()
1184
1256
  } else {
1185
1257
  ephemeral = { id: machineId(), name: host, ...rec }
1258
+ // rw_connect means "connect now": force a fresh dial even for an
1259
+ // identical target (setTarget alone would reuse a possibly half-open
1260
+ // cached client — same reconnect-probe reasoning as /connect).
1261
+ pool.invalidate()
1186
1262
  pool.setTarget({
1187
1263
  host: eff.host, port: effPort, username: effUser,
1188
1264
  password: rec.password, privateKeyPath: eff.privateKeyPath || '',
@@ -2181,6 +2257,12 @@ export async function apply(ctx, config) {
2181
2257
  let payload = {}
2182
2258
  try {
2183
2259
  payload = parseJsonObject(await readBody(req))
2260
+ // This route is a reconnect PROBE ("Connect" button): with the
2261
+ // unchanged-target no-op in setTarget, an identical target would
2262
+ // reuse a possibly half-open cached client and the probe could hang.
2263
+ // invalidate() is the design's forced-reconnect escape hatch — drop
2264
+ // the cached client so the `echo ok` below always re-dials.
2265
+ pool.invalidate()
2184
2266
  pool.setTarget({
2185
2267
  host: payload.host,
2186
2268
  port: payload.port,
@@ -2230,6 +2312,11 @@ export async function apply(ctx, config) {
2230
2312
  const p = normalizeRemotePath(String(payload.path || ''))
2231
2313
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2232
2314
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2315
+ // The picker names the machine it was browsing. Re-assert it so a
2316
+ // switch made elsewhere between the listing and this commit cannot
2317
+ // mirror onto a machine the user never looked at (the picker sends
2318
+ // /current only once per machine now, so nothing else re-applies it).
2319
+ if (!(await applyRequestedMachine(payload, res))) return
2233
2320
  const okDir = await isRemoteDir(p)
2234
2321
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
2235
2322
  const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
@@ -2659,6 +2746,11 @@ export async function apply(ctx, config) {
2659
2746
  handler: async (req, res) => {
2660
2747
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2661
2748
  try {
2749
+ const body = parseJsonObject(await readBody(req))
2750
+ // The picker names the machine it is showing: re-assert it before
2751
+ // answering, so a switch made elsewhere cannot make /home describe a
2752
+ // different host than the tree the user is looking at.
2753
+ if (!(await applyRequestedMachine(body, res))) return
2662
2754
  const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
2663
2755
  const home = String(out.stdout || '').replace(/\s+/g, '').trim()
2664
2756
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
package/lib/pool.js CHANGED
@@ -41,24 +41,48 @@ export class SshPool {
41
41
  return p
42
42
  }
43
43
 
44
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
45
- if (host !== undefined) this.config.host = String(host)
46
- if (port !== undefined && Number(port)) this.config.port = Number(port)
47
- if (username !== undefined) this.config.username = String(username)
48
- if (password !== undefined && password !== null) this.config.password = String(password)
49
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
50
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
51
- if (workspace !== undefined) this.config.workspace = String(workspace)
52
- if (useAgent !== undefined) this.config.useAgent = !!useAgent
53
- if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
54
- if (proxy !== undefined) this.config.proxy = proxy
55
- if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
56
- // the new target may be a different OS — re-detect on the next command
57
- this.platform = 'unknown'
58
- this.gitBashPath = ''
59
- this.shellMode = 'native'
60
- this._detecting = null
61
- this.close()
44
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
45
+ // Collect the requested changes first: an UNCHANGED target is a no-op. The
46
+ // old behavior closed the live connection and re-ran platform detection
47
+ // even for an identical target, so every picker keystroke
48
+ // (POST /current → applyActiveMachine → setTarget) paid a full SSH
49
+ // reconnect + re-detect — the "slow remote path autocomplete" bug.
50
+ //
51
+ // `previous` is the diff BASELINE. Callers whose shared config was already
52
+ // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
53
+ // live config IN PLACE, and pool.config IS that config object) pass a
54
+ // pre-mutation snapshot — otherwise the comparison runs the new values
55
+ // against themselves, is always equal and never closes even for a machine
56
+ // switch (the wrong-host hazard of issue #25). Defaults to the current
57
+ // config for callers that pass fresh values read independently of it.
58
+ const base = previous || this.config
59
+ const wanted = []
60
+ if (host !== undefined) wanted.push(['host', String(host)])
61
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
62
+ if (username !== undefined) wanted.push(['username', String(username)])
63
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
64
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
65
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
66
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
67
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
68
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
69
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
70
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
71
+ const differs = ([k, v]) => (k === 'proxy'
72
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
73
+ : base[k] !== v)
74
+ // Something actually changed → apply it and force a fresh connection.
75
+ // (A caller that WANTS a reconnect for an identical target uses
76
+ // invalidate(); setTarget semantics stay "re-point when moved".)
77
+ if (wanted.some(differs)) {
78
+ for (const [k, v] of wanted) this.config[k] = v
79
+ // the new target may be a different OS — re-detect on the next command
80
+ this.platform = 'unknown'
81
+ this.gitBashPath = ''
82
+ this.shellMode = 'native'
83
+ this._detecting = null
84
+ this.close()
85
+ }
62
86
  return this
63
87
  }
64
88
 
@@ -484,6 +508,13 @@ export class SshPool {
484
508
  if (client) {
485
509
  try { client.end() } catch {}
486
510
  }
511
+ // Same teardown as close(): the forwards manager must detach from the
512
+ // dying client, or its stale `close -> stopAll()` listener later tears
513
+ // down forwards that were re-attached to the NEW client (onReady does
514
+ // the re-attach on the next successful connect).
515
+ if (this.onCloseHook) {
516
+ try { this.onCloseHook() } catch {}
517
+ }
487
518
  }
488
519
 
489
520
  close() {
package/lib/routes-fs.js CHANGED
@@ -40,6 +40,17 @@ export function createFsRoutes({
40
40
  const hint = requestSessionHint(req)
41
41
  const b = await bindOrReply(req, res, hint)
42
42
  if (!b) return
43
+ // Identity echo (external-switch defense): name the machine this
44
+ // listing is answered for, straight from the pool's target — the
45
+ // picker validates every listing against the machine it INTENDED and
46
+ // re-switches on mismatch (the settings page can change the active
47
+ // machine under a memoized picker).
48
+ const tgt = (b.pool && b.pool.config) || {}
49
+ const machine = {
50
+ host: String(tgt.host || b.host || ''),
51
+ port: Number(tgt.port) || Number(b.port) || 22,
52
+ username: String(tgt.username || b.username || ''),
53
+ }
43
54
  await b.pool.detect()
44
55
  const q = new URL(req.url, 'http://localhost').searchParams
45
56
  const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
@@ -48,9 +59,9 @@ export function createFsRoutes({
48
59
  if (canon === '/' || raw === '' || raw === '/') {
49
60
  if (win || b.pool.platform !== 'posix') {
50
61
  const drives = await listRemoteDrives(b.pool)
51
- if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
62
+ if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
52
63
  }
53
- if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
64
+ if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
54
65
  }
55
66
  const sftp = await b.pool.sftp()
56
67
  const out = await listDirStructured(sftp, canon)
@@ -64,6 +75,7 @@ export function createFsRoutes({
64
75
  items,
65
76
  missing: !!out.missing,
66
77
  bound: b.bound,
78
+ machine,
67
79
  })
68
80
  } catch (err) {
69
81
  return sendJson(res, 500, { error: String((err && err.message) || err) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.22",
3
+ "version": "0.8.23",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",