dsh-remote 0.5.9 → 0.6.7

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) ───────────────────────
@@ -100,18 +109,89 @@ const remotePathBase = (p) => {
100
109
  return base || 'workspace'
101
110
  }
102
111
 
112
+ /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
113
+ * own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
114
+ * data under the same root the harness uses means uninstalling / upgrading the
115
+ * app no longer leaves state behind in the home directory. */
116
+ function dshBase() {
117
+ const env = process.env.DSH_HOME
118
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
119
+ return path.join(homedir(), '.dsh')
120
+ }
121
+
122
+ /** Root holding every remote host's mirrors + the machine registry. */
123
+ function remoteWorkspacesRoot() {
124
+ return path.join(dshBase(), 'remote-workspaces')
125
+ }
126
+
127
+ /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
128
+ function copyDirSync(from, to) {
129
+ mkdirSync(to, { recursive: true })
130
+ for (const e of readdirSync(from, { withFileTypes: true })) {
131
+ const s = path.join(from, e.name)
132
+ const d = path.join(to, e.name)
133
+ if (e.isDirectory()) copyDirSync(s, d)
134
+ else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
135
+ }
136
+ }
137
+
138
+ /** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
139
+ * regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
140
+ * desktop install keeps its existing machines and mirrors. Idempotent; a
141
+ * rename across devices falls back to a copy. */
142
+ function migrateLegacyData() {
143
+ const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
144
+ const target = remoteWorkspacesRoot()
145
+ if (legacy === target || !existsSync(legacy) || existsSync(target)) return
146
+ try {
147
+ mkdirSync(path.dirname(target), { recursive: true })
148
+ try {
149
+ renameSync(legacy, target)
150
+ } catch (err) {
151
+ if (err.code !== 'EXDEV') throw err
152
+ copyDirSync(legacy, target)
153
+ }
154
+ } catch {
155
+ // Migration is best-effort: a fresh registry is created on next save.
156
+ }
157
+ }
158
+
159
+ /** 32-bit string hash rendered in base36 (short, collision-safe enough). */
160
+ function shortHash(s) {
161
+ let h = 0
162
+ for (let i = 0; i < s.length; i++) {
163
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0
164
+ }
165
+ return (h >>> 0).toString(36)
166
+ }
167
+
103
168
  /** Stable local root for one remote host's mirrors */
104
169
  function mirrorRootFor(host, user, port) {
105
170
  const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
106
- return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
171
+ return path.join(remoteWorkspacesRoot(), tag)
107
172
  }
108
173
 
109
174
  /** Local mirror dir for a specific remote path (idempotent → returns same dir).
110
175
  * Named after the remote directory's basename so the harness workspace label
111
- * reads cleanly (e.g. .../project) instead of a concatenated hash string. */
176
+ * reads cleanly (e.g. .../project). When the plain basename dir is already
177
+ * taken by a DIFFERENT remote path on the same host, a short path-hash suffix
178
+ * is appended so mirrors never collide. */
112
179
  function mirrorDirFor(remotePath, host, user, port) {
113
180
  const base = remotePathBase(remotePath)
114
- return path.join(mirrorRootFor(host, user, port), base)
181
+ const root = mirrorRootFor(host, user, port)
182
+ const plain = path.join(root, base)
183
+ const norm = normalizeRemotePath(remotePath)
184
+ // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
185
+ try {
186
+ const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
187
+ if (meta.remotePath === norm) return plain
188
+ } catch {
189
+ /* no mirror yet → fall through */
190
+ }
191
+ // Plain dir is free → first mirror for this basename keeps the clean name.
192
+ // It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
193
+ if (!existsSync(plain)) return plain
194
+ return path.join(root, base + '-' + shortHash(norm))
115
195
  }
116
196
 
117
197
  /** Create the local mirror dir + a meta file describing its remote origin. */
@@ -120,82 +200,130 @@ function ensureMirror(remotePath, host, user, port) {
120
200
  mkdirSync(dir, { recursive: true })
121
201
  writeFileSync(
122
202
  path.join(dir, '.dsh-remote-meta.json'),
123
- JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
203
+ JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
124
204
  )
125
205
  return dir
126
206
  }
127
207
 
128
- /** Recursively sync remote → local mirror. Bounded by depth/files. */
129
- async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
208
+ /** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
209
+ async function mapLimit(items, limit, fn) {
210
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
211
+ let next = 0
212
+ const worker = async () => {
213
+ while (next < items.length) {
214
+ const i = next++
215
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
216
+ }
217
+ }
218
+ const workers = []
219
+ for (let w = 0; w < limitN; w++) workers.push(worker())
220
+ await Promise.all(workers)
221
+ }
222
+
223
+ /** Recursively sync remote → local mirror. Bounded by depth/files/size;
224
+ * skips files whose size+mtime already match locally. Returns counters. */
225
+ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
130
226
  const entries = await sftp.readdir(remoteDir).then(
131
227
  (list) => list,
132
228
  () => [],
133
229
  )
134
- let files = 0
135
- const touched = []
230
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
231
+
232
+ // Directories first (sequential, depth-bounded) so every file has a home.
136
233
  for (const e of entries) {
137
234
  const name = String(e.filename)
138
235
  if (name === '.' || name === '..') continue
236
+ const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
237
+ if (!isDir) continue
238
+ if (maxDepth <= 0 || stats.files >= maxFiles) continue
239
+ const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
240
+ const lp = path.join(localDir, name)
241
+ mkdirSync(lp, { recursive: true })
242
+ const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
243
+ stats.dirs += sub.dirs + 1
244
+ stats.files += sub.files
245
+ stats.skippedUnchanged += sub.skippedUnchanged
246
+ stats.skippedLarge += sub.skippedLarge
247
+ stats.touched.push(...sub.touched)
248
+ if (stats.files >= maxFiles) break
249
+ }
250
+ if (stats.files >= maxFiles) return stats
251
+
252
+ // Files in one bounded-parallel sweep per directory level.
253
+ const fileEntries = entries.filter(
254
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
255
+ String(e.filename) !== '.' && String(e.filename) !== '..',
256
+ )
257
+ await mapLimit(fileEntries, 4, async (e) => {
258
+ if (stats.files >= maxFiles) return
259
+ const name = String(e.filename)
139
260
  const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
140
261
  const lp = path.join(localDir, name)
141
- 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
151
262
  try {
263
+ const st = await sftp.stat(rp)
264
+ if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
265
+ const lpStat = existsSync(lp) ? statSync(lp) : null
266
+ const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
267
+ if (unchanged) { stats.skippedUnchanged++; return }
152
268
  const buf = await sftp.readFile(rp)
153
269
  writeFileSync(lp, buf)
154
- touched.push(rp)
155
- files++
270
+ try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
271
+ stats.files++
272
+ stats.touched.push(rp)
156
273
  } catch {
157
274
  /* skip unreadable */
158
275
  }
159
- }
160
- return { files, touched }
276
+ })
277
+ return stats
161
278
  }
162
279
 
163
- /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
164
- async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
280
+ /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
281
+ * skips files whose remote copy already matches (same size, remote no older). */
282
+ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
165
283
  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 = []
284
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
285
+
169
286
  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
287
+ if (!e.isDirectory()) continue
288
+ if (stats.files >= maxFiles) break
289
+ const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
290
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
291
+ stats.dirs++
292
+ const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
293
+ stats.files += sub.files
294
+ stats.skippedUnchanged += sub.skippedUnchanged
295
+ stats.skippedLarge += sub.skippedLarge
296
+ stats.pushed.push(...sub.pushed)
297
+ if (stats.files >= maxFiles) break
298
+ }
299
+ if (stats.files >= maxFiles) return stats
300
+
301
+ const fileEntries = entries.filter((e) => !e.isDirectory())
302
+ await mapLimit(fileEntries, 4, async (e) => {
303
+ if (stats.files >= maxFiles) return
182
304
  const lp = path.join(localDir, e.name)
183
305
  const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
184
306
  try {
307
+ const lstat = statSync(lp)
308
+ if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
309
+ let rstat = null
310
+ try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
311
+ const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
312
+ if (unchanged) { stats.skippedUnchanged++; return }
185
313
  const buf = readFileSync(lp)
186
314
  await sftp.writeFile(rp, buf)
187
- pushed.push(rp)
188
- files++
315
+ stats.files++
316
+ stats.pushed.push(rp)
189
317
  } catch {
190
318
  /* skip unreadable / unwritable */
191
319
  }
192
- }
193
- return { files, pushed }
320
+ })
321
+ return stats
194
322
  }
195
323
 
196
324
  // ── persistent multi-machine registry ─────────────────────────────────────
197
325
  const MACHINES_FILE = 'machines.json'
198
- const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
326
+ const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
199
327
  function loadMachines() {
200
328
  try {
201
329
  const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
@@ -225,6 +353,98 @@ function applyMachine(config, m) {
225
353
  }
226
354
  function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
227
355
 
356
+ // ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
357
+ const KNOWN_HOSTS_FILE = 'known_hosts.json'
358
+ const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
359
+
360
+ /** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
361
+ function loadKnownHosts() {
362
+ try {
363
+ const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
364
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
365
+ } catch {}
366
+ return {}
367
+ }
368
+
369
+ /** Extract the host-key algorithm name from a raw SSH host-key blob
370
+ * (SSH wire format: `uint32 len` + algorithm string + key data). */
371
+ function blobAlgorithm(blob) {
372
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
373
+ try {
374
+ const len = blob.readUInt32BE(0)
375
+ return blob.toString('utf8', 4, 4 + len)
376
+ } catch {
377
+ return ''
378
+ }
379
+ }
380
+
381
+ /** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
382
+ * in a known_hosts file (without the `SHA256:` prefix).
383
+ *
384
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
385
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
386
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
387
+ * argument must be of type string or an instance of Buffer, TypedArray, or
388
+ * DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
389
+ * v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
390
+ * shapes defensively. */
391
+ function keyFingerprint(key) {
392
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
393
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
394
+ return createHash('sha256').update(blob).digest('base64')
395
+ }
396
+
397
+ /** Build an ssh2 `hostVerifier` bound to the current config. Returns
398
+ * `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
399
+ * - `off` → always accepts;
400
+ * - `accept-new` → records a host's key on first connect, rejects any CHANGE
401
+ * afterwards (classic TOFU / MITM detection);
402
+ * - `verify` → same, plus rejects hosts never recorded before. */
403
+ function createHostKeyGuard(config) {
404
+ const id = () => `${config.host}:${config.port}`
405
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
406
+ ? config.hostKeyMode
407
+ : 'accept-new'
408
+ const guard = {
409
+ mode,
410
+ lastError: null,
411
+ knownHosts: loadKnownHosts,
412
+ forgetHost() {
413
+ const kh = loadKnownHosts()
414
+ delete kh[id()]
415
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
416
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
417
+ },
418
+ verifier(key) {
419
+ if (mode === 'off') return true
420
+ const fp = keyFingerprint(key)
421
+ const kh = loadKnownHosts()
422
+ const stored = kh[id()]
423
+ if (stored) {
424
+ if (stored.fingerprint === fp) return true
425
+ guard.lastError =
426
+ `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
427
+ 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
428
+ return false
429
+ }
430
+ if (mode === 'verify') {
431
+ guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
432
+ return false
433
+ }
434
+ kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
435
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
436
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
437
+ return true
438
+ },
439
+ }
440
+ return guard
441
+ }
442
+
443
+ /** Whether the current target's key has been recorded/trusted before. */
444
+ function isHostKeyKnown(host, port) {
445
+ return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
446
+ }
447
+
228
448
  // ── SSH pool (key OR password) ──────────────────────────────────────────────
229
449
 
230
450
  class SshPool {
@@ -232,6 +452,9 @@ class SshPool {
232
452
  this.config = config
233
453
  this.client = null
234
454
  this.connecting = null
455
+ // Generational token: bumped on every target change / close so a stale
456
+ // in-flight connect can never hand this pool a connection to an old host.
457
+ this.epoch = 0
235
458
  }
236
459
 
237
460
  resolveKeyPath() {
@@ -257,31 +480,47 @@ class SshPool {
257
480
  connect() {
258
481
  if (this.client) return Promise.resolve(this.client)
259
482
  if (this.connecting) return this.connecting
260
- this.connecting = this._doConnect().finally(() => {
261
- this.connecting = null
262
- })
263
- return this.connecting
483
+ const epoch = this.epoch
484
+ const pending = this._doConnect(epoch)
485
+ this.connecting = pending
486
+ const clear = () => {
487
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
488
+ }
489
+ // Keep the chain clean in both directions: the raw pending is what callers
490
+ // await (they still see rejections); this side just clears the slot.
491
+ pending.then(clear, clear)
492
+ return pending
264
493
  }
265
494
 
266
- _doConnect() {
495
+ _doConnect(epoch) {
267
496
  return new Promise((resolve, reject) => {
268
497
  const client = new Client()
269
498
  let settled = false
499
+ // Fresh host-key guard per connect attempt so lastError is never stale.
500
+ const guard = createHostKeyGuard(this.config)
501
+ const isCurrent = () => this.epoch === epoch
270
502
  const fail = (err) => {
271
503
  if (settled) return
272
504
  settled = true
273
- if (this.client === client) this.client = null
274
- reject(err)
505
+ if (isCurrent() && this.client === client) this.client = null
506
+ reject(guard.lastError ? new Error(guard.lastError) : err)
275
507
  }
276
508
  client.on('ready', () => {
277
509
  if (settled) return
278
510
  settled = true
511
+ if (!isCurrent()) {
512
+ // The target changed while we were connecting — never adopt this
513
+ // client, and make sure the caller learns the connect is void.
514
+ try { client.end() } catch {}
515
+ reject(new Error('ssh target changed during connect'))
516
+ return
517
+ }
279
518
  this.client = client
280
519
  resolve(client)
281
520
  })
282
521
  client.on('error', fail)
283
522
  client.on('close', () => {
284
- if (this.client === client) this.client = null
523
+ if (isCurrent() && this.client === client) this.client = null
285
524
  fail(new Error('ssh connection closed'))
286
525
  })
287
526
 
@@ -292,6 +531,7 @@ class SshPool {
292
531
  readyTimeout: this.config.connectTimeoutMs,
293
532
  keepaliveInterval: 15000,
294
533
  keepaliveCountMax: 3,
534
+ hostVerifier: (key) => guard.verifier(key),
295
535
  }
296
536
  if (this.config.password) {
297
537
  opts.password = this.config.password
@@ -341,9 +581,17 @@ class SshPool {
341
581
  if (settled) return
342
582
  exitCode = -1
343
583
  exitSignal = 'TIMEOUT'
584
+ // Kill the remote command (SIGTERM) rather than just dropping the
585
+ // channel, so a runaway process cannot keep running and holding
586
+ // the SSH connection after we've given up on its output. The hard
587
+ // close below frees the channel even if the process ignores it.
344
588
  try {
345
- stream.close()
589
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
346
590
  } catch {}
591
+ const hardClose = setTimeout(() => {
592
+ try { stream.close() } catch {}
593
+ }, 800)
594
+ if (typeof hardClose.unref === 'function') hardClose.unref()
347
595
  settle()
348
596
  }, timeoutMs || this.config.commandTimeoutMs)
349
597
  stream.on('close', (code, signal) => {
@@ -389,8 +637,17 @@ class SshPool {
389
637
  }
390
638
 
391
639
  close() {
640
+ // Bump the epoch so any in-flight connect is orphaned, drop the pending
641
+ // promise (its rejection becomes nobody's problem — swallow it), and end
642
+ // the live client if there is one.
643
+ this.epoch++
392
644
  const client = this.client
393
645
  this.client = null
646
+ const pending = this.connecting
647
+ this.connecting = null
648
+ if (pending && typeof pending.catch === 'function') {
649
+ try { pending.catch(() => {}) } catch {}
650
+ }
394
651
  if (client) {
395
652
  try {
396
653
  client.end()
@@ -405,6 +662,10 @@ export async function apply(ctx, config) {
405
662
  const pool = new SshPool(config)
406
663
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
407
664
 
665
+ // Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
666
+ // location so existing machines + mirrors keep working after the move.
667
+ migrateLegacyData()
668
+
408
669
  // ── machine registry (multi-host) ─────────────────────────────────────────
409
670
  const store = loadMachines()
410
671
  const machines = store.list
@@ -433,6 +694,19 @@ export async function apply(ctx, config) {
433
694
  if (cur && cur.host) applyMachine(config, cur)
434
695
  }
435
696
 
697
+ /** Set the active remote workspace AND persist it on the current machine so
698
+ * it survives restarts (previously a tool-set workspace reset on reload). */
699
+ const persistWorkspace = (p) => {
700
+ config.workspace = p
701
+ if (store.currentId) {
702
+ const i = machineIndex(store.currentId)
703
+ if (i >= 0) {
704
+ machines[i].workspace = p
705
+ saveMachines(machines, store.currentId)
706
+ }
707
+ }
708
+ }
709
+
436
710
  const run = async (cmd, opts = {}) => {
437
711
  const res = await pool.exec(cmd, opts.timeoutMs)
438
712
  const parts = []
@@ -445,27 +719,51 @@ export async function apply(ctx, config) {
445
719
  return text
446
720
  }
447
721
 
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. */
722
+ /** Structured-listing of a remote dir: name + usable type.
723
+ *
724
+ * Implementation note — this deliberately uses `ls -1A -F` instead of a
725
+ * `for f in .[!.]* *` loop or `find`:
726
+ * • `-F` marks real directories with a trailing `/` and symlinks with `@`;
727
+ * on Linux GNU ls classifies from readdir `d_type` WITHOUT stat, so the
728
+ * listing can never hang on a stuck FUSE/network mount the way
729
+ * `[ -d "$f" ]` / `find -type` do (those stat every entry and block in
730
+ * an uninterruptible D-state that even SIGTERM can't clear).
731
+ * • there is no shell glob, so zsh's `nomatch` can't abort the listing on
732
+ * directories that have no dotfiles (the old `.[!.]*` glob did).
733
+ * Symlinked entries (e.g. `/bin@`) get ONE bounded follow-up stat — only
734
+ * symlinks are touched, never the stuck mount — to decide dir vs file. */
451
735
  const listDirStructured = async (p, timeoutMs) => {
452
736
  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 = []
737
+ const to = timeoutMs || config.commandTimeoutMs
738
+ const res = await pool.exec(`ls -1A -F ${shq(target)}`, to)
459
739
  if (res.code !== 0 && res.stderr) {
460
740
  throw new Error('ls failed: ' + (res.stderr || '').trim())
461
741
  }
742
+ const items = []
743
+ const symIdx = []
462
744
  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)
467
- if (name === '.' || name === '..' || !name) continue
468
- items.push({ type: type === 'd' ? 'dir' : 'file', name })
745
+ if (!line) continue
746
+ const c = line[line.length - 1]
747
+ if (c === '/') items.push({ type: 'dir', name: line.slice(0, -1) })
748
+ else if (c === '@') {
749
+ items.push({ type: 'symlink', name: line.slice(0, -1) })
750
+ symIdx.push(items.length - 1)
751
+ } else {
752
+ // regular file, executable (`*`), socket (`=`), fifo (`|`) …
753
+ items.push({ type: 'file', name: c === '*' || c === '=' || c === '|' ? line.slice(0, -1) : line })
754
+ }
755
+ }
756
+ // Resolve symlink-to-dir vs symlink-to-file. Bounded and failure-tolerant:
757
+ // if the stat is slow it times out and symlinks just degrade to files
758
+ // (non-enterable) instead of failing the whole browse.
759
+ if (symIdx.length) {
760
+ const cmd = 'for f in ' + symIdx.map((i) => shq(items[i].name)).join(' ') +
761
+ '; do if [ -d "$f" ]; then echo d; else echo f; fi; done'
762
+ const r2 = await pool.exec(cmd, Math.min(to, 5000))
763
+ const kinds = String(r2.stdout || '').split('\n')
764
+ for (let i = 0; i < symIdx.length; i++) {
765
+ items[symIdx[i]].type = (kinds[i] || '').trim() === 'd' ? 'dir' : 'file'
766
+ }
469
767
  }
470
768
  return { path: target, items }
471
769
  }
@@ -482,6 +780,8 @@ export async function apply(ctx, config) {
482
780
  localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
483
781
  currentId: store.currentId || null,
484
782
  machines: machines.map(sanitizeMachine),
783
+ hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
784
+ hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
485
785
  })
486
786
 
487
787
  // ── tools ─────────────────────────────────────────────────────────────────
@@ -495,7 +795,7 @@ export async function apply(ctx, config) {
495
795
  defineTool({
496
796
  name: 'rw_info',
497
797
  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.',
798
+ '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
799
  parameters: {},
500
800
  output: {
501
801
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -506,6 +806,7 @@ export async function apply(ctx, config) {
506
806
  const lines = [
507
807
  `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
508
808
  `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
809
+ `Local mirror: ${s.localMirror || '(none)'}`,
509
810
  `Connected: ${s.connected ? 'yes' : 'no'}`,
510
811
  '',
511
812
  ]
@@ -577,10 +878,10 @@ export async function apply(ctx, config) {
577
878
  const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
578
879
  const ok = res.stdout.trim() === 'DIR'
579
880
  if (!ok) return { text: `not a directory (or missing) on ${p}` }
580
- config.workspace = p
881
+ persistWorkspace(p)
581
882
  const local = ensureMirror(p, config.host, config.username, config.port)
582
883
  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.`,
884
+ 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
885
  }
585
886
  },
586
887
  }),
@@ -610,8 +911,11 @@ export async function apply(ctx, config) {
610
911
  } catch (err) {
611
912
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
612
913
  }
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)' : ''}.` }
914
+ const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
915
+ let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
916
+ if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
917
+ if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
918
+ return { text }
615
919
  },
616
920
  }),
617
921
 
@@ -638,8 +942,11 @@ export async function apply(ctx, config) {
638
942
  } catch (err) {
639
943
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
640
944
  }
641
- const { files } = await pushTree(sftp, local, p, p, maxFiles)
642
- return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
945
+ const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
946
+ let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
947
+ if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
948
+ if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
949
+ return { text }
643
950
  },
644
951
  }),
645
952
 
@@ -657,7 +964,9 @@ export async function apply(ctx, config) {
657
964
  async execute(args) {
658
965
  const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
659
966
  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)}`) }
967
+ // `ls -la` (no --color / -N): piped output never carries color on either
968
+ // GNU or BSD ls, so the GNU-only flags would break macOS/BSD remotes.
969
+ return { text: await run(`ls -la ${shq(p)}`) }
661
970
  },
662
971
  }),
663
972
 
@@ -682,7 +991,9 @@ export async function apply(ctx, config) {
682
991
  let from = Math.max(Number(args.startLine) || 1, 1)
683
992
  let to = Number(args.endLine) || 0
684
993
  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 })
994
+ // Absolute paths start with '/', so the GNU-only `--` terminator is
995
+ // dropped to keep BSD/macOS sed (which rejects `--`) working.
996
+ const raw = await run(`sed -n '${from},${to}p' ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
686
997
  const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
687
998
  let text = numbered === '' ? '(empty or out of range)' : numbered
688
999
  if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
@@ -693,9 +1004,10 @@ export async function apply(ctx, config) {
693
1004
  defineTool({
694
1005
  name: 'rw_exec',
695
1006
  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.',
1007
+ '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
1008
  parameters: {
698
1009
  command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1010
+ cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
699
1011
  },
700
1012
  output: {
701
1013
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -704,7 +1016,10 @@ export async function apply(ctx, config) {
704
1016
  async execute(args) {
705
1017
  const cmd = String(args.command || '')
706
1018
  if (!cmd) throw new Error('rw_exec: command is required')
707
- return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
1019
+ const ws = wsPath()
1020
+ const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1021
+ const full = cwd ? `cd ${shq(cwd)} && ${cmd}` : cmd
1022
+ return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
708
1023
  },
709
1024
  }),
710
1025
 
@@ -750,6 +1065,110 @@ export async function apply(ctx, config) {
750
1065
  },
751
1066
  }),
752
1067
 
1068
+ defineTool({
1069
+ name: 'rw_search',
1070
+ description:
1071
+ '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.',
1072
+ parameters: {
1073
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1074
+ path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1075
+ glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
1076
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1077
+ },
1078
+ output: {
1079
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1080
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1081
+ },
1082
+ async execute(args) {
1083
+ const pattern = String(args.pattern || '')
1084
+ if (!pattern) throw new Error('rw_search: pattern is required')
1085
+ const ws = wsPath()
1086
+ const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1087
+ if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1088
+ const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1089
+ const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1090
+ // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
1091
+ // differ on BSD, and GNU-only --include is avoided in favor of find -name.
1092
+ const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
1093
+ return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
1094
+ },
1095
+ }),
1096
+
1097
+ defineTool({
1098
+ name: 'rw_download',
1099
+ description:
1100
+ '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.',
1101
+ parameters: {
1102
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1103
+ localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1104
+ },
1105
+ output: {
1106
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1107
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
1108
+ },
1109
+ async execute(args) {
1110
+ const p = normalizeRemotePath(String(args.path || ''))
1111
+ if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1112
+ let sftp
1113
+ try {
1114
+ sftp = await pool.sftp()
1115
+ } catch (err) {
1116
+ throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
1117
+ }
1118
+ let local
1119
+ if (args.localPath) {
1120
+ local = path.resolve(String(args.localPath))
1121
+ } else {
1122
+ const ws = wsPath()
1123
+ if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1124
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
1125
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1126
+ local = path.join(base, rel)
1127
+ }
1128
+ mkdirSync(path.dirname(local), { recursive: true })
1129
+ const buf = await sftp.readFile(p)
1130
+ writeFileSync(local, buf)
1131
+ const bytes = buf.byteLength
1132
+ return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1133
+ },
1134
+ }),
1135
+
1136
+ defineTool({
1137
+ name: 'rw_upload',
1138
+ description:
1139
+ '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.',
1140
+ parameters: {
1141
+ localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1142
+ path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1143
+ },
1144
+ output: {
1145
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1146
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
1147
+ },
1148
+ async execute(args) {
1149
+ const rp = normalizeRemotePath(String(args.path || ''))
1150
+ const lp = String(args.localPath || '')
1151
+ if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1152
+ if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1153
+ let sftp
1154
+ try {
1155
+ sftp = await pool.sftp()
1156
+ } catch (err) {
1157
+ throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1158
+ }
1159
+ const segs = remoteDirname(rp).split('/').filter(Boolean)
1160
+ let cur = ''
1161
+ for (const s of segs) {
1162
+ cur += '/' + s
1163
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
1164
+ }
1165
+ const buf = readFileSync(lp)
1166
+ await sftp.writeFile(rp, buf)
1167
+ const bytes = buf.byteLength
1168
+ return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
1169
+ },
1170
+ }),
1171
+
753
1172
  defineTool({
754
1173
  name: 'rw_disconnect',
755
1174
  description:
@@ -780,7 +1199,7 @@ export async function apply(ctx, config) {
780
1199
  return (
781
1200
  '## Remote workspace\n' +
782
1201
  `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.'
1202
+ '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
1203
  )
785
1204
  },
786
1205
  })
@@ -798,11 +1217,21 @@ export async function apply(ctx, config) {
798
1217
  text:
799
1218
  `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
800
1219
  `Remote workspace: ${s.workspace || '(none)'}\n` +
801
- `\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
1220
+ `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1221
+ (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1222
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
802
1223
  (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
803
1224
  }
804
1225
  },
805
1226
  })
1227
+ commands.register({
1228
+ name: 'remote-forget-key',
1229
+ description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1230
+ handler: () => {
1231
+ createHostKeyGuard(config).forgetHost()
1232
+ return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1233
+ },
1234
+ })
806
1235
  }
807
1236
 
808
1237
  // ── JSON endpoints for settings UI ─────────────────────────────────────────
@@ -814,13 +1243,63 @@ export async function apply(ctx, config) {
814
1243
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
815
1244
  res.end(JSON.stringify(body))
816
1245
  }
1246
+ // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1247
+ // unbounded memory on a local-only route.
1248
+ const MAX_BODY_BYTES = 1024 * 1024
817
1249
  const readBody = (req) =>
818
1250
  new Promise((resolve) => {
819
1251
  const chunks = []
820
- req.on('data', (c) => chunks.push(c))
1252
+ let total = 0
1253
+ req.on('data', (c) => {
1254
+ total += c.length
1255
+ if (total > MAX_BODY_BYTES) {
1256
+ req.removeAllListeners('data')
1257
+ resolve('{}')
1258
+ return
1259
+ }
1260
+ chunks.push(c)
1261
+ })
821
1262
  req.on('end', () => resolve(chunks.join('')))
822
1263
  })
823
1264
 
1265
+ // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1266
+ // 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
1267
+ // loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
1268
+ // 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
1269
+ // native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
1270
+ const PICK_TIMEOUT_MS = 120000
1271
+ const runPick = (bin, args) =>
1272
+ new Promise((resolve, reject) => {
1273
+ execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1274
+ if (err) {
1275
+ const code = err.code
1276
+ const msg = String(stderr || '')
1277
+ // 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
1278
+ if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1279
+ if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1280
+ if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1281
+ return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1282
+ }
1283
+ const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1284
+ resolve(p ? { path: p } : { cancelled: true })
1285
+ })
1286
+ })
1287
+ const pickLocalNative = async () => {
1288
+ const platform = process.platform
1289
+ if (platform === 'darwin') {
1290
+ return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1291
+ }
1292
+ if (platform === 'linux') {
1293
+ try {
1294
+ return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1295
+ } catch (err) {
1296
+ if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1297
+ throw err
1298
+ }
1299
+ }
1300
+ throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1301
+ }
1302
+
824
1303
  const routes = [
825
1304
  {
826
1305
  kind: 'exact',
@@ -857,8 +1336,10 @@ export async function apply(ctx, config) {
857
1336
  path: '/dsh-remote/ls',
858
1337
  handler: async (req, res) => {
859
1338
  try {
860
- const m = (req.url || '').match(/path=([^&]*)/)
861
- const p = m ? decodeURIComponent(m[1]) : wsPath()
1339
+ // Proper URL parsing so `+` in a path decodes to a literal space
1340
+ // (the old regex missed it).
1341
+ const q = new URL(req.url, 'http://localhost').searchParams
1342
+ const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
862
1343
  const out = await listDirStructured(p || '/')
863
1344
  return sendJson(res, 200, { path: p, items: out.items })
864
1345
  } catch (err) {
@@ -877,7 +1358,7 @@ export async function apply(ctx, config) {
877
1358
  if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
878
1359
  const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
879
1360
  if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
880
- config.workspace = p
1361
+ persistWorkspace(p)
881
1362
  const local = ensureMirror(p, config.host, config.username, config.port)
882
1363
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
883
1364
  } catch (err) {
@@ -895,13 +1376,12 @@ export async function apply(ctx, config) {
895
1376
  const p = normalizeRemotePath(String(payload.path || ''))
896
1377
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
897
1378
  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
- }
1379
+ // Always verify over SSH (connecting if needed) so an unconnected or
1380
+ // wrong host can never silently mint a mirror for a bogus path.
1381
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
1382
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
903
1383
  const local = ensureMirror(p, config.host, config.username, config.port)
904
- config.workspace = p
1384
+ persistWorkspace(p)
905
1385
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
906
1386
  } catch (err) {
907
1387
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@@ -914,16 +1394,38 @@ export async function apply(ctx, config) {
914
1394
  handler: async (req, res) => {
915
1395
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
916
1396
  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 })
1397
+ // 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
1398
+ // ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
1399
+ // "cannot get property ... without inject"(该名字未在本插件
1400
+ // inject 里声明),ctx.get() 无此要求、缺失返回 null。
1401
+ // 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
1402
+ // 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
1403
+ let outcome = null
1404
+ let via = 'service'
1405
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1406
+ if (dp && typeof dp.capability === 'function') {
1407
+ try {
1408
+ const cap = await Promise.resolve(dp.capability())
1409
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
1410
+ const pickAbort = new AbortController()
1411
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
1412
+ pickAbort.abort()
1413
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
1414
+ }
1415
+ } catch (err) {
1416
+ outcome = null // DSH 服务出错 → 落到自持兜底
1417
+ }
1418
+ }
1419
+ if (!outcome) {
1420
+ via = 'own'
1421
+ try {
1422
+ outcome = await pickLocalNative()
1423
+ } catch (err) {
1424
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
1425
+ }
1426
+ }
1427
+ if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
1428
+ return sendJson(res, 200, { ok: true, path: outcome.path, via })
927
1429
  } catch (err) {
928
1430
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
929
1431
  }
@@ -1020,6 +1522,18 @@ export async function apply(ctx, config) {
1020
1522
  }
1021
1523
  },
1022
1524
  },
1525
+ {
1526
+ kind: 'exact',
1527
+ path: '/dsh-remote/forget-key',
1528
+ handler: async (req, res) => {
1529
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1530
+ // Drop the trusted host-key record for the CURRENT target so the next
1531
+ // connect re-records it (use after an intentional host reinstall or a
1532
+ // false MITM alarm).
1533
+ createHostKeyGuard(config).forgetHost()
1534
+ return sendJson(res, 200, { ok: true, ...status() })
1535
+ },
1536
+ },
1023
1537
  ]
1024
1538
 
1025
1539
  const disposers = routes.map((r) => webServer.register(r))