dsh-remote 0.8.18 → 0.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -15
- package/README.zh.md +18 -12
- package/lib/binding.js +69 -0
- package/lib/client.js +382 -137
- package/lib/hostkey.js +60 -0
- package/lib/index.js +104 -924
- package/lib/pool.js +511 -0
- package/lib/remote-fs.js +143 -0
- package/lib/routes-fs.js +221 -0
- package/lib/search.js +59 -1
- package/lib/sync.js +15 -10
- package/package.json +2 -2
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
|
|
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
|
-
|
|
31
|
+
toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
|
|
34
32
|
} from './paths.js'
|
|
35
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
1327
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
1638
|
-
maxFiles: { type: 'integer', description: 'Max files to download (default
|
|
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) ||
|
|
1650
|
-
const maxFiles = Math.min(Math.max(Number(args.maxFiles) ||
|
|
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}
|
|
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
|
|
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) ||
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
},
|