dsh-remote 0.8.18 → 0.8.20

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
@@ -20,33 +20,32 @@
20
20
  // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
21
21
  import z from '@deepseek-ai/schemastery'
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
- import ssh2 from 'ssh2'
24
23
  import { execFile } from 'node:child_process'
25
- import zlib from 'node:zlib'
26
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs'
24
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch } from 'node:fs'
27
25
  import { homedir } from 'node:os'
28
26
  import path from 'node:path'
29
27
  import iconv from 'iconv-lite'
30
28
 
31
29
  import {
32
30
  shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
33
- toSftpPath, toShellPath, toDisplayPath, remotePathBase, truncate, shortHash, relPathUnder,
31
+ toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
34
32
  } from './paths.js'
35
- import { blobAlgorithm, keyFingerprint } from './hostkey.js'
33
+ import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
36
34
  import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
37
35
  import { friendlyMessage } from './errors.js'
38
36
  import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js'
39
37
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
40
38
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
41
- import { searchTree } from './search.js'
42
- import { resolveMirror, poolKey } from './binding.js'
39
+ import { searchRemote } from './search.js'
40
+ import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
43
41
  import { TaskManager } from './tasks.js'
44
42
  import { ForwardManager } from './forwards.js'
45
43
  import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
46
44
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
47
45
  import { registerHttpTransports } from './http-transport.js'
48
-
49
- const { Client } = ssh2
46
+ import { SshPool } from './pool.js'
47
+ import { createFsRoutes } from './routes-fs.js'
48
+ import { removeRemoteTree } from './remote-fs.js'
50
49
 
51
50
  export const name = 'dsh-remote'
52
51
 
@@ -132,33 +131,6 @@ function remoteWorkspacesRoot() {
132
131
  return path.join(dshBase(), 'remote-workspaces')
133
132
  }
134
133
 
135
- /** Safe path segment for a session id (mirrors DSH's encodeSegment: alnum + _- .).
136
- * Session dirs keep the id mostly verbatim; sanitize anything exotic. */
137
- function encodeSegmentSafe(id) {
138
- const s = String(id || '').replace(/[^A-Za-z0-9._-]/g, '_')
139
- return s || 'session'
140
- }
141
-
142
- /** Read the cwd from a session log header. Handles plain JSONL, .gz, and
143
- * multi-frame zstd (decompress only the first frame, which holds the header).
144
- * Returns the header cwd string, or '' when unreadable/absent. */
145
- function readSessionHeaderCwd(file) {
146
- try {
147
- let buf = readFileSync(file)
148
- if (/\.zstd$/.test(file)) {
149
- buf = zlib.zstdDecompressSync(buf)
150
- } else if (/\.gz$/.test(file)) {
151
- buf = zlib.gunzipSync(buf)
152
- }
153
- const nl = buf.indexOf(10)
154
- if (nl < 0) return ''
155
- const head = JSON.parse(buf.subarray(0, nl).toString('utf8'))
156
- return typeof head.cwd === 'string' ? head.cwd : ''
157
- } catch {
158
- return ''
159
- }
160
- }
161
-
162
134
  /** Local mirrors of one remote host. */
163
135
  function mirrorRootFor(host, user, port) {
164
136
  const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
@@ -247,550 +219,7 @@ const machineId = _machineId
247
219
  // ── host-key registry (TOFU) ──────────────────────────────────────────────
248
220
  const KNOWN_HOSTS_FILE = 'known_hosts.json'
249
221
  const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
250
-
251
- function loadKnownHosts() {
252
- try {
253
- const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
254
- if (j && typeof j === 'object' && !Array.isArray(j)) return j
255
- } catch {}
256
- return {}
257
- }
258
-
259
- /** Build an ssh2 `hostVerifier` bound to the current config. */
260
- function createHostKeyGuard(config) {
261
- const id = () => `${config.host}:${config.port}`
262
- const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
263
- ? config.hostKeyMode
264
- : 'accept-new'
265
- const guard = {
266
- mode,
267
- lastError: null,
268
- knownHosts: loadKnownHosts,
269
- forgetHost() {
270
- const kh = loadKnownHosts()
271
- delete kh[id()]
272
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
273
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
274
- },
275
- verifier(key) {
276
- if (mode === 'off') return true
277
- const fp = keyFingerprint(key)
278
- const kh = loadKnownHosts()
279
- const stored = kh[id()]
280
- if (stored) {
281
- if (stored.fingerprint === fp) return true
282
- guard.lastError =
283
- `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
284
- 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
285
- return false
286
- }
287
- if (mode === 'verify') {
288
- guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
289
- return false
290
- }
291
- kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
292
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
293
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
294
- return true
295
- },
296
- }
297
- return guard
298
- }
299
-
300
- /** Whether the current target's key has been recorded/trusted before. */
301
- function isHostKeyKnown(host, port) {
302
- return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
303
- }
304
-
305
- // ── SSH pool (key / password / agent / keyboard-interactive / proxy) ──────
306
-
307
- class SshPool {
308
- constructor(config) {
309
- this.config = config
310
- this.client = null
311
- this.connecting = null
312
- this.proxyPool = null
313
- // Generational token: bumped on every target change / close so a stale
314
- // in-flight connect can never hand this pool a connection to an old host.
315
- this.epoch = 0
316
- /** Auto-detected remote platform: unknown | windows | posix (per target). */
317
- this.platform = 'unknown'
318
- /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
319
- this.gitBashPath = ''
320
- /** Resolved terminal strategy: native | git-bash. */
321
- this.shellMode = 'native'
322
- /** In-flight platform detection promise (cached). */
323
- this._detecting = null
324
- /** Optional async resolver for a machine-stored (keychain) password. */
325
- this.passwordResolver = null
326
- /** Optional hook called with the live client after a successful connect. */
327
- this.onReady = null
328
- /** Optional hook called when the pool closes. */
329
- this.onCloseHook = null
330
- }
331
-
332
- resolveKeyPath() {
333
- const p = this.config.privateKeyPath
334
- if (!p) return ''
335
- if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
336
- return p
337
- }
338
-
339
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
340
- if (host !== undefined) this.config.host = String(host)
341
- if (port !== undefined && Number(port)) this.config.port = Number(port)
342
- if (username !== undefined) this.config.username = String(username)
343
- if (password !== undefined && password !== null) this.config.password = String(password)
344
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
345
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
346
- if (workspace !== undefined) this.config.workspace = String(workspace)
347
- if (useAgent !== undefined) this.config.useAgent = !!useAgent
348
- if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
349
- if (proxy !== undefined) this.config.proxy = proxy
350
- if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
351
- // the new target may be a different OS — re-detect on the next command
352
- this.platform = 'unknown'
353
- this.gitBashPath = ''
354
- this.shellMode = 'native'
355
- this._detecting = null
356
- this.close()
357
- return this
358
- }
359
-
360
- connect() {
361
- if (this.client) return Promise.resolve(this.client)
362
- if (this.connecting) return this.connecting
363
- const epoch = this.epoch
364
- const pending = this._doConnect(epoch)
365
- this.connecting = pending
366
- const clear = () => {
367
- if (this.epoch === epoch && this.connecting === pending) this.connecting = null
368
- }
369
- pending.then(clear, clear)
370
- return pending
371
- }
372
-
373
- async _doConnect(epoch) {
374
- const isCurrent = () => this.epoch === epoch
375
- const guard = createHostKeyGuard(this.config)
376
- const client = new Client()
377
- let settled = false
378
- const fail = (err) => {
379
- if (settled) return
380
- settled = true
381
- if (isCurrent() && this.client === client) this.client = null
382
- throw guard.lastError ? new Error(guard.lastError) : err
383
- }
384
-
385
- // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
386
- let sock = null
387
- const proxyCfg = this.config.proxy
388
- if (proxyCfg && proxyCfg.host) {
389
- try {
390
- this.proxyPool = new SshPool({
391
- ...this.config,
392
- host: proxyCfg.host,
393
- port: Number(proxyCfg.port) || 22,
394
- username: proxyCfg.username || this.config.username || 'root',
395
- password: proxyCfg.password || '',
396
- privateKeyPath: proxyCfg.privateKeyPath || '',
397
- passphrase: proxyCfg.passphrase || '',
398
- proxy: undefined,
399
- })
400
- const pclient = await this.proxyPool.connect()
401
- if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
402
- sock = await new Promise((res, rej) => {
403
- pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
404
- })
405
- } catch (err) {
406
- return fail(err)
407
- }
408
- }
409
-
410
- return new Promise((resolve, reject) => {
411
- const rejectOnce = (err) => {
412
- if (settled) return
413
- settled = true
414
- if (isCurrent() && this.client === client) this.client = null
415
- reject(guard.lastError ? new Error(guard.lastError) : err)
416
- }
417
- client.on('ready', () => {
418
- if (settled) return
419
- settled = true
420
- if (!isCurrent()) {
421
- try { client.end() } catch {}
422
- reject(new Error('ssh target changed during connect'))
423
- return
424
- }
425
- this.client = client
426
- resolve(client)
427
- if (this.onReady) { try { this.onReady(client) } catch {} }
428
- })
429
- client.on('error', (e) => rejectOnce(e))
430
- client.on('close', () => {
431
- if (isCurrent() && this.client === client) this.client = null
432
- rejectOnce(new Error('ssh connection closed'))
433
- })
434
-
435
- const buildOpts = async () => {
436
- const opts = {
437
- host: this.config.host,
438
- port: this.config.port,
439
- username: this.config.username,
440
- readyTimeout: this.config.connectTimeoutMs,
441
- keepaliveInterval: 15000,
442
- keepaliveCountMax: 3,
443
- hostVerifier: (key) => guard.verifier(key),
444
- }
445
- if (sock) opts.sock = sock
446
- if (this.config.useAgent) {
447
- const sockPath = process.env.SSH_AUTH_SOCK
448
- if (sockPath) opts.agent = sockPath
449
- }
450
- let password = this.config.password || ''
451
- if (!password && this.passwordResolver) {
452
- try { password = (await this.passwordResolver()) || '' } catch {}
453
- }
454
- if (password) {
455
- opts.password = password
456
- opts.tryKeyboard = true
457
- } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
458
- opts.tryKeyboard = true
459
- }
460
- if (this.config.privateKeyPath) {
461
- const keyPath = this.resolveKeyPath()
462
- if (!keyPath) {
463
- throw new Error('no credentials: set a password or a privateKeyPath to connect')
464
- }
465
- let key
466
- try {
467
- key = readFileSync(keyPath)
468
- } catch (err) {
469
- throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
470
- }
471
- opts.privateKey = key
472
- opts.passphrase = this.config.passphrase || undefined
473
- } else if (!password && !opts.agent) {
474
- throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
475
- }
476
- return opts
477
- }
478
-
479
- buildOpts().then(
480
- (opts) => {
481
- if (opts.tryKeyboard) {
482
- client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
483
- finish(prompts.map(() => this.config.password || ''))
484
- })
485
- }
486
- client.connect(opts)
487
- },
488
- (err) => rejectOnce(err),
489
- )
490
- })
491
- }
492
-
493
- /** Detect the remote platform + locate Git Bash once; cached per target. */
494
- detect() {
495
- if (this.platform !== 'unknown') return Promise.resolve()
496
- if (this._detecting) return this._detecting
497
- this._detecting = this._detect().finally(() => {
498
- this._detecting = null
499
- })
500
- return this._detecting
501
- }
502
-
503
- async _detect() {
504
- let res
505
- try {
506
- // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
507
- // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
508
- res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
509
- } catch (err) {
510
- this.platform = 'unknown'
511
- this.shellMode = 'native'
512
- return
513
- }
514
- const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
515
- if (res.code === 0 && /microsoft windows/i.test(out)) {
516
- this.platform = 'windows'
517
- await this._resolveGitBash()
518
- return
519
- }
520
- // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
521
- try {
522
- const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
523
- if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
524
- this.platform = 'windows'
525
- await this._resolveGitBash()
526
- return
527
- }
528
- } catch {}
529
- this.platform = 'posix'
530
- this.shellMode = 'native'
531
- this.gitBashPath = ''
532
- }
533
-
534
- /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
535
- async _resolveGitBash() {
536
- const cfg = String(this.config.shell || '').trim()
537
- if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
538
- if (await this._cmdExists(cfg)) {
539
- this.gitBashPath = cfg
540
- this.shellMode = 'git-bash'
541
- return
542
- }
543
- }
544
- if (cfg === 'native') {
545
- this.shellMode = 'native'
546
- this.gitBashPath = ''
547
- return
548
- }
549
- try {
550
- const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
551
- const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
552
- if (first) {
553
- this.gitBashPath = first
554
- this.shellMode = 'git-bash'
555
- return
556
- }
557
- } catch {}
558
- const candidates = [
559
- 'C:\\Program Files\\Git\\bin\\bash.exe',
560
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
561
- ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
562
- '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
563
- ]
564
- for (const c of candidates) {
565
- if (await this._cmdExists(c)) {
566
- this.gitBashPath = c
567
- this.shellMode = 'git-bash'
568
- return
569
- }
570
- }
571
- this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
572
- this.gitBashPath = ''
573
- }
574
-
575
- async _cmdExists(p) {
576
- try {
577
- const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
578
- return /Y/.test(String(r.stdout || ''))
579
- } catch {
580
- return false
581
- }
582
- }
583
-
584
- /** Run one remote command; resolves { code, signal, stdout, stderr }.
585
- * On Windows remotes with Git Bash the script is piped to `bash -s` over the
586
- * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
587
- * backslashes, newlines) survives verbatim. */
588
- exec(command, timeoutMsOrOpts) {
589
- const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
590
- return this.detect().then(() => {
591
- if (this.platform === 'windows' && this.gitBashPath) {
592
- const script = String(command)
593
- return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
594
- try { stream.end(script) } catch {}
595
- })
596
- }
597
- return this._execRaw(command, opts)
598
- })
599
- }
600
-
601
- /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
602
- * the remote process stdin (used by the Git Bash `-s` mode). */
603
- _execRaw(command, opts, stdinWriter) {
604
- const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
605
- return this.connect().then(
606
- (client) =>
607
- new Promise((resolve, reject) => {
608
- let retried = false
609
- const runOn = (c) => {
610
- const execOpts = {}
611
- if (opts && opts.pty) execOpts.pty = true
612
- if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
613
- c.exec(command, execOpts, (err, stream) => {
614
- if (err) {
615
- // Channel-open failure — or a session termination — usually
616
- // means the pooled connection died server-side (idle timeout /
617
- // network reset) while keepalive hadn't noticed. Drop it and
618
- // retry ONCE on a fresh connection.
619
- if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
620
- retried = true
621
- this.invalidate()
622
- return this.connect().then(
623
- (fresh) => runOn(fresh),
624
- (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
625
- )
626
- }
627
- return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
628
- }
629
- let stdout = ''
630
- let stderr = ''
631
- let settled = false
632
- let exitCode = null
633
- let exitSignal = null
634
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
635
- const settle = () => {
636
- if (settled) return
637
- settled = true
638
- clearTimeout(timer)
639
- resolve({
640
- code: exitCode,
641
- signal: exitSignal,
642
- stdout: truncate(stdout, this.config.maxOutputChars),
643
- stderr: truncate(stderr, this.config.maxOutputChars),
644
- })
645
- }
646
- const timer = setTimeout(() => {
647
- if (settled) return
648
- exitCode = -1
649
- exitSignal = 'TIMEOUT'
650
- // Kill the remote command (SIGTERM) rather than just dropping the
651
- // channel, so a runaway process cannot keep running and holding
652
- // the SSH connection after we've given up on its output.
653
- try {
654
- if (typeof stream.signal === 'function') stream.signal('SIGTERM')
655
- } catch {}
656
- const hardClose = setTimeout(() => {
657
- try { stream.close() } catch {}
658
- }, 800)
659
- if (typeof hardClose.unref === 'function') hardClose.unref()
660
- settle()
661
- }, timeoutMs)
662
- stream.on('close', (code, signal) => {
663
- if (settled) return
664
- exitCode = code
665
- exitSignal = signal
666
- settle()
667
- })
668
- stream.on('data', (d) => {
669
- if (stdout.length < hardCap) stdout += d
670
- })
671
- stream.stderr.on('data', (d) => {
672
- if (stderr.length < hardCap) stderr += d
673
- })
674
- stream.on('error', (e) => {
675
- if (settled) return
676
- settled = true
677
- clearTimeout(timer)
678
- reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
679
- })
680
- if (stdinWriter) {
681
- try { stdinWriter(stream) } catch {}
682
- }
683
- })
684
- }
685
- runOn(client)
686
- }),
687
- )
688
- }
689
-
690
- /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
691
- sftp() {
692
- return this.connect().then(
693
- (client) =>
694
- new Promise((resolve, reject) => {
695
- let retried = false
696
- const runOn = (c) => {
697
- c.sftp((err, sftp) => {
698
- if (err) {
699
- // Same dead-connection recovery as exec(): a channel open
700
- // failure — or a session termination (remote closed the
701
- // SFTP subchannel, e.g. transient network blip / sshd idle
702
- // drop) — means the pooled connection is stale: drop it and
703
- // retry ONCE on a fresh connection.
704
- if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
705
- retried = true
706
- this.invalidate()
707
- return this.connect().then(
708
- (fresh) => runOn(fresh),
709
- (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
710
- )
711
- }
712
- return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
713
- }
714
- const withTimeout = (fn) => (...args) =>
715
- new Promise((r2, j2) => {
716
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
717
- const done = (e, v) => {
718
- clearTimeout(timer)
719
- e ? j2(e) : r2(v)
720
- }
721
- try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
722
- })
723
- const P = (p) => toSftpPath(p)
724
- resolve({
725
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
726
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
727
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
728
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
729
- rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
730
- unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
731
- rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
732
- realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
733
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
734
- writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
735
- fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
736
- fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
737
- })
738
- })
739
- }
740
- runOn(client)
741
- }),
742
- )
743
- }
744
-
745
- /**
746
- * Drop the cached client and force a fresh connection on the next call.
747
- * Called when a channel open fails (e.g. "Channel open failure: open
748
- * failed") — the pooled SSH connection is usually dead server-side while
749
- * keepalive has not yet noticed, and reusing it keeps failing. The epoch
750
- * bump orphans any in-flight connect; the client is ended so ssh2 frees
751
- * its sockets.
752
- */
753
- invalidate() {
754
- this.epoch++
755
- const client = this.client
756
- this.client = null
757
- const pending = this.connecting
758
- this.connecting = null
759
- if (pending && typeof pending.catch === 'function') {
760
- try { pending.catch(() => {}) } catch {}
761
- }
762
- if (this.proxyPool) {
763
- try { this.proxyPool.close() } catch {}
764
- this.proxyPool = null
765
- }
766
- if (client) {
767
- try { client.end() } catch {}
768
- }
769
- }
770
-
771
- close() {
772
- this.epoch++
773
- const client = this.client
774
- this.client = null
775
- const pending = this.connecting
776
- this.connecting = null
777
- if (pending && typeof pending.catch === 'function') {
778
- try { pending.catch(() => {}) } catch {}
779
- }
780
- if (this.proxyPool) {
781
- try { this.proxyPool.close() } catch {}
782
- this.proxyPool = null
783
- }
784
- if (client) {
785
- try {
786
- client.end()
787
- } catch {}
788
- }
789
- if (this.onCloseHook) {
790
- try { this.onCloseHook() } catch {}
791
- }
792
- }
793
- }
222
+ const isHostKeyKnown = (host, port) => _isHostKeyKnown(knownHostsFile(), host, port)
794
223
 
795
224
  // ── encoding helpers ───────────────────────────────────────────────────────
796
225
 
@@ -806,7 +235,7 @@ function encodeText(s, enc) {
806
235
  // ── apply ─────────────────────────────────────────────────────────────────
807
236
 
808
237
  export async function apply(ctx, config) {
809
- const pool = new SshPool(config)
238
+ const pool = new SshPool(config, { knownHostsFile })
810
239
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
811
240
 
812
241
  migrateLegacyData()
@@ -953,7 +382,7 @@ export async function apply(ctx, config) {
953
382
  // session passes its own mirror-resolved remote path explicitly.
954
383
  workspace: '',
955
384
  })
956
- const created = new SshPool(poolConfig)
385
+ const created = new SshPool(poolConfig, { knownHostsFile })
957
386
  // Keychain-backed passwords resolve against THIS pool's machine record.
958
387
  created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password })
959
388
  machinePools.set(key, created)
@@ -1126,7 +555,7 @@ export async function apply(ctx, config) {
1126
555
  const state = loadSyncState(localDir)
1127
556
  const next = { ...state }
1128
557
  if (rels.includes('*')) {
1129
- const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
558
+ const r = await pushTree(sftp, localDir, ws, { maxFiles: 2000, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
1130
559
  Object.assign(next, r.nextState)
1131
560
  for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1, machine)
1132
561
  } else {
@@ -1182,88 +611,6 @@ export async function apply(ctx, config) {
1182
611
  return text
1183
612
  }
1184
613
 
1185
- /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
1186
- * works on any remote: POSIX / cmd.exe / PowerShell). */
1187
- const listDirStructured = async (p, sshPool = pool) => {
1188
- const target = normalizeRemotePath(p || '/')
1189
- let sftp
1190
- try {
1191
- sftp = await sshPool.sftp()
1192
- } catch (err) {
1193
- throw new Error('browse failed: ' + ((err && err.message) || err))
1194
- }
1195
- let list
1196
- try {
1197
- list = await sftp.readdir(target)
1198
- } catch (err) {
1199
- // Missing directory / deleted folder: not an internal error. Report it
1200
- // distinctly so the UI can show「目录不存在」without treating it as a
1201
- // hard failure (500) that collapses the whole tree.
1202
- const msg = String((err && err.message) || err)
1203
- if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
1204
- return { path: target, items: [], missing: true }
1205
- }
1206
- throw new Error('browse failed: ' + msg)
1207
- }
1208
- const items = []
1209
- const symIdx = []
1210
- for (const e of list) {
1211
- const name = String(e.filename)
1212
- if (name === '.' || name === '..' || !name) continue
1213
- const a = e.attrs || {}
1214
- let type
1215
- if (a.isSymbolicLink && a.isSymbolicLink()) {
1216
- type = 'symlink'
1217
- symIdx.push(items.length)
1218
- } else if (a.isDirectory && a.isDirectory()) {
1219
- type = 'dir'
1220
- } else {
1221
- type = 'file'
1222
- }
1223
- items.push({
1224
- type,
1225
- name,
1226
- size: typeof a.size === 'number' ? a.size : 0,
1227
- mtime: typeof a.mtime === 'number' ? a.mtime : 0,
1228
- mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
1229
- })
1230
- }
1231
- // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
1232
- if (symIdx.length) {
1233
- await Promise.all(symIdx.map(async (i) => {
1234
- const full = joinRemotePath(target, items[i].name)
1235
- try {
1236
- const st = await sftp.lstat(full)
1237
- items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
1238
- } catch { /* degrade to file */ }
1239
- }))
1240
- }
1241
- return { path: target, items }
1242
- }
1243
-
1244
- /** Windows drive letters as display-form dir entries (the "This PC" root view).
1245
- * `cmd /c fsutil fsinfo drives` works under cmd/PowerShell/Git Bash and prints
1246
- * e.g. "Drives: C:\\ D:\\ E:\\"; falls back to listing Git-Bash mount points
1247
- * (/c /d …). Returns [] on POSIX hosts or when enumeration fails. */
1248
- const listRemoteDrives = async () => {
1249
- let letters = []
1250
- try {
1251
- const res = await pool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1252
- letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
1253
- } catch {}
1254
- if (!letters.length) {
1255
- try {
1256
- const res = await pool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1257
- letters = String(res.stdout || '')
1258
- .split(/\s+/)
1259
- .map((s) => s.replace(/^\/+|\/+$/g, ''))
1260
- .filter((s) => /^[a-zA-Z]$/.test(s))
1261
- .map((s) => s.toUpperCase())
1262
- } catch {}
1263
- }
1264
- return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
1265
- }
1266
-
1267
614
  const isRemoteDir = async (p, sshPool = pool) => {
1268
615
  const target = normalizeRemotePath(p)
1269
616
  try {
@@ -1275,30 +622,6 @@ export async function apply(ctx, config) {
1275
622
  }
1276
623
  }
1277
624
 
1278
- /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
1279
- const removeRemoteTree = async (sftp, p, maxFiles = 2000) => {
1280
- let removed = 0
1281
- const walk = async (dir) => {
1282
- if (removed >= maxFiles) return
1283
- let entries = []
1284
- try { entries = (await sftp.readdir(dir)) || [] } catch { return }
1285
- for (const e of entries) {
1286
- if (removed >= maxFiles) return
1287
- const name = String(e.filename)
1288
- if (name === '.' || name === '..') continue
1289
- const fp = joinRemotePath(dir, name)
1290
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
1291
- if (isDir) await walk(fp)
1292
- else {
1293
- try { await sftp.unlink(fp); removed++ } catch {}
1294
- }
1295
- }
1296
- try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
1297
- }
1298
- await walk(p)
1299
- return removed
1300
- }
1301
-
1302
625
  // ── remote workspace state ────────────────────────────────────────────────
1303
626
  const wsPath = () => (config.workspace || '').trim()
1304
627
  /** Map a LOCAL path to the remote path its mirror represents ('' when the
@@ -1323,22 +646,66 @@ export async function apply(ctx, config) {
1323
646
  * with several live sessions the "first" one is arbitrary and may belong to
1324
647
  * another machine. Tool calls must never route through this — they resolve
1325
648
  * their own session through `bindingFor()`, which is authoritative. */
1326
- const sessionRemotePath = (sessionId) => {
1327
- let cwd = ''
649
+ const sessionsSvc = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null)
650
+ const sessionCwd = (sessionId) => {
651
+ const sessions = sessionsSvc()
652
+ if (sessionId) return lookupSessionCwd(sessionId, { sessions, dshHome: dshBase() })
1328
653
  try {
1329
- const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
1330
- if (sessions && typeof sessions.get === 'function' && sessionId) {
1331
- const s = sessions.get(sessionId)
1332
- if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
1333
- } else if (sessions && typeof sessions.list === 'function') {
654
+ if (sessions && typeof sessions.list === 'function') {
1334
655
  const s = sessions.list()[0]
1335
- if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
656
+ if (s && s.header && s.header.cwd) return String(s.header.cwd)
1336
657
  }
1337
658
  } catch { /* sessions service unavailable */ }
659
+ return ''
660
+ }
661
+ const sessionRemotePath = (sessionId) => {
662
+ const cwd = sessionCwd(sessionId)
1338
663
  if (!cwd) return ''
1339
664
  return resolveMirrorForLocal(cwd).remotePath
1340
665
  }
1341
666
 
667
+ /**
668
+ * HTTP binding for sidebar file routes. A sessionId/local hint is authoritative:
669
+ * a local (non-mirror) session is refused rather than falling back to the
670
+ * active machine. Requests with no hint keep the picker/settings active pool.
671
+ */
672
+ const resolveRequestBinding = async (req, body = {}) => {
673
+ const hint = requestSessionHint(req, body)
674
+ let cwd = hint.local
675
+ if (!cwd && hint.sessionId) cwd = lookupSessionCwd(hint.sessionId, { sessions: sessionsSvc(), dshHome: dshBase() })
676
+ if (cwd) {
677
+ const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
678
+ if (remotePath && machine) {
679
+ return {
680
+ pool: poolForMachine(machine),
681
+ ws: remotePath,
682
+ host: machine.host,
683
+ username: machine.username,
684
+ port: machine.port,
685
+ mirrorDir,
686
+ bound: true,
687
+ local: false,
688
+ }
689
+ }
690
+ const err = new Error(
691
+ 'this session is LOCAL — its workspace is not a remote mirror, so there is no remote host to act on. '
692
+ + 'Refusing rather than falling back to the active machine.',
693
+ )
694
+ err.httpStatus = 403
695
+ throw err
696
+ }
697
+ return {
698
+ pool,
699
+ ws: wsPath(),
700
+ host: config.host,
701
+ username: config.username,
702
+ port: config.port,
703
+ mirrorDir: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : null,
704
+ bound: false,
705
+ local: false,
706
+ }
707
+ }
708
+
1342
709
  // ── session-bound remote binding (the authoritative tool path) ─────────────
1343
710
 
1344
711
  /**
@@ -1634,8 +1001,8 @@ export async function apply(ctx, config) {
1634
1001
  description:
1635
1002
  'Download the current remote workspace into its local mirror directory over SFTP (bounded, three-way conflict-aware). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1636
1003
  parameters: {
1637
- depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
1638
- maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
1004
+ depth: { type: 'integer', description: 'Max directory depth to mirror (default 8)' },
1005
+ maxFiles: { type: 'integer', description: 'Max files to download (default 2000)' },
1639
1006
  dryRun: { type: 'boolean', description: 'Compute the plan without downloading (default false)' },
1640
1007
  force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1641
1008
  async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
@@ -1646,8 +1013,8 @@ export async function apply(ctx, config) {
1646
1013
  const p = b.ws
1647
1014
  const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
1648
1015
  mkdirSync(local, { recursive: true })
1649
- const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1650
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1016
+ const depth = Math.min(Math.max(Number(args.depth) || 8, 1), 16)
1017
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000)
1651
1018
  const isIgnored = ignoreMatcher()
1652
1019
  const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1653
1020
  const runSync = async () => {
@@ -1660,7 +1027,8 @@ export async function apply(ctx, config) {
1660
1027
  const state = loadSyncState(local)
1661
1028
  const { stats, nextState } = await syncTree(sftp, p, local, { ...body, state, maxFileBytes: config.maxFileBytes })
1662
1029
  if (!args.dryRun) saveSyncState(local, nextState)
1663
- let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}${stats.files >= maxFiles ? ' (hit download cap)' : ''}.`
1030
+ let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}.`
1031
+ if (stats.truncated || stats.files >= maxFiles) text += ` TRUNCATED (hit depth=${depth} or maxFiles=${maxFiles} cap; raise maxFiles/depth and re-run).`
1664
1032
  if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1665
1033
  if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1666
1034
  if (stats.staleRemote) text += ` ${stats.staleRemote} remote entries gone (kept locally; use rw_push to mirror deletions).`
@@ -1685,7 +1053,7 @@ export async function apply(ctx, config) {
1685
1053
  description:
1686
1054
  'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded, three-way conflict-aware). Use after editing files in the local mirror so the remote reflects your changes. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1687
1055
  parameters: {
1688
- maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
1056
+ maxFiles: { type: 'integer', description: 'Max files to upload (default 2000)' },
1689
1057
  dryRun: { type: 'boolean', description: 'Compute the plan without uploading (default false)' },
1690
1058
  force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1691
1059
  async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
@@ -1696,7 +1064,7 @@ export async function apply(ctx, config) {
1696
1064
  const p = b.ws
1697
1065
  const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
1698
1066
  if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1699
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1067
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000)
1700
1068
  const isIgnored = ignoreMatcher()
1701
1069
  const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1702
1070
  const runPush = async () => {
@@ -1710,6 +1078,7 @@ export async function apply(ctx, config) {
1710
1078
  const { stats, nextState } = await pushTree(sftp, local, p, { ...body, state, maxFileBytes: config.maxFileBytes })
1711
1079
  if (!args.dryRun) saveSyncState(local, nextState)
1712
1080
  let text = `${args.dryRun ? 'WOULD upload' : 'Uploaded'} ${stats.files} file(s) from ${local} → ${p}.`
1081
+ if (stats.truncated || stats.files >= maxFiles) text += ` TRUNCATED (hit maxFiles=${maxFiles} cap; raise maxFiles and re-run).`
1713
1082
  if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1714
1083
  if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1715
1084
  if (stats.staleLocal) text += ` ${stats.staleLocal} local entries gone remotely (kept remotely; use rw_remove to mirror deletions).`
@@ -2058,7 +1427,7 @@ export async function apply(ctx, config) {
2058
1427
  defineTool({
2059
1428
  name: 'rw_search',
2060
1429
  description:
2061
- 'Search remote files for a pattern (recursive SFTP walk — works on ANY remote including Windows, honors ignore rules). Returns matching file:line rows; output is capped.',
1430
+ 'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped.',
2062
1431
  parameters: {
2063
1432
  pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
2064
1433
  path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
@@ -2081,16 +1450,18 @@ export async function apply(ctx, config) {
2081
1450
  } catch (err) {
2082
1451
  throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
2083
1452
  }
2084
- const sftp = await b.pool.sftp()
2085
1453
  const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
2086
1454
  const matcher = ignoreMatcher()
2087
- const { matches, scanned, truncated } = await searchTree(sftp, dir, {
1455
+ const { matches, scanned, truncated } = await searchRemote(b.pool, dir, {
1456
+ pattern,
2088
1457
  regex,
2089
1458
  glob: args.glob,
1459
+ ignoreCase: args.ignoreCase !== false,
2090
1460
  contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
2091
1461
  maxMatches,
2092
1462
  maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
2093
1463
  isIgnored: (name, isDir) => matcher(name, isDir),
1464
+ timeoutMs: config.commandTimeoutMs,
2094
1465
  })
2095
1466
  if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` }
2096
1467
  let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
@@ -2282,7 +1653,7 @@ export async function apply(ctx, config) {
2282
1653
  name: 'remote-forget-key',
2283
1654
  description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
2284
1655
  handler: () => {
2285
- createHostKeyGuard(config).forgetHost()
1656
+ createHostKeyGuard(config, knownHostsFile()).forgetHost()
2286
1657
  return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
2287
1658
  },
2288
1659
  })
@@ -2426,7 +1797,25 @@ export async function apply(ctx, config) {
2426
1797
  const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
2427
1798
  // Per-session remote context (issue #13): when a sessionId is given,
2428
1799
  // sessionMode reflects THAT session's cwd, not the machine default.
2429
- return sendJson(res, 200, sessionId ? { ...status(), sessionMode: sessionRemotePath(sessionId) ? 'remote' : 'local', sessionRemotePath: sessionRemotePath(sessionId) } : status())
1800
+ if (!sessionId) return sendJson(res, 200, status())
1801
+ const cwd = sessionCwd(sessionId)
1802
+ const resolved = cwd ? resolveMirrorForLocal(cwd) : { remotePath: '', machine: null }
1803
+ const extra = {
1804
+ sessionMode: resolved.remotePath ? 'remote' : 'local',
1805
+ sessionRemotePath: resolved.remotePath || '',
1806
+ sessionBound: !!(resolved.machine && resolved.remotePath),
1807
+ }
1808
+ if (resolved.machine && resolved.remotePath) {
1809
+ const p = poolForMachine(resolved.machine)
1810
+ extra.host = resolved.machine.host
1811
+ extra.username = resolved.machine.username
1812
+ extra.port = resolved.machine.port
1813
+ extra.workspace = toDisplayPath(resolved.remotePath, p.platform)
1814
+ // Bound sessions are allowed to list/read even if this process has
1815
+ // not yet opened a socket — /ls connects on demand.
1816
+ extra.connected = true
1817
+ }
1818
+ return sendJson(res, 200, { ...status(), ...extra })
2430
1819
  }
2431
1820
  sendJson(res, 405, { error: 'method not allowed' })
2432
1821
  },
@@ -2557,219 +1946,10 @@ export async function apply(ctx, config) {
2557
1946
  }
2558
1947
  },
2559
1948
  },
2560
- {
2561
- kind: 'exact',
2562
- path: '/dsh-remote/ls',
2563
- handler: async (req, res) => {
2564
- try {
2565
- await pool.detect()
2566
- const q = new URL(req.url, 'http://localhost').searchParams
2567
- const raw = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
2568
- const canon = normalizeRemotePath(raw)
2569
- const win = pool.platform === 'windows'
2570
- // Windows root → "This PC" drive view (C:\ D:\ E:\), not the Git-Bash
2571
- // MSYS root. When the platform probe was inconclusive we still try — a
2572
- // real Windows box answers fsutil, a POSIX box yields no letters and
2573
- // falls through to the normal root listing.
2574
- if (canon === '/' || raw === '' || raw === '/') {
2575
- if (win || pool.platform !== 'posix') {
2576
- const drives = await listRemoteDrives()
2577
- if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives })
2578
- }
2579
- if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [] })
2580
- }
2581
- const out = await listDirStructured(canon)
2582
- const items = out.items.map((it) => ({
2583
- ...it,
2584
- // full display-form path so the client never joins paths itself
2585
- path: toDisplayPath(joinRemotePath(canon, it.name), pool.platform),
2586
- }))
2587
- return sendJson(res, 200, { path: toDisplayPath(out.path, pool.platform), platform: win ? 'windows' : 'posix', items })
2588
- } catch (err) {
2589
- return sendJson(res, 500, { error: String((err && err.message) || err) })
2590
- }
2591
- },
2592
- },
2593
- {
2594
- // Read a remote file over SFTP (live). Text returns as content (CRLF→LF,
2595
- // encoding-aware), binary is reported with size+head (base64). A stat
2596
- // happens first; oversized files are previewed from a streamed head chunk
2597
- // instead of being fully buffered.
2598
- kind: 'exact',
2599
- path: '/dsh-remote/read',
2600
- handler: async (req, res) => {
2601
- if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2602
- try {
2603
- const q = new URL(req.url, 'http://localhost').searchParams
2604
- let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
2605
- let encoding = ''
2606
- let maxBytes = 256 * 1024
2607
- if (req.method === 'POST') {
2608
- const body = JSON.parse((await readBody(req)) || '{}')
2609
- if (!p) p = String(body.path || '')
2610
- if (body.encoding) encoding = String(body.encoding)
2611
- if (body.maxBytes) maxBytes = Number(body.maxBytes)
2612
- }
2613
- if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
2614
- if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
2615
- const target = normalizeRemotePath(p)
2616
- maxBytes = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
2617
- let sftp
2618
- try {
2619
- sftp = await pool.sftp()
2620
- } catch (err) {
2621
- return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
2622
- }
2623
- let st
2624
- try { st = await sftp.stat(target) } catch (err) {
2625
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
2626
- }
2627
- if (st.size > maxBytes) {
2628
- // Head preview for oversized files: fastGet to a temp file, read the
2629
- // first maxBytes, delete the temp. Never buffers the whole file.
2630
- const tmp = path.join(dshBase(), '.dsh-remote-preview-' + process.pid)
2631
- try {
2632
- await sftp.fastGet(target, tmp)
2633
- const { open } = await import('node:fs/promises')
2634
- const fd = await open(tmp, 'r')
2635
- const head = Buffer.alloc(Math.min(maxBytes, st.size))
2636
- await fd.read(head, 0, head.length, 0)
2637
- await fd.close()
2638
- try { unlinkSync(tmp) } catch {}
2639
- const content = decodeBuf(head, encoding || config.encoding).replace(/\r\n/g, '\n')
2640
- return sendJson(res, 200, { ok: true, binary: false, content, truncated: true, size: st.size })
2641
- } catch (err) {
2642
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
2643
- }
2644
- }
2645
- let buf
2646
- try {
2647
- buf = await sftp.readFile(target)
2648
- } catch (err) {
2649
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
2650
- }
2651
- const headN = Math.min(buf.length, 8192)
2652
- let binary = false
2653
- for (let i = 0; i < headN; i++) {
2654
- if (buf[i] === 0) { binary = true; break }
2655
- }
2656
- if (binary) {
2657
- return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
2658
- }
2659
- let content = decodeBuf(buf, encoding || config.encoding).replace(/\r\n/g, '\n')
2660
- const truncated = content.length > maxBytes
2661
- if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
2662
- return sendJson(res, 200, { ok: true, binary: false, content, truncated })
2663
- } catch (err) {
2664
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2665
- }
2666
- },
2667
- },
2668
- {
2669
- // Write a remote file (sidebar editor save). `expectedMtime` is an
2670
- // optimistic lock: a 409 is returned when the remote changed meanwhile.
2671
- kind: 'exact',
2672
- path: '/dsh-remote/write',
2673
- handler: async (req, res) => {
2674
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2675
- try {
2676
- const body = JSON.parse((await readBody(req)) || '{}')
2677
- const p = normalizeRemotePath(String(body.path || ''))
2678
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
2679
- const sftp = await pool.sftp()
2680
- let st = null
2681
- try { st = await sftp.stat(p) } catch { /* new file */ }
2682
- if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
2683
- return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
2684
- }
2685
- const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
2686
- if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
2687
- await sftp.writeFile(p, buf)
2688
- audit('write', `write ${p}`, 0)
2689
- const st2 = await sftp.stat(p).catch(() => null)
2690
- return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
2691
- } catch (err) {
2692
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2693
- }
2694
- },
2695
- },
2696
- {
2697
- // Generic remote fs ops used by the sidebar context menu + picker:
2698
- // mkdir / rename / remove (recursive) / write / append.
2699
- kind: 'exact',
2700
- path: '/dsh-remote/fs',
2701
- handler: async (req, res) => {
2702
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2703
- try {
2704
- const body = JSON.parse((await readBody(req)) || '{}')
2705
- const op = String(body.op || '')
2706
- const sftp = await pool.sftp()
2707
- if (op === 'mkdir') {
2708
- const p = normalizeRemotePath(String(body.path || ''))
2709
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
2710
- await mkdirRemoteDirs(sftp, p)
2711
- audit('mkdir', `mkdir ${p}`, 0)
2712
- return sendJson(res, 200, { ok: true })
2713
- }
2714
- if (op === 'rename') {
2715
- const p = normalizeRemotePath(String(body.path || ''))
2716
- const d = normalizeRemotePath(String(body.dest || ''))
2717
- if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
2718
- await sftp.rename(p, d)
2719
- audit('move', `move ${p} → ${d}`, 0)
2720
- return sendJson(res, 200, { ok: true })
2721
- }
2722
- if (op === 'remove') {
2723
- const p = normalizeRemotePath(String(body.path || ''))
2724
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
2725
- const st = await sftp.stat(p).catch(() => null)
2726
- if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
2727
- if (st.isDirectory && st.isDirectory()) {
2728
- await removeRemoteTree(sftp, p)
2729
- } else {
2730
- await sftp.unlink(p)
2731
- }
2732
- audit('remove', `remove ${p}`, 0)
2733
- return sendJson(res, 200, { ok: true })
2734
- }
2735
- if (op === 'write' || op === 'append') {
2736
- const p = normalizeRemotePath(String(body.path || ''))
2737
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
2738
- let content = ''
2739
- if (op === 'append') {
2740
- try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
2741
- }
2742
- const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
2743
- if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
2744
- await sftp.writeFile(p, buf)
2745
- audit(op, `${op} ${p}`, 0)
2746
- return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
2747
- }
2748
- if (op === 'download') {
2749
- // Download a remote file into the current workspace mirror.
2750
- const p = normalizeRemotePath(String(body.path || ''))
2751
- const ws = wsPath()
2752
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
2753
- if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
2754
- const st = await sftp.stat(p).catch(() => null)
2755
- if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
2756
- if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
2757
- return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
2758
- }
2759
- const base = mirrorDirFor(ws, config.host, config.username, config.port)
2760
- const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
2761
- const local = path.join(base, rel)
2762
- mkdirSync(path.dirname(local), { recursive: true })
2763
- await sftp.fastGet(p, local)
2764
- audit('download', `download ${p}`, 0)
2765
- return sendJson(res, 200, { ok: true, bytes: st.size, local })
2766
- }
2767
- return sendJson(res, 400, { ok: false, error: 'unknown op' })
2768
- } catch (err) {
2769
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2770
- }
2771
- },
2772
- },
1949
+ ...createFsRoutes({
1950
+ sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
1951
+ }),
1952
+
2773
1953
  {
2774
1954
  kind: 'exact',
2775
1955
  path: '/dsh-remote/workspace',
@@ -3046,7 +2226,7 @@ export async function apply(ctx, config) {
3046
2226
  } : undefined,
3047
2227
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
3048
2228
  commandTimeoutMs: 10000,
3049
- })
2229
+ }, { knownHostsFile })
3050
2230
  const started = Date.now()
3051
2231
  await probe.connect()
3052
2232
  await probe.exec('true', { timeoutMs: 10000 })
@@ -3091,7 +2271,7 @@ export async function apply(ctx, config) {
3091
2271
  path: '/dsh-remote/forget-key',
3092
2272
  handler: async (req, res) => {
3093
2273
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
3094
- createHostKeyGuard(config).forgetHost()
2274
+ createHostKeyGuard(config, knownHostsFile()).forgetHost()
3095
2275
  return sendJson(res, 200, { ok: true, ...status() })
3096
2276
  },
3097
2277
  },