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 +192 -45
- package/lib/index.js +100 -8
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- package/package.json +1 -1
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
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([
|
|
1269
|
-
base[idx] =
|
|
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) {
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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) {
|
|
1549
|
+
if (!machineId || !dir) { killSuggestions(); return }
|
|
1411
1550
|
setSuggestOpen(false)
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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);
|
|
1438
|
-
|
|
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
|
-
|
|
1586
|
+
killSuggestions()
|
|
1444
1587
|
setPopOpen(false)
|
|
1445
1588
|
}
|
|
1446
1589
|
|
|
1447
1590
|
const pickHome = () => {
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// the
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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>",
|