dsh-remote 0.8.7 → 0.8.9
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/README.md +4 -1
- package/README.zh.md +13 -1
- package/lib/client.js +295 -92
- package/lib/index.js +412 -79
- package/lib/paths.js +48 -0
- package/lib/registry.js +89 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import iconv from 'iconv-lite'
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
|
|
33
|
-
toSftpPath, remotePathBase, truncate, shortHash, relPathUnder,
|
|
33
|
+
toSftpPath, toShellPath, toDisplayPath, remotePathBase, truncate, shortHash, relPathUnder,
|
|
34
34
|
} from './paths.js'
|
|
35
35
|
import { blobAlgorithm, keyFingerprint } from './hostkey.js'
|
|
36
36
|
import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
|
|
@@ -42,6 +42,7 @@ import { searchTree } from './search.js'
|
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
43
|
import { ForwardManager } from './forwards.js'
|
|
44
44
|
import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
|
|
45
|
+
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
45
46
|
|
|
46
47
|
const { Client } = ssh2
|
|
47
48
|
|
|
@@ -67,6 +68,12 @@ export const Config = z.object({
|
|
|
67
68
|
passphrase: z.string().default(''),
|
|
68
69
|
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
69
70
|
workspace: z.string().default(''),
|
|
71
|
+
/** Remote command terminal strategy. '' = auto-detect (Windows remotes look
|
|
72
|
+
* for Git Bash and pipe every command through `bash -s`); 'git-bash' = prefer
|
|
73
|
+
* Git Bash on Windows; 'native' = never wrap; any other value = explicit
|
|
74
|
+
* bash.exe path (e.g. 'C:\\Program Files\\Git\\bin\\bash.exe'). Git Bash makes
|
|
75
|
+
* a Windows remote behave like a POSIX host (/c/Users/... paths). */
|
|
76
|
+
shell: z.string().default(''),
|
|
70
77
|
/** Per-command timeout. */
|
|
71
78
|
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
72
79
|
/** SSH connection establishment timeout. */
|
|
@@ -220,6 +227,9 @@ function migrateLegacyData() {
|
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
230
|
+
// Pure registry logic lives in lib/registry.js (unit-tested): saved machines
|
|
231
|
+
// are STANDBY connections; only an explicit "set current" activates one.
|
|
232
|
+
// (issue #13 — Saved Connections != Active Remote Context)
|
|
223
233
|
const MACHINES_FILE = 'machines.json'
|
|
224
234
|
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
225
235
|
const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
|
|
@@ -227,40 +237,11 @@ const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
|
|
|
227
237
|
const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
|
|
228
238
|
const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
|
|
229
239
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return { list: [], currentId: null }
|
|
236
|
-
}
|
|
237
|
-
function saveMachines(list, currentId) {
|
|
238
|
-
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
239
|
-
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
240
|
-
}
|
|
241
|
-
function sanitizeMachine(m) {
|
|
242
|
-
if (!m) return m
|
|
243
|
-
const { password, proxy, ...rest } = m
|
|
244
|
-
const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
245
|
-
if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
|
|
246
|
-
return out
|
|
247
|
-
}
|
|
248
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
249
|
-
function applyMachine(config, m) {
|
|
250
|
-
if (!m) return
|
|
251
|
-
config.host = m.host
|
|
252
|
-
config.port = Number(m.port) || 22
|
|
253
|
-
config.username = m.username || ''
|
|
254
|
-
config.password = m.password || ''
|
|
255
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
256
|
-
config.passphrase = m.passphrase || ''
|
|
257
|
-
config.workspace = m.workspace || (config.workspace || '')
|
|
258
|
-
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
259
|
-
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
260
|
-
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
261
|
-
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
262
|
-
}
|
|
263
|
-
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
240
|
+
const loadMachines = () => _loadMachines(machinesFile())
|
|
241
|
+
const saveMachines = (list, currentId, keepCurrentKey) => _saveMachines(machinesFile(), list, currentId, keepCurrentKey)
|
|
242
|
+
const sanitizeMachine = _sanitizeMachine
|
|
243
|
+
const applyMachine = _applyMachine
|
|
244
|
+
const machineId = _machineId
|
|
264
245
|
|
|
265
246
|
// ── host-key registry (TOFU) ──────────────────────────────────────────────
|
|
266
247
|
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
@@ -331,6 +312,14 @@ class SshPool {
|
|
|
331
312
|
// Generational token: bumped on every target change / close so a stale
|
|
332
313
|
// in-flight connect can never hand this pool a connection to an old host.
|
|
333
314
|
this.epoch = 0
|
|
315
|
+
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
316
|
+
this.platform = 'unknown'
|
|
317
|
+
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
318
|
+
this.gitBashPath = ''
|
|
319
|
+
/** Resolved terminal strategy: native | git-bash. */
|
|
320
|
+
this.shellMode = 'native'
|
|
321
|
+
/** In-flight platform detection promise (cached). */
|
|
322
|
+
this._detecting = null
|
|
334
323
|
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
335
324
|
this.passwordResolver = null
|
|
336
325
|
/** Optional hook called with the live client after a successful connect. */
|
|
@@ -358,6 +347,11 @@ class SshPool {
|
|
|
358
347
|
if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
|
|
359
348
|
if (proxy !== undefined) this.config.proxy = proxy
|
|
360
349
|
if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
|
|
350
|
+
// the new target may be a different OS — re-detect on the next command
|
|
351
|
+
this.platform = 'unknown'
|
|
352
|
+
this.gitBashPath = ''
|
|
353
|
+
this.shellMode = 'native'
|
|
354
|
+
this._detecting = null
|
|
361
355
|
this.close()
|
|
362
356
|
return this
|
|
363
357
|
}
|
|
@@ -495,25 +489,133 @@ class SshPool {
|
|
|
495
489
|
})
|
|
496
490
|
}
|
|
497
491
|
|
|
492
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
493
|
+
detect() {
|
|
494
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
495
|
+
if (this._detecting) return this._detecting
|
|
496
|
+
this._detecting = this._detect().finally(() => {
|
|
497
|
+
this._detecting = null
|
|
498
|
+
})
|
|
499
|
+
return this._detecting
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async _detect() {
|
|
503
|
+
let res
|
|
504
|
+
try {
|
|
505
|
+
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
506
|
+
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
507
|
+
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.platform = 'unknown'
|
|
510
|
+
this.shellMode = 'native'
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
514
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
515
|
+
this.platform = 'windows'
|
|
516
|
+
await this._resolveGitBash()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
520
|
+
try {
|
|
521
|
+
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
522
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
523
|
+
this.platform = 'windows'
|
|
524
|
+
await this._resolveGitBash()
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
} catch {}
|
|
528
|
+
this.platform = 'posix'
|
|
529
|
+
this.shellMode = 'native'
|
|
530
|
+
this.gitBashPath = ''
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
534
|
+
async _resolveGitBash() {
|
|
535
|
+
const cfg = String(this.config.shell || '').trim()
|
|
536
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
537
|
+
if (await this._cmdExists(cfg)) {
|
|
538
|
+
this.gitBashPath = cfg
|
|
539
|
+
this.shellMode = 'git-bash'
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (cfg === 'native') {
|
|
544
|
+
this.shellMode = 'native'
|
|
545
|
+
this.gitBashPath = ''
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
550
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
551
|
+
if (first) {
|
|
552
|
+
this.gitBashPath = first
|
|
553
|
+
this.shellMode = 'git-bash'
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
} catch {}
|
|
557
|
+
const candidates = [
|
|
558
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
559
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
560
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
561
|
+
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
562
|
+
]
|
|
563
|
+
for (const c of candidates) {
|
|
564
|
+
if (await this._cmdExists(c)) {
|
|
565
|
+
this.gitBashPath = c
|
|
566
|
+
this.shellMode = 'git-bash'
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
571
|
+
this.gitBashPath = ''
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async _cmdExists(p) {
|
|
575
|
+
try {
|
|
576
|
+
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
577
|
+
return /Y/.test(String(r.stdout || ''))
|
|
578
|
+
} catch {
|
|
579
|
+
return false
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
498
583
|
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
499
|
-
*
|
|
584
|
+
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
585
|
+
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
586
|
+
* backslashes, newlines) survives verbatim. */
|
|
500
587
|
exec(command, timeoutMsOrOpts) {
|
|
501
588
|
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
502
|
-
|
|
589
|
+
return this.detect().then(() => {
|
|
590
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
591
|
+
const script = String(command)
|
|
592
|
+
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
593
|
+
try { stream.end(script) } catch {}
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
return this._execRaw(command, opts)
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
601
|
+
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
602
|
+
_execRaw(command, opts, stdinWriter) {
|
|
603
|
+
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
503
604
|
return this.connect().then(
|
|
504
605
|
(client) =>
|
|
505
606
|
new Promise((resolve, reject) => {
|
|
506
607
|
let retried = false
|
|
507
608
|
const runOn = (c) => {
|
|
508
609
|
const execOpts = {}
|
|
509
|
-
if (opts.pty) execOpts.pty = true
|
|
510
|
-
if (opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
610
|
+
if (opts && opts.pty) execOpts.pty = true
|
|
611
|
+
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
511
612
|
c.exec(command, execOpts, (err, stream) => {
|
|
512
613
|
if (err) {
|
|
513
|
-
// Channel-open failure
|
|
514
|
-
// server-side (idle timeout /
|
|
515
|
-
// hadn't noticed. Drop it and
|
|
516
|
-
|
|
614
|
+
// Channel-open failure — or a session termination — usually
|
|
615
|
+
// means the pooled connection died server-side (idle timeout /
|
|
616
|
+
// network reset) while keepalive hadn't noticed. Drop it and
|
|
617
|
+
// retry ONCE on a fresh connection.
|
|
618
|
+
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
517
619
|
retried = true
|
|
518
620
|
this.invalidate()
|
|
519
621
|
return this.connect().then(
|
|
@@ -574,6 +676,9 @@ class SshPool {
|
|
|
574
676
|
clearTimeout(timer)
|
|
575
677
|
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
576
678
|
})
|
|
679
|
+
if (stdinWriter) {
|
|
680
|
+
try { stdinWriter(stream) } catch {}
|
|
681
|
+
}
|
|
577
682
|
})
|
|
578
683
|
}
|
|
579
684
|
runOn(client)
|
|
@@ -591,9 +696,11 @@ class SshPool {
|
|
|
591
696
|
c.sftp((err, sftp) => {
|
|
592
697
|
if (err) {
|
|
593
698
|
// Same dead-connection recovery as exec(): a channel open
|
|
594
|
-
// failure
|
|
699
|
+
// failure — or a session termination (remote closed the
|
|
700
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
701
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
595
702
|
// retry ONCE on a fresh connection.
|
|
596
|
-
if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
703
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
597
704
|
retried = true
|
|
598
705
|
this.invalidate()
|
|
599
706
|
return this.connect().then(
|
|
@@ -753,6 +860,15 @@ export async function apply(ctx, config) {
|
|
|
753
860
|
}
|
|
754
861
|
|
|
755
862
|
const setCurrent = async (id) => {
|
|
863
|
+
if (!id) {
|
|
864
|
+
// Explicit "active remote = none": saved machines stay in the registry
|
|
865
|
+
// (issue #13) but nothing is bound to the pool anymore.
|
|
866
|
+
store.currentId = null
|
|
867
|
+
ephemeral = null
|
|
868
|
+
saveMachines(machines, null)
|
|
869
|
+
await clearActiveMachine()
|
|
870
|
+
return true
|
|
871
|
+
}
|
|
756
872
|
const i = machineIndex(id)
|
|
757
873
|
if (i < 0) return false
|
|
758
874
|
store.currentId = id
|
|
@@ -762,10 +878,62 @@ export async function apply(ctx, config) {
|
|
|
762
878
|
return true
|
|
763
879
|
}
|
|
764
880
|
|
|
765
|
-
|
|
881
|
+
/** Unbind the pool + live config from any machine: the plugin starts in a
|
|
882
|
+
* pure "no remote context" state (issue #13). The host stays configured for
|
|
883
|
+
* tool fallback, but no machine workspace leaks into this session. */
|
|
884
|
+
const clearActiveMachine = async () => {
|
|
885
|
+
ephemeral = null
|
|
886
|
+
if (!config.host) return
|
|
887
|
+
// Clear host + workspace directly (applyMachine keeps the old workspace
|
|
888
|
+
// when the incoming value is empty — it must not be used for a full reset).
|
|
889
|
+
config.host = ''
|
|
890
|
+
config.port = 22
|
|
891
|
+
config.username = ''
|
|
892
|
+
config.password = ''
|
|
893
|
+
config.workspace = ''
|
|
894
|
+
pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
|
|
895
|
+
// Also stop any auto-push watcher bound to the old machine workspace.
|
|
896
|
+
// (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
|
|
897
|
+
if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
|
|
898
|
+
for (const [, entry] of autoPushWatchers) {
|
|
899
|
+
if (!entry) continue
|
|
900
|
+
try { entry.watcher && entry.watcher.close() } catch {}
|
|
901
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
902
|
+
}
|
|
903
|
+
autoPushWatchers.clear()
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// If no stored current, adopt a CLI-provided default as the active machine —
|
|
908
|
+
// UNLESS the user explicitly cleared the current machine (currentId: null in
|
|
909
|
+
// the registry = "active remote = none", issue #13). A registry that exists
|
|
910
|
+
// with currentId null must stay inert; only a fresh/absent registry lets the
|
|
911
|
+
// config host serve as the bootstrap default.
|
|
766
912
|
{
|
|
767
913
|
const cur = currentMachine()
|
|
768
|
-
|
|
914
|
+
const registryExplicitNone = store.explicitNone
|
|
915
|
+
if (cur && cur.host && !store.currentId && !registryExplicitNone) applyMachine(config, cur)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── auto-restore the last active machine (best-effort) ───────────────────
|
|
919
|
+
// When the harness (re)starts, bring back the machine that was "current"
|
|
920
|
+
// last time: apply its config to the pool and probe a real SSH connection so
|
|
921
|
+
// the sidebar shows connected instead of a disconnected 500-spamming state.
|
|
922
|
+
// Failures are swallowed — an unreachable host must never break plugin boot
|
|
923
|
+
// (the sidebar will show「未连接」and the user can reconnect or set current).
|
|
924
|
+
if (store.currentId) {
|
|
925
|
+
const i = machineIndex(store.currentId)
|
|
926
|
+
if (i >= 0 && machines[i] && machines[i].host) {
|
|
927
|
+
setImmediate(async () => {
|
|
928
|
+
try {
|
|
929
|
+
await applyActiveMachine()
|
|
930
|
+
await pool.exec('echo dsh-remote-restore', { timeoutMs: Math.min(config.commandTimeoutMs || 20000, 15000) })
|
|
931
|
+
} catch {
|
|
932
|
+
// Offline / bad credentials: leave pool unconnected; UI falls back
|
|
933
|
+
// to the "未连接" state and the user can reconnect manually.
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
}
|
|
769
937
|
}
|
|
770
938
|
|
|
771
939
|
/** Persist the active workspace on the machine the pool is actually bound to
|
|
@@ -905,7 +1073,14 @@ export async function apply(ctx, config) {
|
|
|
905
1073
|
try {
|
|
906
1074
|
list = await sftp.readdir(target)
|
|
907
1075
|
} catch (err) {
|
|
908
|
-
|
|
1076
|
+
// Missing directory / deleted folder: not an internal error. Report it
|
|
1077
|
+
// distinctly so the UI can show「目录不存在」without treating it as a
|
|
1078
|
+
// hard failure (500) that collapses the whole tree.
|
|
1079
|
+
const msg = String((err && err.message) || err)
|
|
1080
|
+
if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
|
|
1081
|
+
return { path: target, items: [], missing: true }
|
|
1082
|
+
}
|
|
1083
|
+
throw new Error('browse failed: ' + msg)
|
|
909
1084
|
}
|
|
910
1085
|
const items = []
|
|
911
1086
|
const symIdx = []
|
|
@@ -943,6 +1118,29 @@ export async function apply(ctx, config) {
|
|
|
943
1118
|
return { path: target, items }
|
|
944
1119
|
}
|
|
945
1120
|
|
|
1121
|
+
/** Windows drive letters as display-form dir entries (the "This PC" root view).
|
|
1122
|
+
* `cmd /c fsutil fsinfo drives` works under cmd/PowerShell/Git Bash and prints
|
|
1123
|
+
* e.g. "Drives: C:\\ D:\\ E:\\"; falls back to listing Git-Bash mount points
|
|
1124
|
+
* (/c /d …). Returns [] on POSIX hosts or when enumeration fails. */
|
|
1125
|
+
const listRemoteDrives = async () => {
|
|
1126
|
+
let letters = []
|
|
1127
|
+
try {
|
|
1128
|
+
const res = await pool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1129
|
+
letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
|
|
1130
|
+
} catch {}
|
|
1131
|
+
if (!letters.length) {
|
|
1132
|
+
try {
|
|
1133
|
+
const res = await pool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1134
|
+
letters = String(res.stdout || '')
|
|
1135
|
+
.split(/\s+/)
|
|
1136
|
+
.map((s) => s.replace(/^\/+|\/+$/g, ''))
|
|
1137
|
+
.filter((s) => /^[a-zA-Z]$/.test(s))
|
|
1138
|
+
.map((s) => s.toUpperCase())
|
|
1139
|
+
} catch {}
|
|
1140
|
+
}
|
|
1141
|
+
return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
|
|
1142
|
+
}
|
|
1143
|
+
|
|
946
1144
|
const isRemoteDir = async (p) => {
|
|
947
1145
|
const target = normalizeRemotePath(p)
|
|
948
1146
|
try {
|
|
@@ -980,24 +1178,84 @@ export async function apply(ctx, config) {
|
|
|
980
1178
|
|
|
981
1179
|
// ── remote workspace state ────────────────────────────────────────────────
|
|
982
1180
|
const wsPath = () => (config.workspace || '').trim()
|
|
1181
|
+
/** Map a LOCAL path to the remote path its mirror represents ('' when the
|
|
1182
|
+
* path is not inside any dsh-remote mirror). Shared by the resolve-mirror
|
|
1183
|
+
* route, the session-aware system-prompt injection, and status(). */
|
|
1184
|
+
const resolveMirrorForLocal = (local) => {
|
|
1185
|
+
const root = remoteWorkspacesRoot()
|
|
1186
|
+
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
1187
|
+
let matched = ''
|
|
1188
|
+
let matchedRemote = ''
|
|
1189
|
+
if (local && existsSync(root)) {
|
|
1190
|
+
const base = norm(local)
|
|
1191
|
+
for (const hostDir of readdirSync(root)) {
|
|
1192
|
+
const hostPath = path.join(root, hostDir)
|
|
1193
|
+
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1194
|
+
for (const mirrorDir of readdirSync(hostPath)) {
|
|
1195
|
+
const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
|
|
1196
|
+
if (!existsSync(metaPath)) continue
|
|
1197
|
+
try {
|
|
1198
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1199
|
+
const mirrorAbs = norm(path.join(hostPath, mirrorDir))
|
|
1200
|
+
if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
|
|
1201
|
+
if (mirrorAbs.length > matched.length) {
|
|
1202
|
+
matched = mirrorAbs
|
|
1203
|
+
matchedRemote = String(meta.remotePath || '')
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
} catch { /* skip unparsable meta */ }
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return { mirrorDir: matched || null, remotePath: matchedRemote || '' }
|
|
1211
|
+
}
|
|
983
1212
|
if (config.autoPush && wsPath()) {
|
|
984
1213
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
985
1214
|
}
|
|
1215
|
+
/** The remote path the CURRENT agent session is really bound to, or '' when
|
|
1216
|
+
* the session is a plain local one. Walks the mirror registry (host side
|
|
1217
|
+
* copy of the resolve-mirror logic) so status / prompt / sidebar agree.
|
|
1218
|
+
* `sessionId` narrows to one session; without it the first live session's
|
|
1219
|
+
* cwd is used (best effort — status is informational, the per-session
|
|
1220
|
+
* resolve-mirror endpoint is authoritative). */
|
|
1221
|
+
const sessionRemotePath = (sessionId) => {
|
|
1222
|
+
let cwd = ''
|
|
1223
|
+
try {
|
|
1224
|
+
const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
|
|
1225
|
+
if (sessions && typeof sessions.get === 'function' && sessionId) {
|
|
1226
|
+
const s = sessions.get(sessionId)
|
|
1227
|
+
if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
|
|
1228
|
+
} else if (sessions && typeof sessions.list === 'function') {
|
|
1229
|
+
const s = sessions.list()[0]
|
|
1230
|
+
if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
|
|
1231
|
+
}
|
|
1232
|
+
} catch { /* sessions service unavailable */ }
|
|
1233
|
+
if (!cwd) return ''
|
|
1234
|
+
return resolveMirrorForLocal(cwd).remotePath
|
|
1235
|
+
}
|
|
986
1236
|
const status = () => ({
|
|
987
1237
|
host: config.host,
|
|
988
1238
|
port: config.port,
|
|
989
1239
|
username: config.username,
|
|
990
1240
|
connected: !!pool.client,
|
|
991
|
-
workspace: wsPath(),
|
|
1241
|
+
workspace: toDisplayPath(wsPath(), pool.platform),
|
|
992
1242
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
993
1243
|
currentId: store.currentId || null,
|
|
994
1244
|
activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
|
|
1245
|
+
// Issue #13: whether THIS session is in remote mode (cwd inside a mirror).
|
|
1246
|
+
// `none`/`local` means the plugin is present but no remote context is
|
|
1247
|
+
// active — saved machines stay standby-only.
|
|
1248
|
+
sessionMode: sessionRemotePath() ? 'remote' : (store.currentId ? 'standby' : 'local'),
|
|
1249
|
+
sessionRemotePath: sessionRemotePath(),
|
|
995
1250
|
machines: machines.map(sanitizeMachine),
|
|
996
1251
|
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
997
1252
|
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
998
1253
|
forwards: forwards.list(),
|
|
999
1254
|
auditEnabled: !!config.auditLog,
|
|
1000
1255
|
backend: platformBackend(),
|
|
1256
|
+
platform: pool.platform,
|
|
1257
|
+
shell: pool.shellMode,
|
|
1258
|
+
gitBash: pool.gitBashPath || '',
|
|
1001
1259
|
})
|
|
1002
1260
|
|
|
1003
1261
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
@@ -1015,16 +1273,18 @@ export async function apply(ctx, config) {
|
|
|
1015
1273
|
defineTool({
|
|
1016
1274
|
name: 'rw_info',
|
|
1017
1275
|
description:
|
|
1018
|
-
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
|
|
1276
|
+
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity. Note: remote context is session-scoped — a saved machine only becomes active when you explicitly rw_connect to it (issue #13).',
|
|
1019
1277
|
parameters: {},
|
|
1020
1278
|
output: textOut,
|
|
1021
1279
|
async execute() {
|
|
1022
1280
|
const s = status()
|
|
1023
1281
|
const lines = [
|
|
1024
1282
|
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
|
|
1025
|
-
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
1283
|
+
`Current remote workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
|
|
1026
1284
|
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
1285
|
+
`Session remote context: ${s.sessionRemotePath ? s.sessionRemotePath : '(this session is LOCAL — no remote workspace bound)'}`,
|
|
1027
1286
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
1287
|
+
`Remote shell: ${s.platform === 'windows' ? (s.gitBash ? 'Git Bash (' + s.gitBash + ')' : 'Windows (Git Bash not found — set config.shell to a bash.exe path)') : (s.platform === 'posix' ? 'native POSIX' : 'detecting…')}`,
|
|
1028
1288
|
`Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
|
|
1029
1289
|
`Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
1030
1290
|
'',
|
|
@@ -1111,21 +1371,22 @@ export async function apply(ctx, config) {
|
|
|
1111
1371
|
defineTool({
|
|
1112
1372
|
name: 'rw_pick_workspace',
|
|
1113
1373
|
description:
|
|
1114
|
-
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
|
|
1374
|
+
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure. Accepts POSIX (/home/dev/project) or Windows (C:\\Users\\dev\\project) paths.',
|
|
1115
1375
|
parameters: {
|
|
1116
|
-
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
1376
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project or C:\\Users\\dev\\project' },
|
|
1117
1377
|
},
|
|
1118
1378
|
output: textOut,
|
|
1119
1379
|
async execute(args) {
|
|
1120
1380
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1121
1381
|
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
1122
1382
|
const ok = await isRemoteDir(p)
|
|
1123
|
-
|
|
1383
|
+
const shown = toDisplayPath(p, pool.platform)
|
|
1384
|
+
if (!ok) return { text: `not a directory (or missing) on ${shown}` }
|
|
1124
1385
|
persistWorkspace(p)
|
|
1125
1386
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1126
1387
|
startAutoPush(local)
|
|
1127
1388
|
return {
|
|
1128
|
-
text: `Remote workspace set to ${
|
|
1389
|
+
text: `Remote workspace set to ${shown} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
1129
1390
|
}
|
|
1130
1391
|
},
|
|
1131
1392
|
}),
|
|
@@ -1233,7 +1494,7 @@ export async function apply(ctx, config) {
|
|
|
1233
1494
|
defineTool({
|
|
1234
1495
|
name: 'rw_list_dir',
|
|
1235
1496
|
description:
|
|
1236
|
-
'List a remote directory (or a single file) via SSH. Path is absolute;
|
|
1497
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; accepts Windows paths like C:\\Users\\dev\\project on Git Bash remotes. If omitted, lists the current remote workspace. Shows type, size, mtime.',
|
|
1237
1498
|
parameters: {
|
|
1238
1499
|
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1239
1500
|
},
|
|
@@ -1515,7 +1776,15 @@ export async function apply(ctx, config) {
|
|
|
1515
1776
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1516
1777
|
const ws = wsPath()
|
|
1517
1778
|
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1518
|
-
|
|
1779
|
+
let full = cmd
|
|
1780
|
+
if (cwd) {
|
|
1781
|
+
if (pool.platform === 'windows' && pool.gitBashPath) {
|
|
1782
|
+
// Git Bash terminal: cd in the /c/Users/… mount form
|
|
1783
|
+
full = `cd ${shq(toShellPath(cwd))} && ${cmd}`
|
|
1784
|
+
} else if (!cwd.includes('\\')) {
|
|
1785
|
+
full = `cd ${shq(cwd)} && ${cmd}`
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1519
1788
|
try {
|
|
1520
1789
|
const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
|
|
1521
1790
|
audit('exec', cmd, res.code)
|
|
@@ -1689,19 +1958,30 @@ export async function apply(ctx, config) {
|
|
|
1689
1958
|
ctx.tools.register(t)
|
|
1690
1959
|
}
|
|
1691
1960
|
|
|
1692
|
-
// ── system-prompt injection:
|
|
1961
|
+
// ── system-prompt injection: session-aware remote workspace ──────────────
|
|
1962
|
+
// Issue #13: remote context must be SESSION-scoped, not machine-global.
|
|
1963
|
+
// The section text is evaluated per assembly with the current agent's
|
|
1964
|
+
// session; it is injected ONLY when that session's cwd actually maps to a
|
|
1965
|
+
// dsh-remote mirror (i.e. the user picked a remote workspace as the session
|
|
1966
|
+
// workspace). A plain local session gets no remote section — saved machines
|
|
1967
|
+
// never leak their workspace into an unrelated local session's prompt.
|
|
1968
|
+
// (resolveMirrorForLocal is defined above, next to wsPath.)
|
|
1693
1969
|
ctx.systemPrompt.section({
|
|
1694
1970
|
name: 'dsh-remote',
|
|
1695
1971
|
order: 88,
|
|
1696
|
-
text: () => {
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1972
|
+
text: (promptContext) => {
|
|
1973
|
+
const agent = promptContext && promptContext.agent
|
|
1974
|
+
const session = agent && agent.session
|
|
1975
|
+
const cwd = session && session.header && session.header.cwd
|
|
1976
|
+
if (!cwd) return '' // no session cwd → nothing to map → stay quiet
|
|
1977
|
+
const { remotePath } = resolveMirrorForLocal(cwd)
|
|
1978
|
+
if (!remotePath) return '' // this session is NOT a remote session (issue #13)
|
|
1699
1979
|
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
1700
1980
|
let extra = ''
|
|
1701
1981
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
1702
1982
|
return (
|
|
1703
1983
|
'## Remote workspace\n' +
|
|
1704
|
-
`Current remote workspace: ${config.username}@${config.host}:${
|
|
1984
|
+
`Current remote workspace: ${config.username || 'user'}@${config.host}:${toDisplayPath(remotePath, pool.platform)}\n` +
|
|
1705
1985
|
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
|
|
1706
1986
|
extra
|
|
1707
1987
|
)
|
|
@@ -1852,15 +2132,23 @@ export async function apply(ctx, config) {
|
|
|
1852
2132
|
kind: 'exact',
|
|
1853
2133
|
path: '/dsh-remote/status',
|
|
1854
2134
|
handler: async (req, res) => {
|
|
1855
|
-
if (req.method === 'GET')
|
|
2135
|
+
if (req.method === 'GET') {
|
|
2136
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
2137
|
+
const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
|
|
2138
|
+
// Per-session remote context (issue #13): when a sessionId is given,
|
|
2139
|
+
// sessionMode reflects THAT session's cwd, not the machine default.
|
|
2140
|
+
return sendJson(res, 200, sessionId ? { ...status(), sessionMode: sessionRemotePath(sessionId) ? 'remote' : 'local', sessionRemotePath: sessionRemotePath(sessionId) } : status())
|
|
2141
|
+
}
|
|
1856
2142
|
sendJson(res, 405, { error: 'method not allowed' })
|
|
1857
2143
|
},
|
|
1858
2144
|
},
|
|
1859
2145
|
{
|
|
1860
2146
|
// Map a LOCAL path (typically a session cwd that sits inside a remote
|
|
1861
2147
|
// mirror dir) to the remote path it mirrors, by reading each mirror
|
|
1862
|
-
// dir's .dsh-remote-meta.json.
|
|
1863
|
-
//
|
|
2148
|
+
// dir's .dsh-remote-meta.json. NO machine-workspace fallback (issue #13):
|
|
2149
|
+
// a session whose cwd is not inside any mirror is a pure LOCAL session —
|
|
2150
|
+
// the sidebar must show "no remote workspace", never another machine's
|
|
2151
|
+
// remembered default.
|
|
1864
2152
|
// Accepts either ?local=<abs path> or ?sessionId=<id> (resolved via
|
|
1865
2153
|
// the host sessions service header.cwd).
|
|
1866
2154
|
kind: 'exact',
|
|
@@ -1909,7 +2197,6 @@ export async function apply(ctx, config) {
|
|
|
1909
2197
|
} catch { /* session log scan failed */ }
|
|
1910
2198
|
}
|
|
1911
2199
|
}
|
|
1912
|
-
const remote = status().workspace || ''
|
|
1913
2200
|
const root = remoteWorkspacesRoot()
|
|
1914
2201
|
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
1915
2202
|
let matched = ''
|
|
@@ -1936,8 +2223,16 @@ export async function apply(ctx, config) {
|
|
|
1936
2223
|
}
|
|
1937
2224
|
}
|
|
1938
2225
|
}
|
|
1939
|
-
|
|
1940
|
-
|
|
2226
|
+
// Issue #13: a non-mirror session is LOCAL — remotePath stays empty.
|
|
2227
|
+
const remotePath = matchedRemote || ''
|
|
2228
|
+
return sendJson(res, 200, {
|
|
2229
|
+
local, remotePath, mirrorDir: matched || null,
|
|
2230
|
+
// `fallback:true` now means "this session is NOT a remote session"
|
|
2231
|
+
// (used to mean "fell back to machine workspace").
|
|
2232
|
+
fallback: !matchedRemote,
|
|
2233
|
+
mode: matchedRemote ? 'remote' : 'local',
|
|
2234
|
+
resolvedVia,
|
|
2235
|
+
})
|
|
1941
2236
|
} catch (err) {
|
|
1942
2237
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1943
2238
|
}
|
|
@@ -1970,10 +2265,29 @@ export async function apply(ctx, config) {
|
|
|
1970
2265
|
path: '/dsh-remote/ls',
|
|
1971
2266
|
handler: async (req, res) => {
|
|
1972
2267
|
try {
|
|
2268
|
+
await pool.detect()
|
|
1973
2269
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1974
|
-
const
|
|
1975
|
-
const
|
|
1976
|
-
|
|
2270
|
+
const raw = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
2271
|
+
const canon = normalizeRemotePath(raw)
|
|
2272
|
+
const win = pool.platform === 'windows'
|
|
2273
|
+
// Windows root → "This PC" drive view (C:\ D:\ E:\), not the Git-Bash
|
|
2274
|
+
// MSYS root. When the platform probe was inconclusive we still try — a
|
|
2275
|
+
// real Windows box answers fsutil, a POSIX box yields no letters and
|
|
2276
|
+
// falls through to the normal root listing.
|
|
2277
|
+
if (canon === '/' || raw === '' || raw === '/') {
|
|
2278
|
+
if (win || pool.platform !== 'posix') {
|
|
2279
|
+
const drives = await listRemoteDrives()
|
|
2280
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives })
|
|
2281
|
+
}
|
|
2282
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [] })
|
|
2283
|
+
}
|
|
2284
|
+
const out = await listDirStructured(canon)
|
|
2285
|
+
const items = out.items.map((it) => ({
|
|
2286
|
+
...it,
|
|
2287
|
+
// full display-form path so the client never joins paths itself
|
|
2288
|
+
path: toDisplayPath(joinRemotePath(canon, it.name), pool.platform),
|
|
2289
|
+
}))
|
|
2290
|
+
return sendJson(res, 200, { path: toDisplayPath(out.path, pool.platform), platform: win ? 'windows' : 'posix', items })
|
|
1977
2291
|
} catch (err) {
|
|
1978
2292
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1979
2293
|
}
|
|
@@ -2347,9 +2661,13 @@ export async function apply(ctx, config) {
|
|
|
2347
2661
|
rec.password = prev.password
|
|
2348
2662
|
}
|
|
2349
2663
|
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2664
|
+
// Issue #13: saving a machine must NOT make it the active
|
|
2665
|
+
// remote context. `currentId` stays untouched on add/update —
|
|
2666
|
+
// only an explicit "设为当前" (or rw_connect) activates one.
|
|
2667
|
+
// keepCurrentKey:false leaves the stored `currentId` byte-for-byte
|
|
2668
|
+
// alone, so a fresh registry stays "no choice yet" (not an
|
|
2669
|
+
// explicit none) and a previously-cleared registry stays cleared.
|
|
2670
|
+
saveMachines(machines, store.currentId, false)
|
|
2353
2671
|
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
2354
2672
|
}
|
|
2355
2673
|
if (action === 'delete') {
|
|
@@ -2360,9 +2678,17 @@ export async function apply(ctx, config) {
|
|
|
2360
2678
|
await deleteSecret(m.id, secretsDir()).catch(() => {})
|
|
2361
2679
|
}
|
|
2362
2680
|
machines.splice(i, 1)
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2681
|
+
const wasCurrent = store.currentId === m.id
|
|
2682
|
+
if (wasCurrent) {
|
|
2683
|
+
// Issue #13: deleting the current machine leaves NO active
|
|
2684
|
+
// remote context (never auto-promote a sibling — that would
|
|
2685
|
+
// make an unrelated machine leak into the session again).
|
|
2686
|
+
store.currentId = null
|
|
2687
|
+
saveMachines(machines, null)
|
|
2688
|
+
await clearActiveMachine()
|
|
2689
|
+
} else {
|
|
2690
|
+
saveMachines(machines, store.currentId)
|
|
2691
|
+
}
|
|
2366
2692
|
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
2367
2693
|
}
|
|
2368
2694
|
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
@@ -2409,7 +2735,7 @@ export async function apply(ctx, config) {
|
|
|
2409
2735
|
machines[mi].latencyMs = latencyMs
|
|
2410
2736
|
saveMachines(machines, store.currentId)
|
|
2411
2737
|
}
|
|
2412
|
-
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString() })
|
|
2738
|
+
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString(), platform: probe.platform, shell: probe.shellMode, gitBash: probe.gitBashPath || '' })
|
|
2413
2739
|
} catch (err) {
|
|
2414
2740
|
return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body && body.host, port: body && body.port }) })
|
|
2415
2741
|
}
|
|
@@ -2422,7 +2748,14 @@ export async function apply(ctx, config) {
|
|
|
2422
2748
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2423
2749
|
try {
|
|
2424
2750
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2425
|
-
const
|
|
2751
|
+
const id = String(body.id || '').trim()
|
|
2752
|
+
// Issue #13: an empty id is an explicit "active remote = none" —
|
|
2753
|
+
// saved machines stay in the registry but nothing is current.
|
|
2754
|
+
if (!id) {
|
|
2755
|
+
await setCurrent('')
|
|
2756
|
+
return sendJson(res, 200, { ok: true, currentId: null, ...status() })
|
|
2757
|
+
}
|
|
2758
|
+
const okSet = await setCurrent(id)
|
|
2426
2759
|
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
2427
2760
|
return sendJson(res, 200, { ok: true, ...status() })
|
|
2428
2761
|
} catch (err) {
|