dsh-remote 0.5.10 → 0.6.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
@@ -18,7 +18,9 @@
18
18
  import z from '@deepseek-ai/schemastery'
19
19
  import { defineTool } from '@deepseek-ai/dsh-tools'
20
20
  import ssh2 from 'ssh2'
21
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
22
+ import { execFile } from 'node:child_process'
23
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
22
24
  import { homedir } from 'node:os'
23
25
  import path from 'node:path'
24
26
 
@@ -53,6 +55,13 @@ export const Config = z.object({
53
55
  connectTimeoutMs: z.number().step(1).min(1000).default(15000),
54
56
  /** Hard ceiling on collected remote output per call. */
55
57
  maxOutputChars: z.number().step(1).min(1024).default(200000),
58
+ /** Skip mirroring files larger than this many bytes (0 = no cap). */
59
+ maxFileBytes: z.number().step(1).min(0).default(52428800),
60
+ /** Host-key policy: `accept-new` (default) records a host's key on first
61
+ * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
62
+ * accept-new); `verify` also rejects hosts never seen before; `off` skips
63
+ * verification entirely (MITM-unsafe, not recommended). */
64
+ hostKeyMode: z.string().default('accept-new'),
56
65
  })
57
66
 
58
67
  // ── shell / path helpers (proven in the read tooling) ───────────────────────
@@ -62,10 +71,46 @@ function shq(s) {
62
71
  return "'" + String(s).replace(/'/g, "'\\''") + "'"
63
72
  }
64
73
 
65
- /** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
74
+ /**
75
+ * Normalize a remote path into a clean absolute path, supporting BOTH POSIX
76
+ * (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
77
+ *
78
+ * - POSIX paths keep `/` separators and a leading `/`.
79
+ * - Windows drive paths keep their `X:` prefix and are returned with `\`
80
+ * separators (the native form on a Windows remote); a leading `/` is NOT
81
+ * added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
82
+ * - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
83
+ * - `.` / `..` segments are collapsed in both separator styles.
84
+ */
66
85
  function normalizeRemotePath(p) {
86
+ const s = String(p)
87
+ // Windows UNC: \\server\share\…
88
+ const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
89
+ if (unc) {
90
+ const [prefix, rest] = [unc[1], unc[2]]
91
+ const parts = []
92
+ for (const seg of rest.split(/[\\/]+/)) {
93
+ if (seg === '' || seg === '.') continue
94
+ if (seg === '..') { parts.pop(); continue }
95
+ parts.push(seg)
96
+ }
97
+ return parts.length ? prefix + '\\' + parts.join('\\') : prefix
98
+ }
99
+ // Windows drive: X:\… or X:/…
100
+ const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
101
+ if (drive) {
102
+ const [prefix, rest] = [drive[1], drive[2]]
103
+ const parts = []
104
+ for (const seg of rest.split(/[\\/]+/)) {
105
+ if (seg === '' || seg === '.') continue
106
+ if (seg === '..') { parts.pop(); continue }
107
+ parts.push(seg)
108
+ }
109
+ return prefix + '\\' + parts.join('\\')
110
+ }
111
+ // POSIX: /a/b/c
67
112
  const parts = []
68
- for (const seg of String(p).split('/')) {
113
+ for (const seg of s.split('/')) {
69
114
  if (seg === '' || seg === '.') continue
70
115
  if (seg === '..') {
71
116
  parts.pop()
@@ -76,12 +121,57 @@ function normalizeRemotePath(p) {
76
121
  return '/' + parts.join('/')
77
122
  }
78
123
 
79
- /** Parent dir of a remote absolute path (string-level). */
124
+ /** Join a remote dir + entry name, honoring the dir's own separator style. */
125
+ function joinRemotePath(dir, name) {
126
+ const d = String(dir)
127
+ if (!d) return String(name)
128
+ if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
129
+ return d + (d.includes('\\') ? '\\' : '/') + String(name)
130
+ }
131
+
132
+ /** Create every level of a remote dir via SFTP (mkdir -p semantics), working
133
+ * for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
134
+ * existing or permission-denied level is skipped. */
135
+ async function mkdirRemoteDirs(sftp, dir) {
136
+ const parent = remoteDirname(dir)
137
+ const isWin = parent.includes('\\')
138
+ // Split off a Windows drive prefix (D:) so it is not treated as a segment.
139
+ const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
140
+ const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
141
+ let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
142
+ // Drive root (D:\) itself, best effort (usually exists).
143
+ if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
144
+ for (const s of segs) {
145
+ if (cur) {
146
+ if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
147
+ cur += s
148
+ } else {
149
+ cur = (isWin ? s + ':' : '/' + s)
150
+ }
151
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
152
+ }
153
+ }
154
+
155
+ /** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
80
156
  function remoteDirname(p) {
81
157
  const norm = normalizeRemotePath(p)
82
- if (norm === '/') return '/'
83
- const idx = norm.lastIndexOf('/')
84
- return idx <= 0 ? '/' : norm.slice(0, idx)
158
+ // Windows drive/UNC root: D:\ or \\server\share
159
+ if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
160
+ // Ensure a drive root ends with a backslash: D: → D:\
161
+ const m = norm.match(/^([a-zA-Z]:)$/)
162
+ return m ? m[1] + '\\' : norm
163
+ }
164
+ const sep = norm.includes('\\') ? '\\' : '/'
165
+ const idx = norm.lastIndexOf(sep)
166
+ if (idx <= 0) {
167
+ // Windows drive with a child (D:\Code) → D:\
168
+ const m = norm.match(/^([a-zA-Z]:)\\/)
169
+ return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
170
+ }
171
+ // A parent that is a Windows drive root also needs the trailing backslash.
172
+ const parent = norm.slice(0, idx)
173
+ if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
174
+ return parent
85
175
  }
86
176
 
87
177
  function truncate(s, max) {
@@ -95,23 +185,111 @@ function truncate(s, max) {
95
185
  // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
96
186
 
97
187
  const remotePathBase = (p) => {
98
- const norm = normalizeRemotePath(p).replace(/\/+$/, '')
99
- const base = norm.split('/').pop()
100
- return base || 'workspace'
188
+ const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
189
+ const base = norm.split(/[\\/]/).pop()
190
+ // Windows drive root (D:\) has no meaningful basename
191
+ if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
192
+ return base
193
+ }
194
+
195
+ /**
196
+ * Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
197
+ * path format the SFTP server understands.
198
+ *
199
+ * Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
200
+ * written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
201
+ * UNC paths are kept as-is (the server resolves `\\server\share` itself).
202
+ */
203
+ function toSftpPath(p) {
204
+ const norm = normalizeRemotePath(p)
205
+ const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
206
+ if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
207
+ return norm
208
+ }
209
+
210
+ /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
211
+ * own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
212
+ * data under the same root the harness uses means uninstalling / upgrading the
213
+ * app no longer leaves state behind in the home directory. */
214
+ function dshBase() {
215
+ const env = process.env.DSH_HOME
216
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
217
+ return path.join(homedir(), '.dsh')
218
+ }
219
+
220
+ /** Root holding every remote host's mirrors + the machine registry. */
221
+ function remoteWorkspacesRoot() {
222
+ return path.join(dshBase(), 'remote-workspaces')
223
+ }
224
+
225
+ /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
226
+ function copyDirSync(from, to) {
227
+ mkdirSync(to, { recursive: true })
228
+ for (const e of readdirSync(from, { withFileTypes: true })) {
229
+ const s = path.join(from, e.name)
230
+ const d = path.join(to, e.name)
231
+ if (e.isDirectory()) copyDirSync(s, d)
232
+ else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
233
+ }
234
+ }
235
+
236
+ /** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
237
+ * regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
238
+ * desktop install keeps its existing machines and mirrors. Idempotent; a
239
+ * rename across devices falls back to a copy. */
240
+ function migrateLegacyData() {
241
+ const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
242
+ const target = remoteWorkspacesRoot()
243
+ if (legacy === target || !existsSync(legacy) || existsSync(target)) return
244
+ try {
245
+ mkdirSync(path.dirname(target), { recursive: true })
246
+ try {
247
+ renameSync(legacy, target)
248
+ } catch (err) {
249
+ if (err.code !== 'EXDEV') throw err
250
+ copyDirSync(legacy, target)
251
+ }
252
+ } catch {
253
+ // Migration is best-effort: a fresh registry is created on next save.
254
+ }
255
+ }
256
+
257
+ /** 32-bit string hash rendered in base36 (short, collision-safe enough). */
258
+ function shortHash(s) {
259
+ let h = 0
260
+ for (let i = 0; i < s.length; i++) {
261
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0
262
+ }
263
+ return (h >>> 0).toString(36)
101
264
  }
102
265
 
103
266
  /** Stable local root for one remote host's mirrors */
104
267
  function mirrorRootFor(host, user, port) {
105
268
  const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
106
- return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
269
+ return path.join(remoteWorkspacesRoot(), tag)
107
270
  }
108
271
 
109
272
  /** Local mirror dir for a specific remote path (idempotent → returns same dir).
110
273
  * Named after the remote directory's basename so the harness workspace label
111
- * reads cleanly (e.g. .../project) instead of a concatenated hash string. */
274
+ * reads cleanly (e.g. .../project). When the plain basename dir is already
275
+ * taken by a DIFFERENT remote path on the same host, a short path-hash suffix
276
+ * is appended so mirrors never collide. */
112
277
  function mirrorDirFor(remotePath, host, user, port) {
113
278
  const base = remotePathBase(remotePath)
114
- return path.join(mirrorRootFor(host, user, port), base)
279
+ const root = mirrorRootFor(host, user, port)
280
+ const plain = path.join(root, base)
281
+ const norm = normalizeRemotePath(remotePath)
282
+ // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
283
+ try {
284
+ const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
285
+ if (meta.remotePath === norm) return plain
286
+ } catch {
287
+ /* no mirror yet → fall through */
288
+ }
289
+ // Plain dir is free → first mirror for this basename keeps the clean name.
290
+ // It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
291
+ if (!existsSync(plain)) return plain
292
+ return path.join(root, base + '-' + shortHash(norm))
115
293
  }
116
294
 
117
295
  /** Create the local mirror dir + a meta file describing its remote origin. */
@@ -120,82 +298,130 @@ function ensureMirror(remotePath, host, user, port) {
120
298
  mkdirSync(dir, { recursive: true })
121
299
  writeFileSync(
122
300
  path.join(dir, '.dsh-remote-meta.json'),
123
- JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
301
+ JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
124
302
  )
125
303
  return dir
126
304
  }
127
305
 
128
- /** Recursively sync remote → local mirror. Bounded by depth/files. */
129
- async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
306
+ /** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
307
+ async function mapLimit(items, limit, fn) {
308
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
309
+ let next = 0
310
+ const worker = async () => {
311
+ while (next < items.length) {
312
+ const i = next++
313
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
314
+ }
315
+ }
316
+ const workers = []
317
+ for (let w = 0; w < limitN; w++) workers.push(worker())
318
+ await Promise.all(workers)
319
+ }
320
+
321
+ /** Recursively sync remote → local mirror. Bounded by depth/files/size;
322
+ * skips files whose size+mtime already match locally. Returns counters. */
323
+ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
130
324
  const entries = await sftp.readdir(remoteDir).then(
131
325
  (list) => list,
132
326
  () => [],
133
327
  )
134
- let files = 0
135
- const touched = []
328
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
329
+
330
+ // Directories first (sequential, depth-bounded) so every file has a home.
136
331
  for (const e of entries) {
137
332
  const name = String(e.filename)
138
333
  if (name === '.' || name === '..') continue
139
- const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
140
- const lp = path.join(localDir, name)
141
334
  const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
142
- if (isDir) {
143
- if (maxDepth <= 0) continue
144
- mkdirSync(lp, { recursive: true })
145
- const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
146
- files += sub.files
147
- touched.push(...sub.touched)
148
- continue
149
- }
150
- if (files >= maxFiles) break
335
+ if (!isDir) continue
336
+ if (maxDepth <= 0 || stats.files >= maxFiles) continue
337
+ const rp = joinRemotePath(remoteDir, name)
338
+ const lp = path.join(localDir, name)
339
+ mkdirSync(lp, { recursive: true })
340
+ const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
341
+ stats.dirs += sub.dirs + 1
342
+ stats.files += sub.files
343
+ stats.skippedUnchanged += sub.skippedUnchanged
344
+ stats.skippedLarge += sub.skippedLarge
345
+ stats.touched.push(...sub.touched)
346
+ if (stats.files >= maxFiles) break
347
+ }
348
+ if (stats.files >= maxFiles) return stats
349
+
350
+ // Files in one bounded-parallel sweep per directory level.
351
+ const fileEntries = entries.filter(
352
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
353
+ String(e.filename) !== '.' && String(e.filename) !== '..',
354
+ )
355
+ await mapLimit(fileEntries, 4, async (e) => {
356
+ if (stats.files >= maxFiles) return
357
+ const name = String(e.filename)
358
+ const rp = joinRemotePath(remoteDir, name)
359
+ const lp = path.join(localDir, name)
151
360
  try {
361
+ const st = await sftp.stat(rp)
362
+ if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
363
+ const lpStat = existsSync(lp) ? statSync(lp) : null
364
+ const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
365
+ if (unchanged) { stats.skippedUnchanged++; return }
152
366
  const buf = await sftp.readFile(rp)
153
367
  writeFileSync(lp, buf)
154
- touched.push(rp)
155
- files++
368
+ try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
369
+ stats.files++
370
+ stats.touched.push(rp)
156
371
  } catch {
157
372
  /* skip unreadable */
158
373
  }
159
- }
160
- return { files, touched }
374
+ })
375
+ return stats
161
376
  }
162
377
 
163
- /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
164
- async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
378
+ /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
379
+ * skips files whose remote copy already matches (same size, remote no older). */
380
+ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
165
381
  const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
166
- let files = 0
167
- let dirsCreated = 0
168
- const pushed = []
382
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
383
+
169
384
  for (const e of entries) {
170
- if (e.isDirectory()) {
171
- const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
172
- try {
173
- await sftp.mkdir(rp)
174
- dirsCreated++
175
- } catch { /* already exists */ }
176
- const sub = await pushTree(sftp, path.join(localDir, e.name), rp, remoteBaseDir, maxFiles)
177
- files += sub.files
178
- pushed.push(...sub.pushed)
179
- continue
180
- }
181
- if (files >= maxFiles) break
385
+ if (!e.isDirectory()) continue
386
+ if (stats.files >= maxFiles) break
387
+ const rp = joinRemotePath(remoteDir, e.name)
388
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
389
+ stats.dirs++
390
+ const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
391
+ stats.files += sub.files
392
+ stats.skippedUnchanged += sub.skippedUnchanged
393
+ stats.skippedLarge += sub.skippedLarge
394
+ stats.pushed.push(...sub.pushed)
395
+ if (stats.files >= maxFiles) break
396
+ }
397
+ if (stats.files >= maxFiles) return stats
398
+
399
+ const fileEntries = entries.filter((e) => !e.isDirectory())
400
+ await mapLimit(fileEntries, 4, async (e) => {
401
+ if (stats.files >= maxFiles) return
182
402
  const lp = path.join(localDir, e.name)
183
- const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
403
+ const rp = joinRemotePath(remoteDir, e.name)
184
404
  try {
405
+ const lstat = statSync(lp)
406
+ if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
407
+ let rstat = null
408
+ try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
409
+ const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
410
+ if (unchanged) { stats.skippedUnchanged++; return }
185
411
  const buf = readFileSync(lp)
186
412
  await sftp.writeFile(rp, buf)
187
- pushed.push(rp)
188
- files++
413
+ stats.files++
414
+ stats.pushed.push(rp)
189
415
  } catch {
190
416
  /* skip unreadable / unwritable */
191
417
  }
192
- }
193
- return { files, pushed }
418
+ })
419
+ return stats
194
420
  }
195
421
 
196
422
  // ── persistent multi-machine registry ─────────────────────────────────────
197
423
  const MACHINES_FILE = 'machines.json'
198
- const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
424
+ const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
199
425
  function loadMachines() {
200
426
  try {
201
427
  const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
@@ -225,6 +451,98 @@ function applyMachine(config, m) {
225
451
  }
226
452
  function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
227
453
 
454
+ // ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
455
+ const KNOWN_HOSTS_FILE = 'known_hosts.json'
456
+ const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
457
+
458
+ /** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
459
+ function loadKnownHosts() {
460
+ try {
461
+ const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
462
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
463
+ } catch {}
464
+ return {}
465
+ }
466
+
467
+ /** Extract the host-key algorithm name from a raw SSH host-key blob
468
+ * (SSH wire format: `uint32 len` + algorithm string + key data). */
469
+ function blobAlgorithm(blob) {
470
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
471
+ try {
472
+ const len = blob.readUInt32BE(0)
473
+ return blob.toString('utf8', 4, 4 + len)
474
+ } catch {
475
+ return ''
476
+ }
477
+ }
478
+
479
+ /** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
480
+ * in a known_hosts file (without the `SHA256:` prefix).
481
+ *
482
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
483
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
484
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
485
+ * argument must be of type string or an instance of Buffer, TypedArray, or
486
+ * DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
487
+ * v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
488
+ * shapes defensively. */
489
+ function keyFingerprint(key) {
490
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
491
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
492
+ return createHash('sha256').update(blob).digest('base64')
493
+ }
494
+
495
+ /** Build an ssh2 `hostVerifier` bound to the current config. Returns
496
+ * `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
497
+ * - `off` → always accepts;
498
+ * - `accept-new` → records a host's key on first connect, rejects any CHANGE
499
+ * afterwards (classic TOFU / MITM detection);
500
+ * - `verify` → same, plus rejects hosts never recorded before. */
501
+ function createHostKeyGuard(config) {
502
+ const id = () => `${config.host}:${config.port}`
503
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
504
+ ? config.hostKeyMode
505
+ : 'accept-new'
506
+ const guard = {
507
+ mode,
508
+ lastError: null,
509
+ knownHosts: loadKnownHosts,
510
+ forgetHost() {
511
+ const kh = loadKnownHosts()
512
+ delete kh[id()]
513
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
514
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
515
+ },
516
+ verifier(key) {
517
+ if (mode === 'off') return true
518
+ const fp = keyFingerprint(key)
519
+ const kh = loadKnownHosts()
520
+ const stored = kh[id()]
521
+ if (stored) {
522
+ if (stored.fingerprint === fp) return true
523
+ guard.lastError =
524
+ `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
525
+ 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
526
+ return false
527
+ }
528
+ if (mode === 'verify') {
529
+ guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
530
+ return false
531
+ }
532
+ kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
533
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
534
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
535
+ return true
536
+ },
537
+ }
538
+ return guard
539
+ }
540
+
541
+ /** Whether the current target's key has been recorded/trusted before. */
542
+ function isHostKeyKnown(host, port) {
543
+ return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
544
+ }
545
+
228
546
  // ── SSH pool (key OR password) ──────────────────────────────────────────────
229
547
 
230
548
  class SshPool {
@@ -232,6 +550,9 @@ class SshPool {
232
550
  this.config = config
233
551
  this.client = null
234
552
  this.connecting = null
553
+ // Generational token: bumped on every target change / close so a stale
554
+ // in-flight connect can never hand this pool a connection to an old host.
555
+ this.epoch = 0
235
556
  }
236
557
 
237
558
  resolveKeyPath() {
@@ -257,31 +578,47 @@ class SshPool {
257
578
  connect() {
258
579
  if (this.client) return Promise.resolve(this.client)
259
580
  if (this.connecting) return this.connecting
260
- this.connecting = this._doConnect().finally(() => {
261
- this.connecting = null
262
- })
263
- return this.connecting
581
+ const epoch = this.epoch
582
+ const pending = this._doConnect(epoch)
583
+ this.connecting = pending
584
+ const clear = () => {
585
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
586
+ }
587
+ // Keep the chain clean in both directions: the raw pending is what callers
588
+ // await (they still see rejections); this side just clears the slot.
589
+ pending.then(clear, clear)
590
+ return pending
264
591
  }
265
592
 
266
- _doConnect() {
593
+ _doConnect(epoch) {
267
594
  return new Promise((resolve, reject) => {
268
595
  const client = new Client()
269
596
  let settled = false
597
+ // Fresh host-key guard per connect attempt so lastError is never stale.
598
+ const guard = createHostKeyGuard(this.config)
599
+ const isCurrent = () => this.epoch === epoch
270
600
  const fail = (err) => {
271
601
  if (settled) return
272
602
  settled = true
273
- if (this.client === client) this.client = null
274
- reject(err)
603
+ if (isCurrent() && this.client === client) this.client = null
604
+ reject(guard.lastError ? new Error(guard.lastError) : err)
275
605
  }
276
606
  client.on('ready', () => {
277
607
  if (settled) return
278
608
  settled = true
609
+ if (!isCurrent()) {
610
+ // The target changed while we were connecting — never adopt this
611
+ // client, and make sure the caller learns the connect is void.
612
+ try { client.end() } catch {}
613
+ reject(new Error('ssh target changed during connect'))
614
+ return
615
+ }
279
616
  this.client = client
280
617
  resolve(client)
281
618
  })
282
619
  client.on('error', fail)
283
620
  client.on('close', () => {
284
- if (this.client === client) this.client = null
621
+ if (isCurrent() && this.client === client) this.client = null
285
622
  fail(new Error('ssh connection closed'))
286
623
  })
287
624
 
@@ -292,6 +629,7 @@ class SshPool {
292
629
  readyTimeout: this.config.connectTimeoutMs,
293
630
  keepaliveInterval: 15000,
294
631
  keepaliveCountMax: 3,
632
+ hostVerifier: (key) => guard.verifier(key),
295
633
  }
296
634
  if (this.config.password) {
297
635
  opts.password = this.config.password
@@ -341,9 +679,17 @@ class SshPool {
341
679
  if (settled) return
342
680
  exitCode = -1
343
681
  exitSignal = 'TIMEOUT'
682
+ // Kill the remote command (SIGTERM) rather than just dropping the
683
+ // channel, so a runaway process cannot keep running and holding
684
+ // the SSH connection after we've given up on its output. The hard
685
+ // close below frees the channel even if the process ignores it.
344
686
  try {
345
- stream.close()
687
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
346
688
  } catch {}
689
+ const hardClose = setTimeout(() => {
690
+ try { stream.close() } catch {}
691
+ }, 800)
692
+ if (typeof hardClose.unref === 'function') hardClose.unref()
347
693
  settle()
348
694
  }, timeoutMs || this.config.commandTimeoutMs)
349
695
  stream.on('close', (code, signal) => {
@@ -369,19 +715,33 @@ class SshPool {
369
715
  )
370
716
  }
371
717
 
372
- /** Resolve a promisified SFTP client for binary file transfers (sync). */
718
+ /** Resolve a promisified SFTP client for binary file transfers (sync).
719
+ * All path arguments are normalized via toSftpPath() so Windows remotes
720
+ * (drive-letter paths) work too. */
373
721
  sftp() {
374
722
  return this.connect().then(
375
723
  (client) =>
376
724
  new Promise((resolve, reject) => {
377
725
  client.sftp((err, sftp) => {
378
726
  if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
727
+ // Wrap a raw SFTP method so a stuck server can never hang a tool call.
728
+ const withTimeout = (fn) => (...args) =>
729
+ new Promise((r2, j2) => {
730
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
731
+ const done = (e, v) => {
732
+ clearTimeout(timer)
733
+ e ? j2(e) : r2(v)
734
+ }
735
+ try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
736
+ })
737
+ const P = (p) => toSftpPath(p)
379
738
  resolve({
380
- readdir: (dir) => new Promise((r2, j2) => sftp.readdir(dir, (e, list) => (e ? j2(e) : r2(list)))),
381
- stat: (p) => new Promise((r2, j2) => sftp.stat(p, (e, st) => (e ? j2(e) : r2(st)))),
382
- mkdir: (dir) => new Promise((r2, j2) => sftp.mkdir(dir, (e) => (e ? j2(e) : r2()) )),
383
- readFile: (p) => new Promise((r2, j2) => sftp.readFile(p, (e, buf) => (e ? j2(e) : r2(buf)))),
384
- writeFile: (p, data) => new Promise((r2, j2) => sftp.writeFile(p, data, (e) => (e ? j2(e) : r2()))),
739
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
740
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
741
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
742
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
743
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
744
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
385
745
  })
386
746
  })
387
747
  }),
@@ -389,8 +749,17 @@ class SshPool {
389
749
  }
390
750
 
391
751
  close() {
752
+ // Bump the epoch so any in-flight connect is orphaned, drop the pending
753
+ // promise (its rejection becomes nobody's problem — swallow it), and end
754
+ // the live client if there is one.
755
+ this.epoch++
392
756
  const client = this.client
393
757
  this.client = null
758
+ const pending = this.connecting
759
+ this.connecting = null
760
+ if (pending && typeof pending.catch === 'function') {
761
+ try { pending.catch(() => {}) } catch {}
762
+ }
394
763
  if (client) {
395
764
  try {
396
765
  client.end()
@@ -405,6 +774,10 @@ export async function apply(ctx, config) {
405
774
  const pool = new SshPool(config)
406
775
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
407
776
 
777
+ // Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
778
+ // location so existing machines + mirrors keep working after the move.
779
+ migrateLegacyData()
780
+
408
781
  // ── machine registry (multi-host) ─────────────────────────────────────────
409
782
  const store = loadMachines()
410
783
  const machines = store.list
@@ -433,6 +806,19 @@ export async function apply(ctx, config) {
433
806
  if (cur && cur.host) applyMachine(config, cur)
434
807
  }
435
808
 
809
+ /** Set the active remote workspace AND persist it on the current machine so
810
+ * it survives restarts (previously a tool-set workspace reset on reload). */
811
+ const persistWorkspace = (p) => {
812
+ config.workspace = p
813
+ if (store.currentId) {
814
+ const i = machineIndex(store.currentId)
815
+ if (i >= 0) {
816
+ machines[i].workspace = p
817
+ saveMachines(machines, store.currentId)
818
+ }
819
+ }
820
+ }
821
+
436
822
  const run = async (cmd, opts = {}) => {
437
823
  const res = await pool.exec(cmd, opts.timeoutMs)
438
824
  const parts = []
@@ -445,31 +831,68 @@ export async function apply(ctx, config) {
445
831
  return text
446
832
  }
447
833
 
448
- /** Structured-listing of a remote dir: name + usable type. `dir` is decided by
449
- * `[ -d ]`, which FOLLOWS symlinks — so a symlink to a directory is enterable
450
- * (the picker can drill into it), while a file symlink stays a file. */
834
+ /** Structured-listing of a remote dir: name + usable type.
835
+ *
836
+ * Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
837
+ * macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
838
+ * shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
839
+ * resolved with one bounded lstat (if it fails they degrade to files). */
451
840
  const listDirStructured = async (p, timeoutMs) => {
452
- const target = p || '/'
453
- const cmd =
454
- `cd ${shq(target)} 2>/dev/null && for f in .[!.]* *; do ` +
455
- `[ -e "$f" ] || [ -L "$f" ] || continue; ` +
456
- `if [ -d "$f" ]; then printf 'd\\t%s\\n' "$f"; else printf 'f\\t%s\\n' "$f"; fi; done`
457
- const res = await pool.exec(cmd, timeoutMs || config.commandTimeoutMs)
458
- const items = []
459
- if (res.code !== 0 && res.stderr) {
460
- throw new Error('ls failed: ' + (res.stderr || '').trim())
841
+ const target = normalizeRemotePath(p || '/')
842
+ let sftp
843
+ try {
844
+ sftp = await pool.sftp()
845
+ } catch (err) {
846
+ throw new Error('browse failed: ' + ((err && err.message) || err))
847
+ }
848
+ let list = []
849
+ try {
850
+ list = await sftp.readdir(target)
851
+ } catch (err) {
852
+ throw new Error('browse failed: ' + ((err && err.message) || err))
461
853
  }
462
- for (const line of String(res.stdout || '').split('\n')) {
463
- const idx = line.indexOf('\t')
464
- if (idx < 0) continue
465
- const type = line.slice(0, idx)
466
- const name = line.slice(idx + 1)
854
+ const items = []
855
+ const symIdx = []
856
+ for (const e of list) {
857
+ const name = String(e.filename)
467
858
  if (name === '.' || name === '..' || !name) continue
468
- items.push({ type: type === 'd' ? 'dir' : 'file', name })
859
+ const a = e.attrs || {}
860
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
861
+ items.push({ type: 'symlink', name })
862
+ symIdx.push(items.length - 1)
863
+ } else if (a.isDirectory && a.isDirectory()) {
864
+ items.push({ type: 'dir', name })
865
+ } else {
866
+ items.push({ type: 'file', name })
867
+ }
868
+ }
869
+ // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
870
+ if (symIdx.length) {
871
+ await Promise.all(symIdx.map(async (i) => {
872
+ const full = joinRemotePath(target, items[i].name)
873
+ try {
874
+ const st = await sftp.lstat(full)
875
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
876
+ } catch { /* degrade to file */ }
877
+ }))
469
878
  }
470
879
  return { path: target, items }
471
880
  }
472
881
 
882
+ /** Verify a remote path is an existing directory, via SFTP stat (works on
883
+ * any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
884
+ * path is missing, is a file, or the stat errors. */
885
+ const isRemoteDir = async (p) => {
886
+ const target = normalizeRemotePath(p)
887
+ try {
888
+ const sftp = await pool.sftp()
889
+ const st = await sftp.stat(target)
890
+ return !!(st && st.isDirectory && st.isDirectory())
891
+ } catch {
892
+ return false
893
+ }
894
+ }
895
+
473
896
  // ── remote workspace state ────────────────────────────────────────────────
474
897
 
475
898
  const wsPath = () => (config.workspace || '').trim()
@@ -482,6 +905,8 @@ export async function apply(ctx, config) {
482
905
  localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
483
906
  currentId: store.currentId || null,
484
907
  machines: machines.map(sanitizeMachine),
908
+ hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
909
+ hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
485
910
  })
486
911
 
487
912
  // ── tools ─────────────────────────────────────────────────────────────────
@@ -495,7 +920,7 @@ export async function apply(ctx, config) {
495
920
  defineTool({
496
921
  name: 'rw_info',
497
922
  description:
498
- 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when a remote_* call fails to check connectivity.',
923
+ 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when an rw_* call fails to check connectivity.',
499
924
  parameters: {},
500
925
  output: {
501
926
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -506,12 +931,14 @@ export async function apply(ctx, config) {
506
931
  const lines = [
507
932
  `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
508
933
  `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
934
+ `Local mirror: ${s.localMirror || '(none)'}`,
509
935
  `Connected: ${s.connected ? 'yes' : 'no'}`,
510
936
  '',
511
937
  ]
512
938
  if (s.host && s.workspace) {
513
939
  try {
514
- const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
940
+ // `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
941
+ const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
515
942
  if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
516
943
  else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
517
944
  else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
@@ -551,9 +978,9 @@ export async function apply(ctx, config) {
551
978
  privateKeyPath: args.privateKeyPath || undefined,
552
979
  })
553
980
  try {
554
- const res = await pool.exec('echo ok; hostname', 8000)
981
+ const res = await pool.exec('echo ok', 8000)
555
982
  if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
556
- return { text: `Connected to ${host} as ${config.username}.\nhostname: ${res.stdout.replace(/\s+/g, ' ').trim()}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
983
+ return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
557
984
  } catch (err) {
558
985
  throw err
559
986
  }
@@ -574,13 +1001,12 @@ export async function apply(ctx, config) {
574
1001
  async execute(args) {
575
1002
  const p = normalizeRemotePath(String(args.path || ''))
576
1003
  if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
577
- const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
578
- const ok = res.stdout.trim() === 'DIR'
1004
+ const ok = await isRemoteDir(p)
579
1005
  if (!ok) return { text: `not a directory (or missing) on ${p}` }
580
- config.workspace = p
1006
+ persistWorkspace(p)
581
1007
  const local = ensureMirror(p, config.host, config.username, config.port)
582
1008
  return {
583
- text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1009
+ 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.`,
584
1010
  }
585
1011
  },
586
1012
  }),
@@ -610,8 +1036,11 @@ export async function apply(ctx, config) {
610
1036
  } catch (err) {
611
1037
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
612
1038
  }
613
- const { files, touched } = await syncTree(sftp, p, local, depth, maxFiles)
614
- return { text: `Downloaded ${files} file(s) from ${p} → ${local}${files >= maxFiles ? ' (hit download cap)' : ''}.` }
1039
+ const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
1040
+ let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
1041
+ if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
1042
+ if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1043
+ return { text }
615
1044
  },
616
1045
  }),
617
1046
 
@@ -638,8 +1067,11 @@ export async function apply(ctx, config) {
638
1067
  } catch (err) {
639
1068
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
640
1069
  }
641
- const { files } = await pushTree(sftp, local, p, p, maxFiles)
642
- return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
1070
+ const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
1071
+ let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
1072
+ if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
1073
+ if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1074
+ return { text }
643
1075
  },
644
1076
  }),
645
1077
 
@@ -657,7 +1089,24 @@ export async function apply(ctx, config) {
657
1089
  async execute(args) {
658
1090
  const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
659
1091
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
660
- return { text: await run(`ls -la --color=never ${shq(p)}`) }
1092
+ // SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
1093
+ // PowerShell) unlike `ls -la`.
1094
+ let list
1095
+ try {
1096
+ const sftp = await pool.sftp()
1097
+ list = await sftp.readdir(p)
1098
+ } catch (err) {
1099
+ throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1100
+ }
1101
+ const lines = list
1102
+ .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1103
+ .map((e) => {
1104
+ const a = e.attrs || {}
1105
+ const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1106
+ const size = typeof a.size === 'number' ? String(a.size) : '?'
1107
+ return `${type} ${size.padStart(10)} ${String(e.filename)}`
1108
+ })
1109
+ return { text: lines.length ? lines.join('\n') : '(empty directory)' }
661
1110
  },
662
1111
  }),
663
1112
 
@@ -682,8 +1131,19 @@ export async function apply(ctx, config) {
682
1131
  let from = Math.max(Number(args.startLine) || 1, 1)
683
1132
  let to = Number(args.endLine) || 0
684
1133
  if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
685
- const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
686
- const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1134
+ // SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
1135
+ // / PowerShell, no POSIX sed) work too.
1136
+ let buf
1137
+ try {
1138
+ const sftp = await pool.sftp()
1139
+ buf = await sftp.readFile(p)
1140
+ } catch (err) {
1141
+ throw new Error('rw_read_file: ' + ((err && err.message) || err))
1142
+ }
1143
+ const content = buf.toString('utf8').replace(/\r\n/g, '\n')
1144
+ const allLines = content.split('\n')
1145
+ const page = allLines.slice(from - 1, to)
1146
+ const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
687
1147
  let text = numbered === '' ? '(empty or out of range)' : numbered
688
1148
  if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
689
1149
  return { text }
@@ -693,9 +1153,10 @@ export async function apply(ctx, config) {
693
1153
  defineTool({
694
1154
  name: 'rw_exec',
695
1155
  description:
696
- 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
1156
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere.',
697
1157
  parameters: {
698
1158
  command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1159
+ cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
699
1160
  },
700
1161
  output: {
701
1162
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -704,7 +1165,12 @@ export async function apply(ctx, config) {
704
1165
  async execute(args) {
705
1166
  const cmd = String(args.command || '')
706
1167
  if (!cmd) throw new Error('rw_exec: command is required')
707
- return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
1168
+ const ws = wsPath()
1169
+ const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1170
+ // Windows remotes: no POSIX cd/single-quotes — let the command run as-is
1171
+ // (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
1172
+ const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1173
+ return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
708
1174
  },
709
1175
  }),
710
1176
 
@@ -733,15 +1199,9 @@ export async function apply(ctx, config) {
733
1199
  }
734
1200
  const mkdir = args.mkdir !== false
735
1201
  if (mkdir) {
736
- const parent = remoteDirname(p)
737
1202
  // create each level from the root so mkdir -p semantics survive even
738
1203
  // when intermediate privileged-parent dirs don't allow create (best effort)
739
- const segs = parent.split('/').filter(Boolean)
740
- let cur = ''
741
- for (const s of segs) {
742
- cur += '/' + s
743
- try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
744
- }
1204
+ await mkdirRemoteDirs(sftp, p)
745
1205
  }
746
1206
  const buf = Buffer.from(content, 'utf8')
747
1207
  await sftp.writeFile(p, buf)
@@ -750,6 +1210,110 @@ export async function apply(ctx, config) {
750
1210
  },
751
1211
  }),
752
1212
 
1213
+ defineTool({
1214
+ name: 'rw_search',
1215
+ description:
1216
+ 'Search remote files for a pattern (grep, recursive, case-insensitive by default). Returns matching file:line rows; output is capped. Use instead of rw_exec grep when you want a bounded, portable search.',
1217
+ parameters: {
1218
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1219
+ path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1220
+ glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
1221
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1222
+ },
1223
+ output: {
1224
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1225
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1226
+ },
1227
+ async execute(args) {
1228
+ const pattern = String(args.pattern || '')
1229
+ if (!pattern) throw new Error('rw_search: pattern is required')
1230
+ const ws = wsPath()
1231
+ const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1232
+ if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1233
+ if (dir.includes('\\')) {
1234
+ // Windows remotes have no POSIX find/grep; the search would need a
1235
+ // recursive SFTP walk. Give an actionable hint instead of a broken cmd.
1236
+ return { text: `rw_search does not support Windows-remote paths yet (${dir}). Use rw_exec with a PowerShell one-liner, e.g.: powershell -NoProfile -Command "Get-ChildItem -Recurse -File | Select-String -Pattern '${pattern}'"` }
1237
+ }
1238
+ const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1239
+ const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1240
+ // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
1241
+ // differ on BSD, and GNU-only --include is avoided in favor of find -name.
1242
+ const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
1243
+ return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
1244
+ },
1245
+ }),
1246
+
1247
+ defineTool({
1248
+ name: 'rw_download',
1249
+ description:
1250
+ 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text.',
1251
+ parameters: {
1252
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1253
+ localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1254
+ },
1255
+ output: {
1256
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1257
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
1258
+ },
1259
+ async execute(args) {
1260
+ const p = normalizeRemotePath(String(args.path || ''))
1261
+ if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1262
+ let sftp
1263
+ try {
1264
+ sftp = await pool.sftp()
1265
+ } catch (err) {
1266
+ throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
1267
+ }
1268
+ let local
1269
+ if (args.localPath) {
1270
+ local = path.resolve(String(args.localPath))
1271
+ } else {
1272
+ const ws = wsPath()
1273
+ if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1274
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
1275
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1276
+ local = path.join(base, rel)
1277
+ }
1278
+ mkdirSync(path.dirname(local), { recursive: true })
1279
+ const buf = await sftp.readFile(p)
1280
+ writeFileSync(local, buf)
1281
+ const bytes = buf.byteLength
1282
+ return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1283
+ },
1284
+ }),
1285
+
1286
+ defineTool({
1287
+ name: 'rw_upload',
1288
+ description:
1289
+ 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1290
+ parameters: {
1291
+ localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1292
+ path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1293
+ },
1294
+ output: {
1295
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1296
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
1297
+ },
1298
+ async execute(args) {
1299
+ const rp = normalizeRemotePath(String(args.path || ''))
1300
+ const lp = String(args.localPath || '')
1301
+ if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1302
+ if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1303
+ let sftp
1304
+ try {
1305
+ sftp = await pool.sftp()
1306
+ } catch (err) {
1307
+ throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1308
+ }
1309
+ await mkdirRemoteDirs(sftp, rp)
1310
+ const buf = readFileSync(lp)
1311
+ await sftp.writeFile(rp, buf)
1312
+ const bytes = buf.byteLength
1313
+ return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
1314
+ },
1315
+ }),
1316
+
753
1317
  defineTool({
754
1318
  name: 'rw_disconnect',
755
1319
  description:
@@ -780,7 +1344,7 @@ export async function apply(ctx, config) {
780
1344
  return (
781
1345
  '## Remote workspace\n' +
782
1346
  `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
783
- 'Use the remote_* tools (rw_list_dir / rw_read_file / rw_exec) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
1347
+ 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_exec / rw_search) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
784
1348
  )
785
1349
  },
786
1350
  })
@@ -798,11 +1362,21 @@ export async function apply(ctx, config) {
798
1362
  text:
799
1363
  `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
800
1364
  `Remote workspace: ${s.workspace || '(none)'}\n` +
801
- `\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
1365
+ `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1366
+ (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1367
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
802
1368
  (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
803
1369
  }
804
1370
  },
805
1371
  })
1372
+ commands.register({
1373
+ name: 'remote-forget-key',
1374
+ description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1375
+ handler: () => {
1376
+ createHostKeyGuard(config).forgetHost()
1377
+ return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1378
+ },
1379
+ })
806
1380
  }
807
1381
 
808
1382
  // ── JSON endpoints for settings UI ─────────────────────────────────────────
@@ -814,13 +1388,63 @@ export async function apply(ctx, config) {
814
1388
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
815
1389
  res.end(JSON.stringify(body))
816
1390
  }
1391
+ // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1392
+ // unbounded memory on a local-only route.
1393
+ const MAX_BODY_BYTES = 1024 * 1024
817
1394
  const readBody = (req) =>
818
1395
  new Promise((resolve) => {
819
1396
  const chunks = []
820
- req.on('data', (c) => chunks.push(c))
1397
+ let total = 0
1398
+ req.on('data', (c) => {
1399
+ total += c.length
1400
+ if (total > MAX_BODY_BYTES) {
1401
+ req.removeAllListeners('data')
1402
+ resolve('{}')
1403
+ return
1404
+ }
1405
+ chunks.push(c)
1406
+ })
821
1407
  req.on('end', () => resolve(chunks.join('')))
822
1408
  })
823
1409
 
1410
+ // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1411
+ // 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
1412
+ // loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
1413
+ // 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
1414
+ // native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
1415
+ const PICK_TIMEOUT_MS = 120000
1416
+ const runPick = (bin, args) =>
1417
+ new Promise((resolve, reject) => {
1418
+ execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1419
+ if (err) {
1420
+ const code = err.code
1421
+ const msg = String(stderr || '')
1422
+ // 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
1423
+ if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1424
+ if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1425
+ if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1426
+ return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1427
+ }
1428
+ const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1429
+ resolve(p ? { path: p } : { cancelled: true })
1430
+ })
1431
+ })
1432
+ const pickLocalNative = async () => {
1433
+ const platform = process.platform
1434
+ if (platform === 'darwin') {
1435
+ return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1436
+ }
1437
+ if (platform === 'linux') {
1438
+ try {
1439
+ return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1440
+ } catch (err) {
1441
+ if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1442
+ throw err
1443
+ }
1444
+ }
1445
+ throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1446
+ }
1447
+
824
1448
  const routes = [
825
1449
  {
826
1450
  kind: 'exact',
@@ -857,8 +1481,10 @@ export async function apply(ctx, config) {
857
1481
  path: '/dsh-remote/ls',
858
1482
  handler: async (req, res) => {
859
1483
  try {
860
- const m = (req.url || '').match(/path=([^&]*)/)
861
- const p = m ? decodeURIComponent(m[1]) : wsPath()
1484
+ // Proper URL parsing so `+` in a path decodes to a literal space
1485
+ // (the old regex missed it).
1486
+ const q = new URL(req.url, 'http://localhost').searchParams
1487
+ const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
862
1488
  const out = await listDirStructured(p || '/')
863
1489
  return sendJson(res, 200, { path: p, items: out.items })
864
1490
  } catch (err) {
@@ -875,9 +1501,9 @@ export async function apply(ctx, config) {
875
1501
  const payload = JSON.parse((await readBody(req)) || '{}')
876
1502
  const p = normalizeRemotePath(String(payload.path || ''))
877
1503
  if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
878
- const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
879
- if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
880
- config.workspace = p
1504
+ const okDir = await isRemoteDir(p)
1505
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1506
+ persistWorkspace(p)
881
1507
  const local = ensureMirror(p, config.host, config.username, config.port)
882
1508
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
883
1509
  } catch (err) {
@@ -895,13 +1521,12 @@ export async function apply(ctx, config) {
895
1521
  const p = normalizeRemotePath(String(payload.path || ''))
896
1522
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
897
1523
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
898
- // optional: verify it's a directory over SSH when connected
899
- if (pool.client) {
900
- const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
901
- if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
902
- }
1524
+ // Always verify over SSH (connecting if needed) so an unconnected or
1525
+ // wrong host can never silently mint a mirror for a bogus path.
1526
+ const okDir = await isRemoteDir(p)
1527
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
903
1528
  const local = ensureMirror(p, config.host, config.username, config.port)
904
- config.workspace = p
1529
+ persistWorkspace(p)
905
1530
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
906
1531
  } catch (err) {
907
1532
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@@ -914,16 +1539,38 @@ export async function apply(ctx, config) {
914
1539
  handler: async (req, res) => {
915
1540
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
916
1541
  try {
917
- const dp = (ctx && ctx.get && ctx.get('directoryPicker')) || (ctx && ctx.directoryPicker) || null
918
- if (!dp || typeof dp.capability !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器服务不可用(缺少 DSH directory-picker backend)' })
919
- const cap = await Promise.resolve(dp.capability())
920
- if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器不可用(当前为非原生/浏览后端,请在输入框手动填本地路径)' })
921
- const pickAbort = new AbortController()
922
- const signal = pickAbort.signal || null
923
- const picked = await Promise.resolve(cap.pick(signal))
924
- pickAbort.abort()
925
- if (!picked || typeof picked !== 'string') return sendJson(res, 200, { ok: true, cancelled: true })
926
- return sendJson(res, 200, { ok: true, path: picked })
1542
+ // 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
1543
+ // ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
1544
+ // "cannot get property ... without inject"(该名字未在本插件
1545
+ // inject 里声明),ctx.get() 无此要求、缺失返回 null。
1546
+ // 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
1547
+ // 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
1548
+ let outcome = null
1549
+ let via = 'service'
1550
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1551
+ if (dp && typeof dp.capability === 'function') {
1552
+ try {
1553
+ const cap = await Promise.resolve(dp.capability())
1554
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
1555
+ const pickAbort = new AbortController()
1556
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
1557
+ pickAbort.abort()
1558
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
1559
+ }
1560
+ } catch (err) {
1561
+ outcome = null // DSH 服务出错 → 落到自持兜底
1562
+ }
1563
+ }
1564
+ if (!outcome) {
1565
+ via = 'own'
1566
+ try {
1567
+ outcome = await pickLocalNative()
1568
+ } catch (err) {
1569
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
1570
+ }
1571
+ }
1572
+ if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
1573
+ return sendJson(res, 200, { ok: true, path: outcome.path, via })
927
1574
  } catch (err) {
928
1575
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
929
1576
  }
@@ -1020,6 +1667,18 @@ export async function apply(ctx, config) {
1020
1667
  }
1021
1668
  },
1022
1669
  },
1670
+ {
1671
+ kind: 'exact',
1672
+ path: '/dsh-remote/forget-key',
1673
+ handler: async (req, res) => {
1674
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1675
+ // Drop the trusted host-key record for the CURRENT target so the next
1676
+ // connect re-records it (use after an intentional host reinstall or a
1677
+ // false MITM alarm).
1678
+ createHostKeyGuard(config).forgetHost()
1679
+ return sendJson(res, 200, { ok: true, ...status() })
1680
+ },
1681
+ },
1023
1682
  ]
1024
1683
 
1025
1684
  const disposers = routes.map((r) => webServer.register(r))