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/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
- function loadMachines() {
231
- try {
232
- const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
233
- if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
234
- } catch {}
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
- * Accepts `exec(cmd, timeoutMs)` (legacy) or `exec(cmd, {timeoutMs, pty, env})`. */
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
- const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
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 usually means the pooled connection died
514
- // server-side (idle timeout / network reset) while keepalive
515
- // hadn't noticed. Drop it and retry ONCE on a fresh connection.
516
- if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
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 means the pooled connection is stale — drop it and
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
- // If no stored current, adopt a CLI-provided default as the active machine.
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
- if (cur && cur.host && !store.currentId) applyMachine(config, cur)
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
- throw new Error('browse failed: ' + ((err && err.message) || err))
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
- if (!ok) return { text: `not a directory (or missing) on ${p}` }
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 ${p} 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.`,
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; if omitted, lists the current remote workspace. Shows type, size, mtime.',
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
- const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
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: the current remote workspace ─────────────────
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 w = wsPath()
1698
- if (!w || !config.host) return ''
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}:${w}\n` +
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') return sendJson(res, 200, status())
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. Falls back to the machine workspace so
1863
- // the sidebar still shows something sensible for non-mirror cwds.
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
- const remotePath = matchedRemote || remote
1940
- return sendJson(res, 200, { local, remotePath, mirrorDir: matched || null, fallback: !matchedRemote, resolvedVia })
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 p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
1975
- const out = await listDirStructured(p || '/')
1976
- return sendJson(res, 200, { path: out.path, items: out.items })
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
- if (!store.currentId) store.currentId = id
2351
- saveMachines(machines, store.currentId)
2352
- if (store.currentId === id) await applyActiveMachine()
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
- if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
2364
- saveMachines(machines, store.currentId)
2365
- if (store.currentId) await applyActiveMachine()
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 okSet = await setCurrent(String(body.id || ''))
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) {