dsh-remote 0.7.0 → 0.7.2

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
@@ -1,1737 +1,1737 @@
1
- // dsh-remote — remote-work assistant for DeepSeek Harness.
2
- //
3
- // Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
4
- // • one persistent SSH/SFTP pool per configured remote (password OR private key),
5
- // • a "current remote workspace" — a remote directory the agent treats as the
6
- // active project root (user@host:/path) — injected into every system prompt,
7
- // • model tools `rw_info` / `rw_connect` / `rw_pick_workspace` /
8
- // `rw_list_dir` / `rw_read_file` / `rw_exec`,
9
- // • JSON endpoints the client settings page uses to connect → browse → select the
10
- // remote workspace over the harness `webServer`.
11
- //
12
- // The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
13
- // proven by dsh-remote-debug, extended with password auth and a mutable workspace:
14
- // `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
15
- // presented as such to the model and UI, not a replacement of the local one.
16
- //
17
- // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
18
- import z from '@deepseek-ai/schemastery'
19
- import { defineTool } from '@deepseek-ai/dsh-tools'
20
- import ssh2 from 'ssh2'
21
- import { createHash } from 'node:crypto'
22
- import { execFile } from 'node:child_process'
23
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
24
- import { homedir } from 'node:os'
25
- import path from 'node:path'
26
-
27
- const { Client } = ssh2
28
-
29
- export const name = 'dsh-remote'
30
-
31
- // tools + systemPrompt + webServer are required. webServer is INJECTED (not just
32
- // lazily read) so this plugin activates only after the web server is up — otherwise
33
- // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
34
- // (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
35
- export const inject = ['tools', 'systemPrompt', 'webServer']
36
-
37
- export const Config = z.object({
38
- /** Remote SSH host (empty → the plugin starts disconnected). */
39
- host: z.string().default(''),
40
- /** Remote SSH port (22 unless the machine uses a custom port). */
41
- port: z.number().step(1).min(1).max(65535).default(22),
42
- /** SSH login user. */
43
- username: z.string().default(''),
44
- /** Password login (only when the remote has no key. Override the fallback below). */
45
- password: z.string().default(''),
46
- /** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
47
- privateKeyPath: z.string().default(''),
48
- /** Key passphrase when the key is encrypted. */
49
- passphrase: z.string().default(''),
50
- /** Initial remote workspace path (absolute dir the agent should treat as root). */
51
- workspace: z.string().default(''),
52
- /** Per-command timeout. */
53
- commandTimeoutMs: z.number().step(1).min(1000).default(20000),
54
- /** SSH connection establishment timeout. */
55
- connectTimeoutMs: z.number().step(1).min(1000).default(15000),
56
- /** Hard ceiling on collected remote output per call. */
57
- maxOutputChars: z.number().step(1).min(1024).default(200000),
58
- /** Skip mirroring files larger than this many bytes (0 = no cap). */
59
- maxFileBytes: z.number().step(1).min(0).default(52428800),
60
- /** Host-key policy: `accept-new` (default) records a host's key on first
61
- * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
62
- * accept-new); `verify` also rejects hosts never seen before; `off` skips
63
- * verification entirely (MITM-unsafe, not recommended). */
64
- hostKeyMode: z.string().default('accept-new'),
65
- })
66
-
67
- // ── shell / path helpers (proven in the read tooling) ───────────────────────
68
-
69
- /** Single-quote one shell argument verbatim. */
70
- function shq(s) {
71
- return "'" + String(s).replace(/'/g, "'\\''") + "'"
72
- }
73
-
74
- /**
75
- * Normalize a remote path into a clean absolute path, supporting BOTH POSIX
76
- * (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
77
- *
78
- * - POSIX paths keep `/` separators and a leading `/`.
79
- * - Windows drive paths keep their `X:` prefix and are returned with `\`
80
- * separators (the native form on a Windows remote); a leading `/` is NOT
81
- * added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
82
- * - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
83
- * - `.` / `..` segments are collapsed in both separator styles.
84
- */
85
- function normalizeRemotePath(p) {
86
- const s = String(p)
87
- // Windows UNC: \\server\share\…
88
- const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
89
- if (unc) {
90
- const [prefix, rest] = [unc[1], unc[2]]
91
- const parts = []
92
- for (const seg of rest.split(/[\\/]+/)) {
93
- if (seg === '' || seg === '.') continue
94
- if (seg === '..') { parts.pop(); continue }
95
- parts.push(seg)
96
- }
97
- return parts.length ? prefix + '\\' + parts.join('\\') : prefix
98
- }
99
- // Windows drive: X:\… or X:/…
100
- const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
101
- if (drive) {
102
- const [prefix, rest] = [drive[1], drive[2]]
103
- const parts = []
104
- for (const seg of rest.split(/[\\/]+/)) {
105
- if (seg === '' || seg === '.') continue
106
- if (seg === '..') { parts.pop(); continue }
107
- parts.push(seg)
108
- }
109
- return prefix + '\\' + parts.join('\\')
110
- }
111
- // POSIX: /a/b/c
112
- const parts = []
113
- for (const seg of s.split('/')) {
114
- if (seg === '' || seg === '.') continue
115
- if (seg === '..') {
116
- parts.pop()
117
- continue
118
- }
119
- parts.push(seg)
120
- }
121
- return '/' + parts.join('/')
122
- }
123
-
124
- /** Join a remote dir + entry name, honoring the dir's own separator style. */
125
- function joinRemotePath(dir, name) {
126
- const d = String(dir)
127
- if (!d) return String(name)
128
- if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
129
- return d + (d.includes('\\') ? '\\' : '/') + String(name)
130
- }
131
-
132
- /** Create every level of a remote dir via SFTP (mkdir -p semantics), working
133
- * for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
134
- * existing or permission-denied level is skipped. */
135
- async function mkdirRemoteDirs(sftp, dir) {
136
- const parent = remoteDirname(dir)
137
- const isWin = parent.includes('\\')
138
- // Split off a Windows drive prefix (D:) so it is not treated as a segment.
139
- const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
140
- const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
141
- let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
142
- // Drive root (D:\) itself, best effort (usually exists).
143
- if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
144
- for (const s of segs) {
145
- if (cur) {
146
- if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
147
- cur += s
148
- } else {
149
- cur = (isWin ? s + ':' : '/' + s)
150
- }
151
- try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
152
- }
153
- }
154
-
155
- /** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
156
- function remoteDirname(p) {
157
- const norm = normalizeRemotePath(p)
158
- // Windows drive/UNC root: D:\ or \\server\share
159
- if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
160
- // Ensure a drive root ends with a backslash: D: → D:\
161
- const m = norm.match(/^([a-zA-Z]:)$/)
162
- return m ? m[1] + '\\' : norm
163
- }
164
- const sep = norm.includes('\\') ? '\\' : '/'
165
- const idx = norm.lastIndexOf(sep)
166
- if (idx <= 0) {
167
- // Windows drive with a child (D:\Code) → D:\
168
- const m = norm.match(/^([a-zA-Z]:)\\/)
169
- return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
170
- }
171
- // A parent that is a Windows drive root also needs the trailing backslash.
172
- const parent = norm.slice(0, idx)
173
- if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
174
- return parent
175
- }
176
-
177
- function truncate(s, max) {
178
- if (s.length <= max) return s
179
- return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
180
- }
181
-
182
- // ── local mirror of an OS remote workspace ────────────────────────────────
183
- // Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
184
- // so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
185
- // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
186
-
187
- const remotePathBase = (p) => {
188
- const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
189
- const base = norm.split(/[\\/]/).pop()
190
- // Windows drive root (D:\) has no meaningful basename
191
- if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
192
- return base
193
- }
194
-
195
- /**
196
- * Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
197
- * path format the SFTP server understands.
198
- *
199
- * Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
200
- * written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
201
- * UNC paths are kept as-is (the server resolves `\\server\share` itself).
202
- */
203
- function toSftpPath(p) {
204
- const norm = normalizeRemotePath(p)
205
- const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
206
- if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
207
- return norm
208
- }
209
-
210
- /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
211
- * own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
212
- * data under the same root the harness uses means uninstalling / upgrading the
213
- * app no longer leaves state behind in the home directory. */
214
- function dshBase() {
215
- const env = process.env.DSH_HOME
216
- if (env && String(env).trim()) return path.resolve(String(env).trim())
217
- return path.join(homedir(), '.dsh')
218
- }
219
-
220
- /** Root holding every remote host's mirrors + the machine registry. */
221
- function remoteWorkspacesRoot() {
222
- return path.join(dshBase(), 'remote-workspaces')
223
- }
224
-
225
- /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
226
- function copyDirSync(from, to) {
227
- mkdirSync(to, { recursive: true })
228
- for (const e of readdirSync(from, { withFileTypes: true })) {
229
- const s = path.join(from, e.name)
230
- const d = path.join(to, e.name)
231
- if (e.isDirectory()) copyDirSync(s, d)
232
- else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
233
- }
234
- }
235
-
236
- /** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
237
- * regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
238
- * desktop install keeps its existing machines and mirrors. Idempotent; a
239
- * rename across devices falls back to a copy. */
240
- function migrateLegacyData() {
241
- const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
242
- const target = remoteWorkspacesRoot()
243
- if (legacy === target || !existsSync(legacy) || existsSync(target)) return
244
- try {
245
- mkdirSync(path.dirname(target), { recursive: true })
246
- try {
247
- renameSync(legacy, target)
248
- } catch (err) {
249
- if (err.code !== 'EXDEV') throw err
250
- copyDirSync(legacy, target)
251
- }
252
- } catch {
253
- // Migration is best-effort: a fresh registry is created on next save.
254
- }
255
- }
256
-
257
- /** 32-bit string hash rendered in base36 (short, collision-safe enough). */
258
- function shortHash(s) {
259
- let h = 0
260
- for (let i = 0; i < s.length; i++) {
261
- h = ((h << 5) - h + s.charCodeAt(i)) | 0
262
- }
263
- return (h >>> 0).toString(36)
264
- }
265
-
266
- /** Stable local root for one remote host's mirrors */
267
- function mirrorRootFor(host, user, port) {
268
- const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
269
- return path.join(remoteWorkspacesRoot(), tag)
270
- }
271
-
272
- /** Local mirror dir for a specific remote path (idempotent → returns same dir).
273
- * Named after the remote directory's basename so the harness workspace label
274
- * reads cleanly (e.g. .../project). When the plain basename dir is already
275
- * taken by a DIFFERENT remote path on the same host, a short path-hash suffix
276
- * is appended so mirrors never collide. */
277
- function mirrorDirFor(remotePath, host, user, port) {
278
- const base = remotePathBase(remotePath)
279
- const root = mirrorRootFor(host, user, port)
280
- const plain = path.join(root, base)
281
- const norm = normalizeRemotePath(remotePath)
282
- // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
283
- try {
284
- const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
285
- if (meta.remotePath === norm) return plain
286
- } catch {
287
- /* no mirror yet → fall through */
288
- }
289
- // Plain dir is free → first mirror for this basename keeps the clean name.
290
- // It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
291
- if (!existsSync(plain)) return plain
292
- return path.join(root, base + '-' + shortHash(norm))
293
- }
294
-
295
- /** Create the local mirror dir + a meta file describing its remote origin. */
296
- function ensureMirror(remotePath, host, user, port) {
297
- const dir = mirrorDirFor(remotePath, host, user, port)
298
- mkdirSync(dir, { recursive: true })
299
- writeFileSync(
300
- path.join(dir, '.dsh-remote-meta.json'),
301
- JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
302
- )
303
- return dir
304
- }
305
-
306
- /** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
307
- async function mapLimit(items, limit, fn) {
308
- const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
309
- let next = 0
310
- const worker = async () => {
311
- while (next < items.length) {
312
- const i = next++
313
- try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
314
- }
315
- }
316
- const workers = []
317
- for (let w = 0; w < limitN; w++) workers.push(worker())
318
- await Promise.all(workers)
319
- }
320
-
321
- /** Recursively sync remote → local mirror. Bounded by depth/files/size;
322
- * skips files whose size+mtime already match locally. Returns counters. */
323
- async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
324
- const entries = await sftp.readdir(remoteDir).then(
325
- (list) => list,
326
- () => [],
327
- )
328
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
329
-
330
- // Directories first (sequential, depth-bounded) so every file has a home.
331
- for (const e of entries) {
332
- const name = String(e.filename)
333
- if (name === '.' || name === '..') continue
334
- const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
335
- if (!isDir) continue
336
- if (maxDepth <= 0 || stats.files >= maxFiles) continue
337
- const rp = joinRemotePath(remoteDir, name)
338
- const lp = path.join(localDir, name)
339
- mkdirSync(lp, { recursive: true })
340
- const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
341
- stats.dirs += sub.dirs + 1
342
- stats.files += sub.files
343
- stats.skippedUnchanged += sub.skippedUnchanged
344
- stats.skippedLarge += sub.skippedLarge
345
- stats.touched.push(...sub.touched)
346
- if (stats.files >= maxFiles) break
347
- }
348
- if (stats.files >= maxFiles) return stats
349
-
350
- // Files in one bounded-parallel sweep per directory level.
351
- const fileEntries = entries.filter(
352
- (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
353
- String(e.filename) !== '.' && String(e.filename) !== '..',
354
- )
355
- await mapLimit(fileEntries, 4, async (e) => {
356
- if (stats.files >= maxFiles) return
357
- const name = String(e.filename)
358
- const rp = joinRemotePath(remoteDir, name)
359
- const lp = path.join(localDir, name)
360
- try {
361
- const st = await sftp.stat(rp)
362
- if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
363
- const lpStat = existsSync(lp) ? statSync(lp) : null
364
- const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
365
- if (unchanged) { stats.skippedUnchanged++; return }
366
- const buf = await sftp.readFile(rp)
367
- writeFileSync(lp, buf)
368
- try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
369
- stats.files++
370
- stats.touched.push(rp)
371
- } catch {
372
- /* skip unreadable */
373
- }
374
- })
375
- return stats
376
- }
377
-
378
- /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
379
- * skips files whose remote copy already matches (same size, remote no older). */
380
- async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
381
- const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
382
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
383
-
384
- for (const e of entries) {
385
- if (!e.isDirectory()) continue
386
- if (stats.files >= maxFiles) break
387
- const rp = joinRemotePath(remoteDir, e.name)
388
- try { await sftp.mkdir(rp) } catch { /* already exists */ }
389
- stats.dirs++
390
- const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
391
- stats.files += sub.files
392
- stats.skippedUnchanged += sub.skippedUnchanged
393
- stats.skippedLarge += sub.skippedLarge
394
- stats.pushed.push(...sub.pushed)
395
- if (stats.files >= maxFiles) break
396
- }
397
- if (stats.files >= maxFiles) return stats
398
-
399
- const fileEntries = entries.filter((e) => !e.isDirectory())
400
- await mapLimit(fileEntries, 4, async (e) => {
401
- if (stats.files >= maxFiles) return
402
- const lp = path.join(localDir, e.name)
403
- const rp = joinRemotePath(remoteDir, e.name)
404
- try {
405
- const lstat = statSync(lp)
406
- if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
407
- let rstat = null
408
- try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
409
- const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
410
- if (unchanged) { stats.skippedUnchanged++; return }
411
- const buf = readFileSync(lp)
412
- await sftp.writeFile(rp, buf)
413
- stats.files++
414
- stats.pushed.push(rp)
415
- } catch {
416
- /* skip unreadable / unwritable */
417
- }
418
- })
419
- return stats
420
- }
421
-
422
- // ── persistent multi-machine registry ─────────────────────────────────────
423
- const MACHINES_FILE = 'machines.json'
424
- const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
425
- function loadMachines() {
426
- try {
427
- const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
428
- if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
429
- } catch {}
430
- return { list: [], currentId: null }
431
- }
432
- function saveMachines(list, currentId) {
433
- try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
434
- writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
435
- }
436
- function sanitizeMachine(m) {
437
- if (!m) return m
438
- const { password, ...rest } = m
439
- return { ...rest, passwordSet: !!(m.password && m.password.length) }
440
- }
441
- /** Apply a machine's fields onto the live config object (pool + tools read it). */
442
- function applyMachine(config, m) {
443
- if (!m) return
444
- config.host = m.host
445
- config.port = Number(m.port) || 22
446
- config.username = m.username || ''
447
- config.password = m.password || ''
448
- config.privateKeyPath = m.privateKeyPath || ''
449
- config.passphrase = m.passphrase || ''
450
- config.workspace = m.workspace || (config.workspace || '')
451
- }
452
- function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
453
-
454
- // ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
455
- const KNOWN_HOSTS_FILE = 'known_hosts.json'
456
- const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
457
-
458
- /** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
459
- function loadKnownHosts() {
460
- try {
461
- const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
462
- if (j && typeof j === 'object' && !Array.isArray(j)) return j
463
- } catch {}
464
- return {}
465
- }
466
-
467
- /** Extract the host-key algorithm name from a raw SSH host-key blob
468
- * (SSH wire format: `uint32 len` + algorithm string + key data). */
469
- function blobAlgorithm(blob) {
470
- if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
471
- try {
472
- const len = blob.readUInt32BE(0)
473
- return blob.toString('utf8', 4, 4 + len)
474
- } catch {
475
- return ''
476
- }
477
- }
478
-
479
- /** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
480
- * in a known_hosts file (without the `SHA256:` prefix).
481
- *
482
- * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
483
- * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
484
- * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
485
- * argument must be of type string or an instance of Buffer, TypedArray, or
486
- * DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
487
- * v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
488
- * shapes defensively. */
489
- function keyFingerprint(key) {
490
- const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
491
- if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
492
- return createHash('sha256').update(blob).digest('base64')
493
- }
494
-
495
- /** Build an ssh2 `hostVerifier` bound to the current config. Returns
496
- * `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
497
- * - `off` → always accepts;
498
- * - `accept-new` → records a host's key on first connect, rejects any CHANGE
499
- * afterwards (classic TOFU / MITM detection);
500
- * - `verify` → same, plus rejects hosts never recorded before. */
501
- function createHostKeyGuard(config) {
502
- const id = () => `${config.host}:${config.port}`
503
- const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
504
- ? config.hostKeyMode
505
- : 'accept-new'
506
- const guard = {
507
- mode,
508
- lastError: null,
509
- knownHosts: loadKnownHosts,
510
- forgetHost() {
511
- const kh = loadKnownHosts()
512
- delete kh[id()]
513
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
514
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
515
- },
516
- verifier(key) {
517
- if (mode === 'off') return true
518
- const fp = keyFingerprint(key)
519
- const kh = loadKnownHosts()
520
- const stored = kh[id()]
521
- if (stored) {
522
- if (stored.fingerprint === fp) return true
523
- guard.lastError =
524
- `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
525
- 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
526
- return false
527
- }
528
- if (mode === 'verify') {
529
- guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
530
- return false
531
- }
532
- kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
533
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
534
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
535
- return true
536
- },
537
- }
538
- return guard
539
- }
540
-
541
- /** Whether the current target's key has been recorded/trusted before. */
542
- function isHostKeyKnown(host, port) {
543
- return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
544
- }
545
-
546
- // ── SSH pool (key OR password) ──────────────────────────────────────────────
547
-
548
- class SshPool {
549
- constructor(config) {
550
- this.config = config
551
- this.client = null
552
- this.connecting = null
553
- // Generational token: bumped on every target change / close so a stale
554
- // in-flight connect can never hand this pool a connection to an old host.
555
- this.epoch = 0
556
- }
557
-
558
- resolveKeyPath() {
559
- const p = this.config.privateKeyPath
560
- if (!p) return ''
561
- if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
562
- return p
563
- }
564
-
565
- /** Configure (and reconnect with) a new target. Returns this pool for chaining. */
566
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
567
- if (host !== undefined) this.config.host = String(host)
568
- if (port !== undefined && Number(port)) this.config.port = Number(port)
569
- if (username !== undefined) this.config.username = String(username)
570
- if (password !== undefined && password !== null) this.config.password = String(password)
571
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
572
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
573
- if (workspace !== undefined) this.config.workspace = String(workspace)
574
- this.close()
575
- return this
576
- }
577
-
578
- connect() {
579
- if (this.client) return Promise.resolve(this.client)
580
- if (this.connecting) return this.connecting
581
- const epoch = this.epoch
582
- const pending = this._doConnect(epoch)
583
- this.connecting = pending
584
- const clear = () => {
585
- if (this.epoch === epoch && this.connecting === pending) this.connecting = null
586
- }
587
- // Keep the chain clean in both directions: the raw pending is what callers
588
- // await (they still see rejections); this side just clears the slot.
589
- pending.then(clear, clear)
590
- return pending
591
- }
592
-
593
- _doConnect(epoch) {
594
- return new Promise((resolve, reject) => {
595
- const client = new Client()
596
- let settled = false
597
- // Fresh host-key guard per connect attempt so lastError is never stale.
598
- const guard = createHostKeyGuard(this.config)
599
- const isCurrent = () => this.epoch === epoch
600
- const fail = (err) => {
601
- if (settled) return
602
- settled = true
603
- if (isCurrent() && this.client === client) this.client = null
604
- reject(guard.lastError ? new Error(guard.lastError) : err)
605
- }
606
- client.on('ready', () => {
607
- if (settled) return
608
- settled = true
609
- if (!isCurrent()) {
610
- // The target changed while we were connecting — never adopt this
611
- // client, and make sure the caller learns the connect is void.
612
- try { client.end() } catch {}
613
- reject(new Error('ssh target changed during connect'))
614
- return
615
- }
616
- this.client = client
617
- resolve(client)
618
- })
619
- client.on('error', fail)
620
- client.on('close', () => {
621
- if (isCurrent() && this.client === client) this.client = null
622
- fail(new Error('ssh connection closed'))
623
- })
624
-
625
- const opts = {
626
- host: this.config.host,
627
- port: this.config.port,
628
- username: this.config.username,
629
- readyTimeout: this.config.connectTimeoutMs,
630
- keepaliveInterval: 15000,
631
- keepaliveCountMax: 3,
632
- hostVerifier: (key) => guard.verifier(key),
633
- }
634
- if (this.config.password) {
635
- opts.password = this.config.password
636
- } else {
637
- const keyPath = this.resolveKeyPath()
638
- if (!keyPath) {
639
- return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
640
- }
641
- let key
642
- try {
643
- key = readFileSync(keyPath)
644
- } catch (err) {
645
- return fail(new Error(`cannot read private key "${keyPath}": ${err && err.message}`))
646
- }
647
- opts.privateKey = key
648
- opts.passphrase = this.config.passphrase || undefined
649
- }
650
- client.connect(opts)
651
- })
652
- }
653
-
654
- /** Run one remote command; resolves { code, signal, stdout, stderr }. */
655
- exec(command, timeoutMs) {
656
- return this.connect().then(
657
- (client) =>
658
- new Promise((resolve, reject) => {
659
- client.exec(command, (err, stream) => {
660
- if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
661
- let stdout = ''
662
- let stderr = ''
663
- let settled = false
664
- let exitCode = null
665
- let exitSignal = null
666
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
667
- const settle = () => {
668
- if (settled) return
669
- settled = true
670
- clearTimeout(timer)
671
- resolve({
672
- code: exitCode,
673
- signal: exitSignal,
674
- stdout: truncate(stdout, this.config.maxOutputChars),
675
- stderr: truncate(stderr, this.config.maxOutputChars),
676
- })
677
- }
678
- const timer = setTimeout(() => {
679
- if (settled) return
680
- exitCode = -1
681
- exitSignal = 'TIMEOUT'
682
- // Kill the remote command (SIGTERM) rather than just dropping the
683
- // channel, so a runaway process cannot keep running and holding
684
- // the SSH connection after we've given up on its output. The hard
685
- // close below frees the channel even if the process ignores it.
686
- try {
687
- if (typeof stream.signal === 'function') stream.signal('SIGTERM')
688
- } catch {}
689
- const hardClose = setTimeout(() => {
690
- try { stream.close() } catch {}
691
- }, 800)
692
- if (typeof hardClose.unref === 'function') hardClose.unref()
693
- settle()
694
- }, timeoutMs || this.config.commandTimeoutMs)
695
- stream.on('close', (code, signal) => {
696
- if (settled) return
697
- exitCode = code
698
- exitSignal = signal
699
- settle()
700
- })
701
- stream.on('data', (d) => {
702
- if (stdout.length < hardCap) stdout += d
703
- })
704
- stream.stderr.on('data', (d) => {
705
- if (stderr.length < hardCap) stderr += d
706
- })
707
- stream.on('error', (e) => {
708
- if (settled) return
709
- settled = true
710
- clearTimeout(timer)
711
- reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
712
- })
713
- })
714
- }),
715
- )
716
- }
717
-
718
- /** Resolve a promisified SFTP client for binary file transfers (sync).
719
- * All path arguments are normalized via toSftpPath() so Windows remotes
720
- * (drive-letter paths) work too. */
721
- sftp() {
722
- return this.connect().then(
723
- (client) =>
724
- new Promise((resolve, reject) => {
725
- client.sftp((err, sftp) => {
726
- if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
727
- // Wrap a raw SFTP method so a stuck server can never hang a tool call.
728
- const withTimeout = (fn) => (...args) =>
729
- new Promise((r2, j2) => {
730
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
731
- const done = (e, v) => {
732
- clearTimeout(timer)
733
- e ? j2(e) : r2(v)
734
- }
735
- try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
736
- })
737
- const P = (p) => toSftpPath(p)
738
- resolve({
739
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
740
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
741
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
742
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
743
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
744
- writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
745
- })
746
- })
747
- }),
748
- )
749
- }
750
-
751
- close() {
752
- // Bump the epoch so any in-flight connect is orphaned, drop the pending
753
- // promise (its rejection becomes nobody's problem — swallow it), and end
754
- // the live client if there is one.
755
- this.epoch++
756
- const client = this.client
757
- this.client = null
758
- const pending = this.connecting
759
- this.connecting = null
760
- if (pending && typeof pending.catch === 'function') {
761
- try { pending.catch(() => {}) } catch {}
762
- }
763
- if (client) {
764
- try {
765
- client.end()
766
- } catch {}
767
- }
768
- }
769
- }
770
-
771
- // ── helper: run + text ─────────────────────────────────────────────────────
772
-
773
- export async function apply(ctx, config) {
774
- const pool = new SshPool(config)
775
- ctx.effect(() => () => pool.close(), 'dsh-remote.close')
776
-
777
- // Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
778
- // location so existing machines + mirrors keep working after the move.
779
- migrateLegacyData()
780
-
781
- // ── machine registry (multi-host) ─────────────────────────────────────────
782
- const store = loadMachines()
783
- const machines = store.list
784
- const machineIndex = (id) => machines.findIndex((m) => m.id === id)
785
- const currentMachine = () => {
786
- if (store.currentId) {
787
- const i = machineIndex(store.currentId)
788
- if (i >= 0) return machines[i]
789
- }
790
- // fall back to config-derived default (host set via cordis config)
791
- if (config.host) return { id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase }
792
- return null
793
- }
794
- const setCurrent = (id) => {
795
- const i = machineIndex(id)
796
- if (i < 0) return false
797
- store.currentId = id
798
- saveMachines(machines, id)
799
- applyMachine(config, machines[i])
800
- pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
801
- return true
802
- }
803
- // If no stored current, adopt a CLI-provided default as the active machine.
804
- {
805
- const cur = currentMachine()
806
- if (cur && cur.host) applyMachine(config, cur)
807
- }
808
-
809
- /** Set the active remote workspace AND persist it on the current machine so
810
- * it survives restarts (previously a tool-set workspace reset on reload). */
811
- const persistWorkspace = (p) => {
812
- config.workspace = p
813
- if (store.currentId) {
814
- const i = machineIndex(store.currentId)
815
- if (i >= 0) {
816
- machines[i].workspace = p
817
- saveMachines(machines, store.currentId)
818
- }
819
- }
820
- }
821
-
822
- const run = async (cmd, opts = {}) => {
823
- const res = await pool.exec(cmd, opts.timeoutMs)
824
- const parts = []
825
- if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
826
- if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
827
- if (!parts.length) parts.push('(no output)')
828
- let text = parts.join('\n')
829
- if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
830
- else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
831
- return text
832
- }
833
-
834
- /** Structured-listing of a remote dir: name + usable type.
835
- *
836
- * Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
837
- * macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
838
- * shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
839
- * resolved with one bounded lstat (if it fails they degrade to files). */
840
- const listDirStructured = async (p, timeoutMs) => {
841
- const target = normalizeRemotePath(p || '/')
842
- let sftp
843
- try {
844
- sftp = await pool.sftp()
845
- } catch (err) {
846
- throw new Error('browse failed: ' + ((err && err.message) || err))
847
- }
848
- let list = []
849
- try {
850
- list = await sftp.readdir(target)
851
- } catch (err) {
852
- throw new Error('browse failed: ' + ((err && err.message) || err))
853
- }
854
- const items = []
855
- const symIdx = []
856
- for (const e of list) {
857
- const name = String(e.filename)
858
- if (name === '.' || name === '..' || !name) continue
859
- const a = e.attrs || {}
860
- if (a.isSymbolicLink && a.isSymbolicLink()) {
861
- items.push({ type: 'symlink', name })
862
- symIdx.push(items.length - 1)
863
- } else if (a.isDirectory && a.isDirectory()) {
864
- items.push({ type: 'dir', name })
865
- } else {
866
- items.push({ type: 'file', name })
867
- }
868
- }
869
- // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
870
- if (symIdx.length) {
871
- await Promise.all(symIdx.map(async (i) => {
872
- const full = joinRemotePath(target, items[i].name)
873
- try {
874
- const st = await sftp.lstat(full)
875
- items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
876
- } catch { /* degrade to file */ }
877
- }))
878
- }
879
- return { path: target, items }
880
- }
881
-
882
- /** Verify a remote path is an existing directory, via SFTP stat (works on
883
- * any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
884
- * path is missing, is a file, or the stat errors. */
885
- const isRemoteDir = async (p) => {
886
- const target = normalizeRemotePath(p)
887
- try {
888
- const sftp = await pool.sftp()
889
- const st = await sftp.stat(target)
890
- return !!(st && st.isDirectory && st.isDirectory())
891
- } catch {
892
- return false
893
- }
894
- }
895
-
896
- // ── remote workspace state ────────────────────────────────────────────────
897
-
898
- const wsPath = () => (config.workspace || '').trim()
899
- const status = () => ({
900
- host: config.host,
901
- port: config.port,
902
- username: config.username,
903
- connected: !!pool.client,
904
- workspace: wsPath(),
905
- localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
906
- currentId: store.currentId || null,
907
- machines: machines.map(sanitizeMachine),
908
- hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
909
- hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
910
- })
911
-
912
- // ── tools ─────────────────────────────────────────────────────────────────
913
-
914
- const renderErr = (err) => ({
915
- kind: 'error',
916
- text: String((err && err.message) || err),
917
- })
918
-
919
- const tools = [
920
- defineTool({
921
- name: 'rw_info',
922
- description:
923
- 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when an rw_* call fails to check connectivity.',
924
- parameters: {},
925
- output: {
926
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
927
- render: (_a, v) => [{ type: 'text', text: v.text }],
928
- },
929
- async execute() {
930
- const s = status()
931
- const lines = [
932
- `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
933
- `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
934
- `Local mirror: ${s.localMirror || '(none)'}`,
935
- `Connected: ${s.connected ? 'yes' : 'no'}`,
936
- '',
937
- ]
938
- if (s.host && s.workspace) {
939
- try {
940
- // `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
941
- const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
942
- if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
943
- else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
944
- else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
945
- } catch (err) {
946
- lines.push('Ping: FAILED — ' + ((err && err.message) || err))
947
- }
948
- } else {
949
- lines.push('No host + workspace configured — call rw_connect with a host to get started.')
950
- }
951
- return { text: lines.join('\n') }
952
- },
953
- }),
954
-
955
- defineTool({
956
- name: 'rw_connect',
957
- description:
958
- 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
959
- parameters: {
960
- host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
961
- username: { type: 'string', description: 'SSH user (default from config or root)' },
962
- port: { type: 'integer', description: 'SSH port (default 22)' },
963
- password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
964
- privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
965
- },
966
- output: {
967
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
968
- render: (_a, a) => [{ type: 'text', text: a.text }],
969
- },
970
- async execute(args) {
971
- const host = String(args.host || '').trim()
972
- if (!host) throw new Error('rw_connect: host is required')
973
- pool.setTarget({
974
- host,
975
- username: args.username || config.username || 'root',
976
- port: args.port || undefined,
977
- password: args.password !== undefined ? args.password : undefined,
978
- privateKeyPath: args.privateKeyPath || undefined,
979
- })
980
- try {
981
- const res = await pool.exec('echo ok', 8000)
982
- if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
983
- return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
984
- } catch (err) {
985
- throw err
986
- }
987
- },
988
- }),
989
-
990
- defineTool({
991
- name: 'rw_pick_workspace',
992
- description:
993
- 'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
994
- parameters: {
995
- path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
996
- },
997
- output: {
998
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
999
- render: (_a, a) => [{ type: 'text', text: a.text }],
1000
- },
1001
- async execute(args) {
1002
- const p = normalizeRemotePath(String(args.path || ''))
1003
- if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1004
- const ok = await isRemoteDir(p)
1005
- if (!ok) return { text: `not a directory (or missing) on ${p}` }
1006
- persistWorkspace(p)
1007
- const local = ensureMirror(p, config.host, config.username, config.port)
1008
- return {
1009
- text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1010
- }
1011
- },
1012
- }),
1013
-
1014
- defineTool({
1015
- name: 'rw_sync',
1016
- description:
1017
- 'Download the current remote workspace into its local mirror directory over SFTP (bounded). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them.',
1018
- parameters: {
1019
- depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
1020
- maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
1021
- },
1022
- output: {
1023
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1024
- render: (_a, a) => [{ type: 'text', text: a.text }],
1025
- },
1026
- async execute(args) {
1027
- const p = wsPath()
1028
- if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
1029
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1030
- mkdirSync(local, { recursive: true })
1031
- const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1032
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1033
- let sftp
1034
- try {
1035
- sftp = await pool.sftp()
1036
- } catch (err) {
1037
- return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
1038
- }
1039
- const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
1040
- let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
1041
- if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
1042
- if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1043
- return { text }
1044
- },
1045
- }),
1046
-
1047
- defineTool({
1048
- name: 'rw_push',
1049
- description:
1050
- 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded). Use after editing files in the local mirror so the remote reflects your changes (bidirectional sync).',
1051
- parameters: {
1052
- maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
1053
- },
1054
- output: {
1055
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1056
- render: (_a, a) => [{ type: 'text', text: a.text }],
1057
- },
1058
- async execute(args) {
1059
- const p = wsPath()
1060
- if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
1061
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1062
- if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1063
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1064
- let sftp
1065
- try {
1066
- sftp = await pool.sftp()
1067
- } catch (err) {
1068
- return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
1069
- }
1070
- const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
1071
- let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
1072
- if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
1073
- if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1074
- return { text }
1075
- },
1076
- }),
1077
-
1078
- defineTool({
1079
- name: 'rw_list_dir',
1080
- description:
1081
- 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
1082
- parameters: {
1083
- path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
1084
- },
1085
- output: {
1086
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1087
- render: (_a, a) => [{ type: 'text', text: a.text }],
1088
- },
1089
- async execute(args) {
1090
- const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
1091
- if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1092
- // SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
1093
- // PowerShell) unlike `ls -la`.
1094
- let list
1095
- try {
1096
- const sftp = await pool.sftp()
1097
- list = await sftp.readdir(p)
1098
- } catch (err) {
1099
- throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1100
- }
1101
- const lines = list
1102
- .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1103
- .map((e) => {
1104
- const a = e.attrs || {}
1105
- const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1106
- const size = typeof a.size === 'number' ? String(a.size) : '?'
1107
- return `${type} ${size.padStart(10)} ${String(e.filename)}`
1108
- })
1109
- return { text: lines.length ? lines.join('\n') : '(empty directory)' }
1110
- },
1111
- }),
1112
-
1113
- defineTool({
1114
- name: 'rw_read_file',
1115
- description:
1116
- 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
1117
- parameters: {
1118
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1119
- startLine: { type: 'integer', description: '1-based first line (default 1)' },
1120
- endLine: { type: 'integer', description: '1-based last line (inclusive)' },
1121
- maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
1122
- },
1123
- output: {
1124
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1125
- render: (_a, a) => [{ type: 'text', text: a.text }],
1126
- },
1127
- async execute(args) {
1128
- const p = normalizeRemotePath(String(args.path || ''))
1129
- if (!p) throw new Error('rw_read_file: path is required')
1130
- const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1131
- let from = Math.max(Number(args.startLine) || 1, 1)
1132
- let to = Number(args.endLine) || 0
1133
- if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
1134
- // SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
1135
- // / PowerShell, no POSIX sed) work too.
1136
- let buf
1137
- try {
1138
- const sftp = await pool.sftp()
1139
- buf = await sftp.readFile(p)
1140
- } catch (err) {
1141
- throw new Error('rw_read_file: ' + ((err && err.message) || err))
1142
- }
1143
- const content = buf.toString('utf8').replace(/\r\n/g, '\n')
1144
- const allLines = content.split('\n')
1145
- const page = allLines.slice(from - 1, to)
1146
- const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1147
- let text = numbered === '' ? '(empty or out of range)' : numbered
1148
- if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
1149
- return { text }
1150
- },
1151
- }),
1152
-
1153
- defineTool({
1154
- name: 'rw_exec',
1155
- description:
1156
- 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere.',
1157
- parameters: {
1158
- command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1159
- cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
1160
- },
1161
- output: {
1162
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1163
- render: (_a, a) => [{ type: 'text', text: a.text }],
1164
- },
1165
- async execute(args) {
1166
- const cmd = String(args.command || '')
1167
- if (!cmd) throw new Error('rw_exec: command is required')
1168
- const ws = wsPath()
1169
- const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1170
- // Windows remotes: no POSIX cd/single-quotes — let the command run as-is
1171
- // (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
1172
- const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1173
- return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
1174
- },
1175
- }),
1176
-
1177
- defineTool({
1178
- name: 'rw_write_file',
1179
- description:
1180
- 'Write text to a file on the remote host (creating parent directories if needed). Path is absolute. Use this to create or overwrite a remote file directly, instead of round-tripping through a local mirror.',
1181
- parameters: {
1182
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1183
- content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
1184
- mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
1185
- },
1186
- output: {
1187
- schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1188
- render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
1189
- },
1190
- async execute(args) {
1191
- const p = normalizeRemotePath(String(args.path || ''))
1192
- if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1193
- const content = String(args.content == null ? '' : args.content)
1194
- let sftp
1195
- try {
1196
- sftp = await pool.sftp()
1197
- } catch (err) {
1198
- throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
1199
- }
1200
- const mkdir = args.mkdir !== false
1201
- if (mkdir) {
1202
- // create each level from the root so mkdir -p semantics survive even
1203
- // when intermediate privileged-parent dirs don't allow create (best effort)
1204
- await mkdirRemoteDirs(sftp, p)
1205
- }
1206
- const buf = Buffer.from(content, 'utf8')
1207
- await sftp.writeFile(p, buf)
1208
- const bytes = Buffer.byteLength(content, 'utf8')
1209
- return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
1210
- },
1211
- }),
1212
-
1213
- defineTool({
1214
- name: 'rw_search',
1215
- description:
1216
- 'Search remote files for a pattern (grep, recursive, case-insensitive by default). Returns matching file:line rows; output is capped. Use instead of rw_exec grep when you want a bounded, portable search.',
1217
- parameters: {
1218
- pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1219
- path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1220
- glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
1221
- ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1222
- },
1223
- output: {
1224
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1225
- render: (_a, a) => [{ type: 'text', text: a.text }],
1226
- },
1227
- async execute(args) {
1228
- const pattern = String(args.pattern || '')
1229
- if (!pattern) throw new Error('rw_search: pattern is required')
1230
- const ws = wsPath()
1231
- const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1232
- if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1233
- if (dir.includes('\\')) {
1234
- // Windows remotes have no POSIX find/grep; the search would need a
1235
- // recursive SFTP walk. Give an actionable hint instead of a broken cmd.
1236
- return { text: `rw_search does not support Windows-remote paths yet (${dir}). Use rw_exec with a PowerShell one-liner, e.g.: powershell -NoProfile -Command "Get-ChildItem -Recurse -File | Select-String -Pattern '${pattern}'"` }
1237
- }
1238
- const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1239
- const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1240
- // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
1241
- // differ on BSD, and GNU-only --include is avoided in favor of find -name.
1242
- const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
1243
- return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
1244
- },
1245
- }),
1246
-
1247
- defineTool({
1248
- name: 'rw_download',
1249
- description:
1250
- 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text.',
1251
- parameters: {
1252
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1253
- localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1254
- },
1255
- output: {
1256
- schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1257
- render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
1258
- },
1259
- async execute(args) {
1260
- const p = normalizeRemotePath(String(args.path || ''))
1261
- if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1262
- let sftp
1263
- try {
1264
- sftp = await pool.sftp()
1265
- } catch (err) {
1266
- throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
1267
- }
1268
- let local
1269
- if (args.localPath) {
1270
- local = path.resolve(String(args.localPath))
1271
- } else {
1272
- const ws = wsPath()
1273
- if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1274
- const base = mirrorDirFor(ws, config.host, config.username, config.port)
1275
- const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1276
- local = path.join(base, rel)
1277
- }
1278
- mkdirSync(path.dirname(local), { recursive: true })
1279
- const buf = await sftp.readFile(p)
1280
- writeFileSync(local, buf)
1281
- const bytes = buf.byteLength
1282
- return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1283
- },
1284
- }),
1285
-
1286
- defineTool({
1287
- name: 'rw_upload',
1288
- description:
1289
- 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1290
- parameters: {
1291
- localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1292
- path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1293
- },
1294
- output: {
1295
- schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1296
- render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
1297
- },
1298
- async execute(args) {
1299
- const rp = normalizeRemotePath(String(args.path || ''))
1300
- const lp = String(args.localPath || '')
1301
- if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1302
- if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1303
- let sftp
1304
- try {
1305
- sftp = await pool.sftp()
1306
- } catch (err) {
1307
- throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1308
- }
1309
- await mkdirRemoteDirs(sftp, rp)
1310
- const buf = readFileSync(lp)
1311
- await sftp.writeFile(rp, buf)
1312
- const bytes = buf.byteLength
1313
- return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
1314
- },
1315
- }),
1316
-
1317
- defineTool({
1318
- name: 'rw_disconnect',
1319
- description:
1320
- 'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
1321
- parameters: {},
1322
- output: {
1323
- schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
1324
- render: (_a, a) => [{ type: 'text', text: a.text }],
1325
- },
1326
- async execute() {
1327
- pool.close()
1328
- return { ok: true, text: 'disconnected' }
1329
- },
1330
- }),
1331
- ]
1332
-
1333
- for (const t of tools) {
1334
- ctx.tools.register(t)
1335
- }
1336
-
1337
- // ── system-prompt injection: the current remote workspace ─────────────────
1338
- ctx.systemPrompt.section({
1339
- name: 'dsh-remote',
1340
- order: 88,
1341
- text: () => {
1342
- const w = wsPath()
1343
- if (!w || !config.host) return ''
1344
- return (
1345
- '## Remote workspace\n' +
1346
- `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
1347
- 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_exec / rw_search) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
1348
- )
1349
- },
1350
- })
1351
-
1352
- // ── slash command: /remote reports status + connection hints ──────────────
1353
- const commands = ctx.get('commands')
1354
- if (commands !== undefined) {
1355
- commands.register({
1356
- name: 'remote',
1357
- description: 'Show the current remote workspace / connection status and how to use remote tools.',
1358
- handler: (invocation) => {
1359
- const s = status()
1360
- return {
1361
- kind: 'success',
1362
- text:
1363
- `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
1364
- `Remote workspace: ${s.workspace || '(none)'}\n` +
1365
- `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1366
- (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1367
- `\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
1368
- (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
1369
- }
1370
- },
1371
- })
1372
- commands.register({
1373
- name: 'remote-forget-key',
1374
- description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1375
- handler: () => {
1376
- createHostKeyGuard(config).forgetHost()
1377
- return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1378
- },
1379
- })
1380
- }
1381
-
1382
- // ── JSON endpoints for settings UI ─────────────────────────────────────────
1383
- const webServer = ctx.get('webServer')
1384
- if (webServer === undefined) return
1385
-
1386
- const sendJson = (res, status, body) => {
1387
- res.statusCode = status
1388
- res.setHeader('Content-Type', 'application/json; charset=utf-8')
1389
- res.end(JSON.stringify(body))
1390
- }
1391
- // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1392
- // unbounded memory on a local-only route.
1393
- const MAX_BODY_BYTES = 1024 * 1024
1394
- const readBody = (req) =>
1395
- new Promise((resolve) => {
1396
- const chunks = []
1397
- let total = 0
1398
- req.on('data', (c) => {
1399
- total += c.length
1400
- if (total > MAX_BODY_BYTES) {
1401
- req.removeAllListeners('data')
1402
- resolve('{}')
1403
- return
1404
- }
1405
- chunks.push(c)
1406
- })
1407
- req.on('end', () => resolve(chunks.join('')))
1408
- })
1409
-
1410
- // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1411
- // 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
1412
- // loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
1413
- // 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
1414
- // native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
1415
- const PICK_TIMEOUT_MS = 120000
1416
- const runPick = (bin, args) =>
1417
- new Promise((resolve, reject) => {
1418
- execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1419
- if (err) {
1420
- const code = err.code
1421
- const msg = String(stderr || '')
1422
- // 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
1423
- if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1424
- if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1425
- if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1426
- return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1427
- }
1428
- const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1429
- resolve(p ? { path: p } : { cancelled: true })
1430
- })
1431
- })
1432
- const pickLocalNative = async () => {
1433
- const platform = process.platform
1434
- if (platform === 'darwin') {
1435
- return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1436
- }
1437
- if (platform === 'linux') {
1438
- try {
1439
- return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1440
- } catch (err) {
1441
- if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1442
- throw err
1443
- }
1444
- }
1445
- throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1446
- }
1447
-
1448
- const routes = [
1449
- {
1450
- kind: 'exact',
1451
- path: '/dsh-remote/status',
1452
- handler: async (req, res) => {
1453
- if (req.method === 'GET') return sendJson(res, 200, status())
1454
- sendJson(res, 405, { error: 'method not allowed' })
1455
- },
1456
- },
1457
- {
1458
- kind: 'exact',
1459
- path: '/dsh-remote/connect',
1460
- handler: async (req, res) => {
1461
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1462
- try {
1463
- const payload = JSON.parse((await readBody(req)) || '{}')
1464
- pool.setTarget({
1465
- host: payload.host,
1466
- port: payload.port,
1467
- username: payload.username,
1468
- password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
1469
- privateKeyPath: payload.privateKeyPath,
1470
- workspace: payload.workspace,
1471
- })
1472
- await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
1473
- return sendJson(res, 200, { ok: true, ...status() })
1474
- } catch (err) {
1475
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1476
- }
1477
- },
1478
- },
1479
- {
1480
- kind: 'exact',
1481
- path: '/dsh-remote/ls',
1482
- handler: async (req, res) => {
1483
- try {
1484
- // Proper URL parsing so `+` in a path decodes to a literal space
1485
- // (the old regex missed it).
1486
- const q = new URL(req.url, 'http://localhost').searchParams
1487
- const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
1488
- const out = await listDirStructured(p || '/')
1489
- return sendJson(res, 200, { path: p, items: out.items })
1490
- } catch (err) {
1491
- return sendJson(res, 500, { error: String((err && err.message) || err) })
1492
- }
1493
- },
1494
- },
1495
- {
1496
- // Read a remote file over SFTP (live, not the local mirror) for the
1497
- // sidebar's remote file viewer. Text files come back as UTF-8 content
1498
- // (CRLF→LF), binary files are reported as binary with size+head bytes
1499
- // (base64) so the client can sniff the format without loading the
1500
- // whole file. `maxBytes` caps how much text is returned.
1501
- kind: 'exact',
1502
- path: '/dsh-remote/read',
1503
- handler: async (req, res) => {
1504
- if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1505
- try {
1506
- const q = new URL(req.url, 'http://localhost').searchParams
1507
- let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
1508
- if (req.method === 'POST') {
1509
- const body = JSON.parse((await readBody(req)) || '{}')
1510
- if (!p) p = String(body.path || '')
1511
- }
1512
- if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
1513
- const target = normalizeRemotePath(p)
1514
- const maxBytes = Math.min(Math.max(Number(q.get('maxBytes')) || 256 * 1024, 1024), 2 * 1024 * 1024)
1515
- let sftp
1516
- try {
1517
- sftp = await pool.sftp()
1518
- } catch (err) {
1519
- return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
1520
- }
1521
- let buf
1522
- try {
1523
- buf = await sftp.readFile(target)
1524
- } catch (err) {
1525
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1526
- }
1527
- // Binary sniff: NUL byte in the first 8KB (or the whole file when
1528
- // smaller) marks the file binary — matches the sidebar's convention.
1529
- const headN = Math.min(buf.length, 8192)
1530
- let binary = false
1531
- for (let i = 0; i < headN; i++) {
1532
- if (buf[i] === 0) { binary = true; break }
1533
- }
1534
- if (binary) {
1535
- return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
1536
- }
1537
- let content = buf.toString('utf8').replace(/\r\n/g, '\n')
1538
- const truncated = content.length > maxBytes
1539
- if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
1540
- return sendJson(res, 200, { ok: true, binary: false, content, truncated })
1541
- } catch (err) {
1542
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1543
- }
1544
- },
1545
- },
1546
- {
1547
- kind: 'exact',
1548
- path: '/dsh-remote/workspace',
1549
- handler: async (req, res) => {
1550
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1551
- try {
1552
- const payload = JSON.parse((await readBody(req)) || '{}')
1553
- const p = normalizeRemotePath(String(payload.path || ''))
1554
- if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
1555
- const okDir = await isRemoteDir(p)
1556
- if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1557
- persistWorkspace(p)
1558
- const local = ensureMirror(p, config.host, config.username, config.port)
1559
- return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
1560
- } catch (err) {
1561
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1562
- }
1563
- },
1564
- },
1565
- {
1566
- kind: 'exact',
1567
- path: '/dsh-remote/mirror',
1568
- handler: async (req, res) => {
1569
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1570
- try {
1571
- const payload = JSON.parse((await readBody(req)) || '{}')
1572
- const p = normalizeRemotePath(String(payload.path || ''))
1573
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
1574
- if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
1575
- // Always verify over SSH (connecting if needed) so an unconnected or
1576
- // wrong host can never silently mint a mirror for a bogus path.
1577
- const okDir = await isRemoteDir(p)
1578
- if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
1579
- const local = ensureMirror(p, config.host, config.username, config.port)
1580
- persistWorkspace(p)
1581
- return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
1582
- } catch (err) {
1583
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1584
- }
1585
- },
1586
- },
1587
- {
1588
- kind: 'exact',
1589
- path: '/dsh-remote/local-pick',
1590
- handler: async (req, res) => {
1591
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1592
- try {
1593
- // 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
1594
- // ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
1595
- // "cannot get property ... without inject"(该名字未在本插件
1596
- // inject 里声明),ctx.get() 无此要求、缺失返回 null。
1597
- // 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
1598
- // 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
1599
- let outcome = null
1600
- let via = 'service'
1601
- const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1602
- if (dp && typeof dp.capability === 'function') {
1603
- try {
1604
- const cap = await Promise.resolve(dp.capability())
1605
- if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
1606
- const pickAbort = new AbortController()
1607
- const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
1608
- pickAbort.abort()
1609
- outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
1610
- }
1611
- } catch (err) {
1612
- outcome = null // DSH 服务出错 → 落到自持兜底
1613
- }
1614
- }
1615
- if (!outcome) {
1616
- via = 'own'
1617
- try {
1618
- outcome = await pickLocalNative()
1619
- } catch (err) {
1620
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
1621
- }
1622
- }
1623
- if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
1624
- return sendJson(res, 200, { ok: true, path: outcome.path, via })
1625
- } catch (err) {
1626
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1627
- }
1628
- },
1629
- },
1630
- {
1631
- kind: 'exact',
1632
- path: '/dsh-remote/machines',
1633
- handler: async (req, res) => {
1634
- if (req.method === 'GET') {
1635
- return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
1636
- }
1637
- if (req.method === 'POST') {
1638
- try {
1639
- const body = JSON.parse((await readBody(req)) || '{}')
1640
- const action = body.action || 'add'
1641
- if (action === 'add' || action === 'update') {
1642
- const host = String(body.host || '').trim()
1643
- if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
1644
- const rec = {
1645
- id: body.id || machineId(),
1646
- name: String(body.name || '').trim() || host,
1647
- host,
1648
- port: Number(body.port) || 22,
1649
- username: String(body.username || '').trim() || 'root',
1650
- password: body.password || '',
1651
- privateKeyPath: String(body.privateKeyPath || '').trim(),
1652
- passphrase: body.passphrase || '',
1653
- workspace: String(body.workspace || '').trim(),
1654
- }
1655
- const i = machineIndex(rec.id)
1656
- if (i >= 0) machines[i] = rec; else machines.push(rec)
1657
- if (!store.currentId) { store.currentId = rec.id }
1658
- saveMachines(machines, store.currentId)
1659
- if (store.currentId === rec.id) applyMachine(config, rec)
1660
- return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
1661
- }
1662
- if (action === 'delete') {
1663
- const i = machineIndex(String(body.id || ''))
1664
- if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
1665
- machines.splice(i, 1)
1666
- if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
1667
- saveMachines(machines, store.currentId)
1668
- return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
1669
- }
1670
- return sendJson(res, 400, { ok: false, error: 'unknown action' })
1671
- } catch (err) {
1672
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1673
- }
1674
- }
1675
- return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1676
- },
1677
- },
1678
- {
1679
- kind: 'exact',
1680
- path: '/dsh-remote/test-connect',
1681
- handler: async (req, res) => {
1682
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1683
- try {
1684
- const body = JSON.parse((await readBody(req)) || '{}')
1685
- const probe = new SshPool({
1686
- ...config,
1687
- host: String(body.host || config.host),
1688
- port: Number(body.port) || config.port,
1689
- username: String(body.username || config.username),
1690
- password: String(body.password || ''),
1691
- privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
1692
- passphrase: String(body.passphrase || ''),
1693
- connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
1694
- commandTimeoutMs: 10000,
1695
- })
1696
- const started = Date.now()
1697
- await probe.connect()
1698
- await probe.exec('true', 10000)
1699
- probe.close()
1700
- return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
1701
- } catch (err) {
1702
- return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
1703
- }
1704
- },
1705
- },
1706
- {
1707
- kind: 'exact',
1708
- path: '/dsh-remote/current',
1709
- handler: async (req, res) => {
1710
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1711
- try {
1712
- const body = JSON.parse((await readBody(req)) || '{}')
1713
- const okSet = setCurrent(String(body.id || ''))
1714
- if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
1715
- return sendJson(res, 200, { ok: true, ...status() })
1716
- } catch (err) {
1717
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1718
- }
1719
- },
1720
- },
1721
- {
1722
- kind: 'exact',
1723
- path: '/dsh-remote/forget-key',
1724
- handler: async (req, res) => {
1725
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1726
- // Drop the trusted host-key record for the CURRENT target so the next
1727
- // connect re-records it (use after an intentional host reinstall or a
1728
- // false MITM alarm).
1729
- createHostKeyGuard(config).forgetHost()
1730
- return sendJson(res, 200, { ok: true, ...status() })
1731
- },
1732
- },
1733
- ]
1734
-
1735
- const disposers = routes.map((r) => webServer.register(r))
1736
- ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
1
+ // dsh-remote — remote-work assistant for DeepSeek Harness.
2
+ //
3
+ // Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
4
+ // • one persistent SSH/SFTP pool per configured remote (password OR private key),
5
+ // • a "current remote workspace" — a remote directory the agent treats as the
6
+ // active project root (user@host:/path) — injected into every system prompt,
7
+ // • model tools `rw_info` / `rw_connect` / `rw_pick_workspace` /
8
+ // `rw_list_dir` / `rw_read_file` / `rw_exec`,
9
+ // • JSON endpoints the client settings page uses to connect → browse → select the
10
+ // remote workspace over the harness `webServer`.
11
+ //
12
+ // The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
13
+ // proven by dsh-remote-debug, extended with password auth and a mutable workspace:
14
+ // `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
15
+ // presented as such to the model and UI, not a replacement of the local one.
16
+ //
17
+ // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import ssh2 from 'ssh2'
21
+ import { createHash } from 'node:crypto'
22
+ import { execFile } from 'node:child_process'
23
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import path from 'node:path'
26
+
27
+ const { Client } = ssh2
28
+
29
+ export const name = 'dsh-remote'
30
+
31
+ // tools + systemPrompt + webServer are required. webServer is INJECTED (not just
32
+ // lazily read) so this plugin activates only after the web server is up — otherwise
33
+ // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
34
+ // (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
35
+ export const inject = ['tools', 'systemPrompt', 'webServer']
36
+
37
+ export const Config = z.object({
38
+ /** Remote SSH host (empty → the plugin starts disconnected). */
39
+ host: z.string().default(''),
40
+ /** Remote SSH port (22 unless the machine uses a custom port). */
41
+ port: z.number().step(1).min(1).max(65535).default(22),
42
+ /** SSH login user. */
43
+ username: z.string().default(''),
44
+ /** Password login (only when the remote has no key. Override the fallback below). */
45
+ password: z.string().default(''),
46
+ /** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
47
+ privateKeyPath: z.string().default(''),
48
+ /** Key passphrase when the key is encrypted. */
49
+ passphrase: z.string().default(''),
50
+ /** Initial remote workspace path (absolute dir the agent should treat as root). */
51
+ workspace: z.string().default(''),
52
+ /** Per-command timeout. */
53
+ commandTimeoutMs: z.number().step(1).min(1000).default(20000),
54
+ /** SSH connection establishment timeout. */
55
+ connectTimeoutMs: z.number().step(1).min(1000).default(15000),
56
+ /** Hard ceiling on collected remote output per call. */
57
+ maxOutputChars: z.number().step(1).min(1024).default(200000),
58
+ /** Skip mirroring files larger than this many bytes (0 = no cap). */
59
+ maxFileBytes: z.number().step(1).min(0).default(52428800),
60
+ /** Host-key policy: `accept-new` (default) records a host's key on first
61
+ * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
62
+ * accept-new); `verify` also rejects hosts never seen before; `off` skips
63
+ * verification entirely (MITM-unsafe, not recommended). */
64
+ hostKeyMode: z.string().default('accept-new'),
65
+ })
66
+
67
+ // ── shell / path helpers (proven in the read tooling) ───────────────────────
68
+
69
+ /** Single-quote one shell argument verbatim. */
70
+ function shq(s) {
71
+ return "'" + String(s).replace(/'/g, "'\\''") + "'"
72
+ }
73
+
74
+ /**
75
+ * Normalize a remote path into a clean absolute path, supporting BOTH POSIX
76
+ * (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
77
+ *
78
+ * - POSIX paths keep `/` separators and a leading `/`.
79
+ * - Windows drive paths keep their `X:` prefix and are returned with `\`
80
+ * separators (the native form on a Windows remote); a leading `/` is NOT
81
+ * added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
82
+ * - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
83
+ * - `.` / `..` segments are collapsed in both separator styles.
84
+ */
85
+ function normalizeRemotePath(p) {
86
+ const s = String(p)
87
+ // Windows UNC: \\server\share\…
88
+ const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
89
+ if (unc) {
90
+ const [prefix, rest] = [unc[1], unc[2]]
91
+ const parts = []
92
+ for (const seg of rest.split(/[\\/]+/)) {
93
+ if (seg === '' || seg === '.') continue
94
+ if (seg === '..') { parts.pop(); continue }
95
+ parts.push(seg)
96
+ }
97
+ return parts.length ? prefix + '\\' + parts.join('\\') : prefix
98
+ }
99
+ // Windows drive: X:\… or X:/…
100
+ const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
101
+ if (drive) {
102
+ const [prefix, rest] = [drive[1], drive[2]]
103
+ const parts = []
104
+ for (const seg of rest.split(/[\\/]+/)) {
105
+ if (seg === '' || seg === '.') continue
106
+ if (seg === '..') { parts.pop(); continue }
107
+ parts.push(seg)
108
+ }
109
+ return prefix + '\\' + parts.join('\\')
110
+ }
111
+ // POSIX: /a/b/c
112
+ const parts = []
113
+ for (const seg of s.split('/')) {
114
+ if (seg === '' || seg === '.') continue
115
+ if (seg === '..') {
116
+ parts.pop()
117
+ continue
118
+ }
119
+ parts.push(seg)
120
+ }
121
+ return '/' + parts.join('/')
122
+ }
123
+
124
+ /** Join a remote dir + entry name, honoring the dir's own separator style. */
125
+ function joinRemotePath(dir, name) {
126
+ const d = String(dir)
127
+ if (!d) return String(name)
128
+ if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
129
+ return d + (d.includes('\\') ? '\\' : '/') + String(name)
130
+ }
131
+
132
+ /** Create every level of a remote dir via SFTP (mkdir -p semantics), working
133
+ * for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
134
+ * existing or permission-denied level is skipped. */
135
+ async function mkdirRemoteDirs(sftp, dir) {
136
+ const parent = remoteDirname(dir)
137
+ const isWin = parent.includes('\\')
138
+ // Split off a Windows drive prefix (D:) so it is not treated as a segment.
139
+ const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
140
+ const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
141
+ let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
142
+ // Drive root (D:\) itself, best effort (usually exists).
143
+ if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
144
+ for (const s of segs) {
145
+ if (cur) {
146
+ if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
147
+ cur += s
148
+ } else {
149
+ cur = (isWin ? s + ':' : '/' + s)
150
+ }
151
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
152
+ }
153
+ }
154
+
155
+ /** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
156
+ function remoteDirname(p) {
157
+ const norm = normalizeRemotePath(p)
158
+ // Windows drive/UNC root: D:\ or \\server\share
159
+ if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
160
+ // Ensure a drive root ends with a backslash: D: → D:\
161
+ const m = norm.match(/^([a-zA-Z]:)$/)
162
+ return m ? m[1] + '\\' : norm
163
+ }
164
+ const sep = norm.includes('\\') ? '\\' : '/'
165
+ const idx = norm.lastIndexOf(sep)
166
+ if (idx <= 0) {
167
+ // Windows drive with a child (D:\Code) → D:\
168
+ const m = norm.match(/^([a-zA-Z]:)\\/)
169
+ return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
170
+ }
171
+ // A parent that is a Windows drive root also needs the trailing backslash.
172
+ const parent = norm.slice(0, idx)
173
+ if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
174
+ return parent
175
+ }
176
+
177
+ function truncate(s, max) {
178
+ if (s.length <= max) return s
179
+ return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
180
+ }
181
+
182
+ // ── local mirror of an OS remote workspace ────────────────────────────────
183
+ // Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
184
+ // so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
185
+ // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
186
+
187
+ const remotePathBase = (p) => {
188
+ const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
189
+ const base = norm.split(/[\\/]/).pop()
190
+ // Windows drive root (D:\) has no meaningful basename
191
+ if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
192
+ return base
193
+ }
194
+
195
+ /**
196
+ * Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
197
+ * path format the SFTP server understands.
198
+ *
199
+ * Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
200
+ * written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
201
+ * UNC paths are kept as-is (the server resolves `\\server\share` itself).
202
+ */
203
+ function toSftpPath(p) {
204
+ const norm = normalizeRemotePath(p)
205
+ const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
206
+ if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
207
+ return norm
208
+ }
209
+
210
+ /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
211
+ * own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
212
+ * data under the same root the harness uses means uninstalling / upgrading the
213
+ * app no longer leaves state behind in the home directory. */
214
+ function dshBase() {
215
+ const env = process.env.DSH_HOME
216
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
217
+ return path.join(homedir(), '.dsh')
218
+ }
219
+
220
+ /** Root holding every remote host's mirrors + the machine registry. */
221
+ function remoteWorkspacesRoot() {
222
+ return path.join(dshBase(), 'remote-workspaces')
223
+ }
224
+
225
+ /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
226
+ function copyDirSync(from, to) {
227
+ mkdirSync(to, { recursive: true })
228
+ for (const e of readdirSync(from, { withFileTypes: true })) {
229
+ const s = path.join(from, e.name)
230
+ const d = path.join(to, e.name)
231
+ if (e.isDirectory()) copyDirSync(s, d)
232
+ else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
233
+ }
234
+ }
235
+
236
+ /** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
237
+ * regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
238
+ * desktop install keeps its existing machines and mirrors. Idempotent; a
239
+ * rename across devices falls back to a copy. */
240
+ function migrateLegacyData() {
241
+ const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
242
+ const target = remoteWorkspacesRoot()
243
+ if (legacy === target || !existsSync(legacy) || existsSync(target)) return
244
+ try {
245
+ mkdirSync(path.dirname(target), { recursive: true })
246
+ try {
247
+ renameSync(legacy, target)
248
+ } catch (err) {
249
+ if (err.code !== 'EXDEV') throw err
250
+ copyDirSync(legacy, target)
251
+ }
252
+ } catch {
253
+ // Migration is best-effort: a fresh registry is created on next save.
254
+ }
255
+ }
256
+
257
+ /** 32-bit string hash rendered in base36 (short, collision-safe enough). */
258
+ function shortHash(s) {
259
+ let h = 0
260
+ for (let i = 0; i < s.length; i++) {
261
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0
262
+ }
263
+ return (h >>> 0).toString(36)
264
+ }
265
+
266
+ /** Stable local root for one remote host's mirrors */
267
+ function mirrorRootFor(host, user, port) {
268
+ const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
269
+ return path.join(remoteWorkspacesRoot(), tag)
270
+ }
271
+
272
+ /** Local mirror dir for a specific remote path (idempotent → returns same dir).
273
+ * Named after the remote directory's basename so the harness workspace label
274
+ * reads cleanly (e.g. .../project). When the plain basename dir is already
275
+ * taken by a DIFFERENT remote path on the same host, a short path-hash suffix
276
+ * is appended so mirrors never collide. */
277
+ function mirrorDirFor(remotePath, host, user, port) {
278
+ const base = remotePathBase(remotePath)
279
+ const root = mirrorRootFor(host, user, port)
280
+ const plain = path.join(root, base)
281
+ const norm = normalizeRemotePath(remotePath)
282
+ // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
283
+ try {
284
+ const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
285
+ if (meta.remotePath === norm) return plain
286
+ } catch {
287
+ /* no mirror yet → fall through */
288
+ }
289
+ // Plain dir is free → first mirror for this basename keeps the clean name.
290
+ // It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
291
+ if (!existsSync(plain)) return plain
292
+ return path.join(root, base + '-' + shortHash(norm))
293
+ }
294
+
295
+ /** Create the local mirror dir + a meta file describing its remote origin. */
296
+ function ensureMirror(remotePath, host, user, port) {
297
+ const dir = mirrorDirFor(remotePath, host, user, port)
298
+ mkdirSync(dir, { recursive: true })
299
+ writeFileSync(
300
+ path.join(dir, '.dsh-remote-meta.json'),
301
+ JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
302
+ )
303
+ return dir
304
+ }
305
+
306
+ /** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
307
+ async function mapLimit(items, limit, fn) {
308
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
309
+ let next = 0
310
+ const worker = async () => {
311
+ while (next < items.length) {
312
+ const i = next++
313
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
314
+ }
315
+ }
316
+ const workers = []
317
+ for (let w = 0; w < limitN; w++) workers.push(worker())
318
+ await Promise.all(workers)
319
+ }
320
+
321
+ /** Recursively sync remote → local mirror. Bounded by depth/files/size;
322
+ * skips files whose size+mtime already match locally. Returns counters. */
323
+ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
324
+ const entries = await sftp.readdir(remoteDir).then(
325
+ (list) => list,
326
+ () => [],
327
+ )
328
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
329
+
330
+ // Directories first (sequential, depth-bounded) so every file has a home.
331
+ for (const e of entries) {
332
+ const name = String(e.filename)
333
+ if (name === '.' || name === '..') continue
334
+ const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
335
+ if (!isDir) continue
336
+ if (maxDepth <= 0 || stats.files >= maxFiles) continue
337
+ const rp = joinRemotePath(remoteDir, name)
338
+ const lp = path.join(localDir, name)
339
+ mkdirSync(lp, { recursive: true })
340
+ const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
341
+ stats.dirs += sub.dirs + 1
342
+ stats.files += sub.files
343
+ stats.skippedUnchanged += sub.skippedUnchanged
344
+ stats.skippedLarge += sub.skippedLarge
345
+ stats.touched.push(...sub.touched)
346
+ if (stats.files >= maxFiles) break
347
+ }
348
+ if (stats.files >= maxFiles) return stats
349
+
350
+ // Files in one bounded-parallel sweep per directory level.
351
+ const fileEntries = entries.filter(
352
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
353
+ String(e.filename) !== '.' && String(e.filename) !== '..',
354
+ )
355
+ await mapLimit(fileEntries, 4, async (e) => {
356
+ if (stats.files >= maxFiles) return
357
+ const name = String(e.filename)
358
+ const rp = joinRemotePath(remoteDir, name)
359
+ const lp = path.join(localDir, name)
360
+ try {
361
+ const st = await sftp.stat(rp)
362
+ if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
363
+ const lpStat = existsSync(lp) ? statSync(lp) : null
364
+ const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
365
+ if (unchanged) { stats.skippedUnchanged++; return }
366
+ const buf = await sftp.readFile(rp)
367
+ writeFileSync(lp, buf)
368
+ try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
369
+ stats.files++
370
+ stats.touched.push(rp)
371
+ } catch {
372
+ /* skip unreadable */
373
+ }
374
+ })
375
+ return stats
376
+ }
377
+
378
+ /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
379
+ * skips files whose remote copy already matches (same size, remote no older). */
380
+ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
381
+ const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
382
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
383
+
384
+ for (const e of entries) {
385
+ if (!e.isDirectory()) continue
386
+ if (stats.files >= maxFiles) break
387
+ const rp = joinRemotePath(remoteDir, e.name)
388
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
389
+ stats.dirs++
390
+ const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
391
+ stats.files += sub.files
392
+ stats.skippedUnchanged += sub.skippedUnchanged
393
+ stats.skippedLarge += sub.skippedLarge
394
+ stats.pushed.push(...sub.pushed)
395
+ if (stats.files >= maxFiles) break
396
+ }
397
+ if (stats.files >= maxFiles) return stats
398
+
399
+ const fileEntries = entries.filter((e) => !e.isDirectory())
400
+ await mapLimit(fileEntries, 4, async (e) => {
401
+ if (stats.files >= maxFiles) return
402
+ const lp = path.join(localDir, e.name)
403
+ const rp = joinRemotePath(remoteDir, e.name)
404
+ try {
405
+ const lstat = statSync(lp)
406
+ if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
407
+ let rstat = null
408
+ try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
409
+ const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
410
+ if (unchanged) { stats.skippedUnchanged++; return }
411
+ const buf = readFileSync(lp)
412
+ await sftp.writeFile(rp, buf)
413
+ stats.files++
414
+ stats.pushed.push(rp)
415
+ } catch {
416
+ /* skip unreadable / unwritable */
417
+ }
418
+ })
419
+ return stats
420
+ }
421
+
422
+ // ── persistent multi-machine registry ─────────────────────────────────────
423
+ const MACHINES_FILE = 'machines.json'
424
+ const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
425
+ function loadMachines() {
426
+ try {
427
+ const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
428
+ if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
429
+ } catch {}
430
+ return { list: [], currentId: null }
431
+ }
432
+ function saveMachines(list, currentId) {
433
+ try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
434
+ writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
435
+ }
436
+ function sanitizeMachine(m) {
437
+ if (!m) return m
438
+ const { password, ...rest } = m
439
+ return { ...rest, passwordSet: !!(m.password && m.password.length) }
440
+ }
441
+ /** Apply a machine's fields onto the live config object (pool + tools read it). */
442
+ function applyMachine(config, m) {
443
+ if (!m) return
444
+ config.host = m.host
445
+ config.port = Number(m.port) || 22
446
+ config.username = m.username || ''
447
+ config.password = m.password || ''
448
+ config.privateKeyPath = m.privateKeyPath || ''
449
+ config.passphrase = m.passphrase || ''
450
+ config.workspace = m.workspace || (config.workspace || '')
451
+ }
452
+ function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
453
+
454
+ // ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
455
+ const KNOWN_HOSTS_FILE = 'known_hosts.json'
456
+ const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
457
+
458
+ /** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
459
+ function loadKnownHosts() {
460
+ try {
461
+ const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
462
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
463
+ } catch {}
464
+ return {}
465
+ }
466
+
467
+ /** Extract the host-key algorithm name from a raw SSH host-key blob
468
+ * (SSH wire format: `uint32 len` + algorithm string + key data). */
469
+ function blobAlgorithm(blob) {
470
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
471
+ try {
472
+ const len = blob.readUInt32BE(0)
473
+ return blob.toString('utf8', 4, 4 + len)
474
+ } catch {
475
+ return ''
476
+ }
477
+ }
478
+
479
+ /** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
480
+ * in a known_hosts file (without the `SHA256:` prefix).
481
+ *
482
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
483
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
484
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
485
+ * argument must be of type string or an instance of Buffer, TypedArray, or
486
+ * DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
487
+ * v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
488
+ * shapes defensively. */
489
+ function keyFingerprint(key) {
490
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
491
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
492
+ return createHash('sha256').update(blob).digest('base64')
493
+ }
494
+
495
+ /** Build an ssh2 `hostVerifier` bound to the current config. Returns
496
+ * `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
497
+ * - `off` → always accepts;
498
+ * - `accept-new` → records a host's key on first connect, rejects any CHANGE
499
+ * afterwards (classic TOFU / MITM detection);
500
+ * - `verify` → same, plus rejects hosts never recorded before. */
501
+ function createHostKeyGuard(config) {
502
+ const id = () => `${config.host}:${config.port}`
503
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
504
+ ? config.hostKeyMode
505
+ : 'accept-new'
506
+ const guard = {
507
+ mode,
508
+ lastError: null,
509
+ knownHosts: loadKnownHosts,
510
+ forgetHost() {
511
+ const kh = loadKnownHosts()
512
+ delete kh[id()]
513
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
514
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
515
+ },
516
+ verifier(key) {
517
+ if (mode === 'off') return true
518
+ const fp = keyFingerprint(key)
519
+ const kh = loadKnownHosts()
520
+ const stored = kh[id()]
521
+ if (stored) {
522
+ if (stored.fingerprint === fp) return true
523
+ guard.lastError =
524
+ `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
525
+ 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
526
+ return false
527
+ }
528
+ if (mode === 'verify') {
529
+ guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
530
+ return false
531
+ }
532
+ kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
533
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
534
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
535
+ return true
536
+ },
537
+ }
538
+ return guard
539
+ }
540
+
541
+ /** Whether the current target's key has been recorded/trusted before. */
542
+ function isHostKeyKnown(host, port) {
543
+ return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
544
+ }
545
+
546
+ // ── SSH pool (key OR password) ──────────────────────────────────────────────
547
+
548
+ class SshPool {
549
+ constructor(config) {
550
+ this.config = config
551
+ this.client = null
552
+ this.connecting = null
553
+ // Generational token: bumped on every target change / close so a stale
554
+ // in-flight connect can never hand this pool a connection to an old host.
555
+ this.epoch = 0
556
+ }
557
+
558
+ resolveKeyPath() {
559
+ const p = this.config.privateKeyPath
560
+ if (!p) return ''
561
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
562
+ return p
563
+ }
564
+
565
+ /** Configure (and reconnect with) a new target. Returns this pool for chaining. */
566
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
567
+ if (host !== undefined) this.config.host = String(host)
568
+ if (port !== undefined && Number(port)) this.config.port = Number(port)
569
+ if (username !== undefined) this.config.username = String(username)
570
+ if (password !== undefined && password !== null) this.config.password = String(password)
571
+ if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
572
+ if (passphrase !== undefined) this.config.passphrase = String(passphrase)
573
+ if (workspace !== undefined) this.config.workspace = String(workspace)
574
+ this.close()
575
+ return this
576
+ }
577
+
578
+ connect() {
579
+ if (this.client) return Promise.resolve(this.client)
580
+ if (this.connecting) return this.connecting
581
+ const epoch = this.epoch
582
+ const pending = this._doConnect(epoch)
583
+ this.connecting = pending
584
+ const clear = () => {
585
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
586
+ }
587
+ // Keep the chain clean in both directions: the raw pending is what callers
588
+ // await (they still see rejections); this side just clears the slot.
589
+ pending.then(clear, clear)
590
+ return pending
591
+ }
592
+
593
+ _doConnect(epoch) {
594
+ return new Promise((resolve, reject) => {
595
+ const client = new Client()
596
+ let settled = false
597
+ // Fresh host-key guard per connect attempt so lastError is never stale.
598
+ const guard = createHostKeyGuard(this.config)
599
+ const isCurrent = () => this.epoch === epoch
600
+ const fail = (err) => {
601
+ if (settled) return
602
+ settled = true
603
+ if (isCurrent() && this.client === client) this.client = null
604
+ reject(guard.lastError ? new Error(guard.lastError) : err)
605
+ }
606
+ client.on('ready', () => {
607
+ if (settled) return
608
+ settled = true
609
+ if (!isCurrent()) {
610
+ // The target changed while we were connecting — never adopt this
611
+ // client, and make sure the caller learns the connect is void.
612
+ try { client.end() } catch {}
613
+ reject(new Error('ssh target changed during connect'))
614
+ return
615
+ }
616
+ this.client = client
617
+ resolve(client)
618
+ })
619
+ client.on('error', fail)
620
+ client.on('close', () => {
621
+ if (isCurrent() && this.client === client) this.client = null
622
+ fail(new Error('ssh connection closed'))
623
+ })
624
+
625
+ const opts = {
626
+ host: this.config.host,
627
+ port: this.config.port,
628
+ username: this.config.username,
629
+ readyTimeout: this.config.connectTimeoutMs,
630
+ keepaliveInterval: 15000,
631
+ keepaliveCountMax: 3,
632
+ hostVerifier: (key) => guard.verifier(key),
633
+ }
634
+ if (this.config.password) {
635
+ opts.password = this.config.password
636
+ } else {
637
+ const keyPath = this.resolveKeyPath()
638
+ if (!keyPath) {
639
+ return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
640
+ }
641
+ let key
642
+ try {
643
+ key = readFileSync(keyPath)
644
+ } catch (err) {
645
+ return fail(new Error(`cannot read private key "${keyPath}": ${err && err.message}`))
646
+ }
647
+ opts.privateKey = key
648
+ opts.passphrase = this.config.passphrase || undefined
649
+ }
650
+ client.connect(opts)
651
+ })
652
+ }
653
+
654
+ /** Run one remote command; resolves { code, signal, stdout, stderr }. */
655
+ exec(command, timeoutMs) {
656
+ return this.connect().then(
657
+ (client) =>
658
+ new Promise((resolve, reject) => {
659
+ client.exec(command, (err, stream) => {
660
+ if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
661
+ let stdout = ''
662
+ let stderr = ''
663
+ let settled = false
664
+ let exitCode = null
665
+ let exitSignal = null
666
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
667
+ const settle = () => {
668
+ if (settled) return
669
+ settled = true
670
+ clearTimeout(timer)
671
+ resolve({
672
+ code: exitCode,
673
+ signal: exitSignal,
674
+ stdout: truncate(stdout, this.config.maxOutputChars),
675
+ stderr: truncate(stderr, this.config.maxOutputChars),
676
+ })
677
+ }
678
+ const timer = setTimeout(() => {
679
+ if (settled) return
680
+ exitCode = -1
681
+ exitSignal = 'TIMEOUT'
682
+ // Kill the remote command (SIGTERM) rather than just dropping the
683
+ // channel, so a runaway process cannot keep running and holding
684
+ // the SSH connection after we've given up on its output. The hard
685
+ // close below frees the channel even if the process ignores it.
686
+ try {
687
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
688
+ } catch {}
689
+ const hardClose = setTimeout(() => {
690
+ try { stream.close() } catch {}
691
+ }, 800)
692
+ if (typeof hardClose.unref === 'function') hardClose.unref()
693
+ settle()
694
+ }, timeoutMs || this.config.commandTimeoutMs)
695
+ stream.on('close', (code, signal) => {
696
+ if (settled) return
697
+ exitCode = code
698
+ exitSignal = signal
699
+ settle()
700
+ })
701
+ stream.on('data', (d) => {
702
+ if (stdout.length < hardCap) stdout += d
703
+ })
704
+ stream.stderr.on('data', (d) => {
705
+ if (stderr.length < hardCap) stderr += d
706
+ })
707
+ stream.on('error', (e) => {
708
+ if (settled) return
709
+ settled = true
710
+ clearTimeout(timer)
711
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
712
+ })
713
+ })
714
+ }),
715
+ )
716
+ }
717
+
718
+ /** Resolve a promisified SFTP client for binary file transfers (sync).
719
+ * All path arguments are normalized via toSftpPath() so Windows remotes
720
+ * (drive-letter paths) work too. */
721
+ sftp() {
722
+ return this.connect().then(
723
+ (client) =>
724
+ new Promise((resolve, reject) => {
725
+ client.sftp((err, sftp) => {
726
+ if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
727
+ // Wrap a raw SFTP method so a stuck server can never hang a tool call.
728
+ const withTimeout = (fn) => (...args) =>
729
+ new Promise((r2, j2) => {
730
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
731
+ const done = (e, v) => {
732
+ clearTimeout(timer)
733
+ e ? j2(e) : r2(v)
734
+ }
735
+ try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
736
+ })
737
+ const P = (p) => toSftpPath(p)
738
+ resolve({
739
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
740
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
741
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
742
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
743
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
744
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
745
+ })
746
+ })
747
+ }),
748
+ )
749
+ }
750
+
751
+ close() {
752
+ // Bump the epoch so any in-flight connect is orphaned, drop the pending
753
+ // promise (its rejection becomes nobody's problem — swallow it), and end
754
+ // the live client if there is one.
755
+ this.epoch++
756
+ const client = this.client
757
+ this.client = null
758
+ const pending = this.connecting
759
+ this.connecting = null
760
+ if (pending && typeof pending.catch === 'function') {
761
+ try { pending.catch(() => {}) } catch {}
762
+ }
763
+ if (client) {
764
+ try {
765
+ client.end()
766
+ } catch {}
767
+ }
768
+ }
769
+ }
770
+
771
+ // ── helper: run + text ─────────────────────────────────────────────────────
772
+
773
+ export async function apply(ctx, config) {
774
+ const pool = new SshPool(config)
775
+ ctx.effect(() => () => pool.close(), 'dsh-remote.close')
776
+
777
+ // Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
778
+ // location so existing machines + mirrors keep working after the move.
779
+ migrateLegacyData()
780
+
781
+ // ── machine registry (multi-host) ─────────────────────────────────────────
782
+ const store = loadMachines()
783
+ const machines = store.list
784
+ const machineIndex = (id) => machines.findIndex((m) => m.id === id)
785
+ const currentMachine = () => {
786
+ if (store.currentId) {
787
+ const i = machineIndex(store.currentId)
788
+ if (i >= 0) return machines[i]
789
+ }
790
+ // fall back to config-derived default (host set via cordis config)
791
+ if (config.host) return { id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase }
792
+ return null
793
+ }
794
+ const setCurrent = (id) => {
795
+ const i = machineIndex(id)
796
+ if (i < 0) return false
797
+ store.currentId = id
798
+ saveMachines(machines, id)
799
+ applyMachine(config, machines[i])
800
+ pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
801
+ return true
802
+ }
803
+ // If no stored current, adopt a CLI-provided default as the active machine.
804
+ {
805
+ const cur = currentMachine()
806
+ if (cur && cur.host) applyMachine(config, cur)
807
+ }
808
+
809
+ /** Set the active remote workspace AND persist it on the current machine so
810
+ * it survives restarts (previously a tool-set workspace reset on reload). */
811
+ const persistWorkspace = (p) => {
812
+ config.workspace = p
813
+ if (store.currentId) {
814
+ const i = machineIndex(store.currentId)
815
+ if (i >= 0) {
816
+ machines[i].workspace = p
817
+ saveMachines(machines, store.currentId)
818
+ }
819
+ }
820
+ }
821
+
822
+ const run = async (cmd, opts = {}) => {
823
+ const res = await pool.exec(cmd, opts.timeoutMs)
824
+ const parts = []
825
+ if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
826
+ if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
827
+ if (!parts.length) parts.push('(no output)')
828
+ let text = parts.join('\n')
829
+ if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
830
+ else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
831
+ return text
832
+ }
833
+
834
+ /** Structured-listing of a remote dir: name + usable type.
835
+ *
836
+ * Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
837
+ * macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
838
+ * shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
839
+ * resolved with one bounded lstat (if it fails they degrade to files). */
840
+ const listDirStructured = async (p, timeoutMs) => {
841
+ const target = normalizeRemotePath(p || '/')
842
+ let sftp
843
+ try {
844
+ sftp = await pool.sftp()
845
+ } catch (err) {
846
+ throw new Error('browse failed: ' + ((err && err.message) || err))
847
+ }
848
+ let list = []
849
+ try {
850
+ list = await sftp.readdir(target)
851
+ } catch (err) {
852
+ throw new Error('browse failed: ' + ((err && err.message) || err))
853
+ }
854
+ const items = []
855
+ const symIdx = []
856
+ for (const e of list) {
857
+ const name = String(e.filename)
858
+ if (name === '.' || name === '..' || !name) continue
859
+ const a = e.attrs || {}
860
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
861
+ items.push({ type: 'symlink', name })
862
+ symIdx.push(items.length - 1)
863
+ } else if (a.isDirectory && a.isDirectory()) {
864
+ items.push({ type: 'dir', name })
865
+ } else {
866
+ items.push({ type: 'file', name })
867
+ }
868
+ }
869
+ // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
870
+ if (symIdx.length) {
871
+ await Promise.all(symIdx.map(async (i) => {
872
+ const full = joinRemotePath(target, items[i].name)
873
+ try {
874
+ const st = await sftp.lstat(full)
875
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
876
+ } catch { /* degrade to file */ }
877
+ }))
878
+ }
879
+ return { path: target, items }
880
+ }
881
+
882
+ /** Verify a remote path is an existing directory, via SFTP stat (works on
883
+ * any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
884
+ * path is missing, is a file, or the stat errors. */
885
+ const isRemoteDir = async (p) => {
886
+ const target = normalizeRemotePath(p)
887
+ try {
888
+ const sftp = await pool.sftp()
889
+ const st = await sftp.stat(target)
890
+ return !!(st && st.isDirectory && st.isDirectory())
891
+ } catch {
892
+ return false
893
+ }
894
+ }
895
+
896
+ // ── remote workspace state ────────────────────────────────────────────────
897
+
898
+ const wsPath = () => (config.workspace || '').trim()
899
+ const status = () => ({
900
+ host: config.host,
901
+ port: config.port,
902
+ username: config.username,
903
+ connected: !!pool.client,
904
+ workspace: wsPath(),
905
+ localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
906
+ currentId: store.currentId || null,
907
+ machines: machines.map(sanitizeMachine),
908
+ hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
909
+ hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
910
+ })
911
+
912
+ // ── tools ─────────────────────────────────────────────────────────────────
913
+
914
+ const renderErr = (err) => ({
915
+ kind: 'error',
916
+ text: String((err && err.message) || err),
917
+ })
918
+
919
+ const tools = [
920
+ defineTool({
921
+ name: 'rw_info',
922
+ description:
923
+ 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when an rw_* call fails to check connectivity.',
924
+ parameters: {},
925
+ output: {
926
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
927
+ render: (_a, v) => [{ type: 'text', text: v.text }],
928
+ },
929
+ async execute() {
930
+ const s = status()
931
+ const lines = [
932
+ `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
933
+ `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
934
+ `Local mirror: ${s.localMirror || '(none)'}`,
935
+ `Connected: ${s.connected ? 'yes' : 'no'}`,
936
+ '',
937
+ ]
938
+ if (s.host && s.workspace) {
939
+ try {
940
+ // `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
941
+ const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
942
+ if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
943
+ else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
944
+ else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
945
+ } catch (err) {
946
+ lines.push('Ping: FAILED — ' + ((err && err.message) || err))
947
+ }
948
+ } else {
949
+ lines.push('No host + workspace configured — call rw_connect with a host to get started.')
950
+ }
951
+ return { text: lines.join('\n') }
952
+ },
953
+ }),
954
+
955
+ defineTool({
956
+ name: 'rw_connect',
957
+ description:
958
+ 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
959
+ parameters: {
960
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
961
+ username: { type: 'string', description: 'SSH user (default from config or root)' },
962
+ port: { type: 'integer', description: 'SSH port (default 22)' },
963
+ password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
964
+ privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
965
+ },
966
+ output: {
967
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
968
+ render: (_a, a) => [{ type: 'text', text: a.text }],
969
+ },
970
+ async execute(args) {
971
+ const host = String(args.host || '').trim()
972
+ if (!host) throw new Error('rw_connect: host is required')
973
+ pool.setTarget({
974
+ host,
975
+ username: args.username || config.username || 'root',
976
+ port: args.port || undefined,
977
+ password: args.password !== undefined ? args.password : undefined,
978
+ privateKeyPath: args.privateKeyPath || undefined,
979
+ })
980
+ try {
981
+ const res = await pool.exec('echo ok', 8000)
982
+ if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
983
+ return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
984
+ } catch (err) {
985
+ throw err
986
+ }
987
+ },
988
+ }),
989
+
990
+ defineTool({
991
+ name: 'rw_pick_workspace',
992
+ description:
993
+ 'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
994
+ parameters: {
995
+ path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
996
+ },
997
+ output: {
998
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
999
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1000
+ },
1001
+ async execute(args) {
1002
+ const p = normalizeRemotePath(String(args.path || ''))
1003
+ if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1004
+ const ok = await isRemoteDir(p)
1005
+ if (!ok) return { text: `not a directory (or missing) on ${p}` }
1006
+ persistWorkspace(p)
1007
+ const local = ensureMirror(p, config.host, config.username, config.port)
1008
+ return {
1009
+ text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1010
+ }
1011
+ },
1012
+ }),
1013
+
1014
+ defineTool({
1015
+ name: 'rw_sync',
1016
+ description:
1017
+ 'Download the current remote workspace into its local mirror directory over SFTP (bounded). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them.',
1018
+ parameters: {
1019
+ depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
1020
+ maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
1021
+ },
1022
+ output: {
1023
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1024
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1025
+ },
1026
+ async execute(args) {
1027
+ const p = wsPath()
1028
+ if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
1029
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
1030
+ mkdirSync(local, { recursive: true })
1031
+ const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1032
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1033
+ let sftp
1034
+ try {
1035
+ sftp = await pool.sftp()
1036
+ } catch (err) {
1037
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
1038
+ }
1039
+ const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
1040
+ let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
1041
+ if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
1042
+ if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1043
+ return { text }
1044
+ },
1045
+ }),
1046
+
1047
+ defineTool({
1048
+ name: 'rw_push',
1049
+ description:
1050
+ 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded). Use after editing files in the local mirror so the remote reflects your changes (bidirectional sync).',
1051
+ parameters: {
1052
+ maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
1053
+ },
1054
+ output: {
1055
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1056
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1057
+ },
1058
+ async execute(args) {
1059
+ const p = wsPath()
1060
+ if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
1061
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
1062
+ if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1063
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1064
+ let sftp
1065
+ try {
1066
+ sftp = await pool.sftp()
1067
+ } catch (err) {
1068
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
1069
+ }
1070
+ const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
1071
+ let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
1072
+ if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
1073
+ if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1074
+ return { text }
1075
+ },
1076
+ }),
1077
+
1078
+ defineTool({
1079
+ name: 'rw_list_dir',
1080
+ description:
1081
+ 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
1082
+ parameters: {
1083
+ path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
1084
+ },
1085
+ output: {
1086
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1087
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1088
+ },
1089
+ async execute(args) {
1090
+ const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
1091
+ if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1092
+ // SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
1093
+ // PowerShell) unlike `ls -la`.
1094
+ let list
1095
+ try {
1096
+ const sftp = await pool.sftp()
1097
+ list = await sftp.readdir(p)
1098
+ } catch (err) {
1099
+ throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1100
+ }
1101
+ const lines = list
1102
+ .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1103
+ .map((e) => {
1104
+ const a = e.attrs || {}
1105
+ const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1106
+ const size = typeof a.size === 'number' ? String(a.size) : '?'
1107
+ return `${type} ${size.padStart(10)} ${String(e.filename)}`
1108
+ })
1109
+ return { text: lines.length ? lines.join('\n') : '(empty directory)' }
1110
+ },
1111
+ }),
1112
+
1113
+ defineTool({
1114
+ name: 'rw_read_file',
1115
+ description:
1116
+ 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
1117
+ parameters: {
1118
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1119
+ startLine: { type: 'integer', description: '1-based first line (default 1)' },
1120
+ endLine: { type: 'integer', description: '1-based last line (inclusive)' },
1121
+ maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
1122
+ },
1123
+ output: {
1124
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1125
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1126
+ },
1127
+ async execute(args) {
1128
+ const p = normalizeRemotePath(String(args.path || ''))
1129
+ if (!p) throw new Error('rw_read_file: path is required')
1130
+ const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1131
+ let from = Math.max(Number(args.startLine) || 1, 1)
1132
+ let to = Number(args.endLine) || 0
1133
+ if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
1134
+ // SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
1135
+ // / PowerShell, no POSIX sed) work too.
1136
+ let buf
1137
+ try {
1138
+ const sftp = await pool.sftp()
1139
+ buf = await sftp.readFile(p)
1140
+ } catch (err) {
1141
+ throw new Error('rw_read_file: ' + ((err && err.message) || err))
1142
+ }
1143
+ const content = buf.toString('utf8').replace(/\r\n/g, '\n')
1144
+ const allLines = content.split('\n')
1145
+ const page = allLines.slice(from - 1, to)
1146
+ const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1147
+ let text = numbered === '' ? '(empty or out of range)' : numbered
1148
+ if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
1149
+ return { text }
1150
+ },
1151
+ }),
1152
+
1153
+ defineTool({
1154
+ name: 'rw_exec',
1155
+ description:
1156
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere.',
1157
+ parameters: {
1158
+ command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1159
+ cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
1160
+ },
1161
+ output: {
1162
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1163
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1164
+ },
1165
+ async execute(args) {
1166
+ const cmd = String(args.command || '')
1167
+ if (!cmd) throw new Error('rw_exec: command is required')
1168
+ const ws = wsPath()
1169
+ const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1170
+ // Windows remotes: no POSIX cd/single-quotes — let the command run as-is
1171
+ // (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
1172
+ const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1173
+ return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
1174
+ },
1175
+ }),
1176
+
1177
+ defineTool({
1178
+ name: 'rw_write_file',
1179
+ description:
1180
+ 'Write text to a file on the remote host (creating parent directories if needed). Path is absolute. Use this to create or overwrite a remote file directly, instead of round-tripping through a local mirror.',
1181
+ parameters: {
1182
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1183
+ content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
1184
+ mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
1185
+ },
1186
+ output: {
1187
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1188
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
1189
+ },
1190
+ async execute(args) {
1191
+ const p = normalizeRemotePath(String(args.path || ''))
1192
+ if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1193
+ const content = String(args.content == null ? '' : args.content)
1194
+ let sftp
1195
+ try {
1196
+ sftp = await pool.sftp()
1197
+ } catch (err) {
1198
+ throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
1199
+ }
1200
+ const mkdir = args.mkdir !== false
1201
+ if (mkdir) {
1202
+ // create each level from the root so mkdir -p semantics survive even
1203
+ // when intermediate privileged-parent dirs don't allow create (best effort)
1204
+ await mkdirRemoteDirs(sftp, p)
1205
+ }
1206
+ const buf = Buffer.from(content, 'utf8')
1207
+ await sftp.writeFile(p, buf)
1208
+ const bytes = Buffer.byteLength(content, 'utf8')
1209
+ return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
1210
+ },
1211
+ }),
1212
+
1213
+ defineTool({
1214
+ name: 'rw_search',
1215
+ description:
1216
+ 'Search remote files for a pattern (grep, recursive, case-insensitive by default). Returns matching file:line rows; output is capped. Use instead of rw_exec grep when you want a bounded, portable search.',
1217
+ parameters: {
1218
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1219
+ path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1220
+ glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
1221
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1222
+ },
1223
+ output: {
1224
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1225
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1226
+ },
1227
+ async execute(args) {
1228
+ const pattern = String(args.pattern || '')
1229
+ if (!pattern) throw new Error('rw_search: pattern is required')
1230
+ const ws = wsPath()
1231
+ const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1232
+ if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1233
+ if (dir.includes('\\')) {
1234
+ // Windows remotes have no POSIX find/grep; the search would need a
1235
+ // recursive SFTP walk. Give an actionable hint instead of a broken cmd.
1236
+ return { text: `rw_search does not support Windows-remote paths yet (${dir}). Use rw_exec with a PowerShell one-liner, e.g.: powershell -NoProfile -Command "Get-ChildItem -Recurse -File | Select-String -Pattern '${pattern}'"` }
1237
+ }
1238
+ const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1239
+ const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1240
+ // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
1241
+ // differ on BSD, and GNU-only --include is avoided in favor of find -name.
1242
+ const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
1243
+ return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
1244
+ },
1245
+ }),
1246
+
1247
+ defineTool({
1248
+ name: 'rw_download',
1249
+ description:
1250
+ 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text.',
1251
+ parameters: {
1252
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1253
+ localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1254
+ },
1255
+ output: {
1256
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1257
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
1258
+ },
1259
+ async execute(args) {
1260
+ const p = normalizeRemotePath(String(args.path || ''))
1261
+ if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1262
+ let sftp
1263
+ try {
1264
+ sftp = await pool.sftp()
1265
+ } catch (err) {
1266
+ throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
1267
+ }
1268
+ let local
1269
+ if (args.localPath) {
1270
+ local = path.resolve(String(args.localPath))
1271
+ } else {
1272
+ const ws = wsPath()
1273
+ if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1274
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
1275
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1276
+ local = path.join(base, rel)
1277
+ }
1278
+ mkdirSync(path.dirname(local), { recursive: true })
1279
+ const buf = await sftp.readFile(p)
1280
+ writeFileSync(local, buf)
1281
+ const bytes = buf.byteLength
1282
+ return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1283
+ },
1284
+ }),
1285
+
1286
+ defineTool({
1287
+ name: 'rw_upload',
1288
+ description:
1289
+ 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1290
+ parameters: {
1291
+ localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1292
+ path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1293
+ },
1294
+ output: {
1295
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1296
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
1297
+ },
1298
+ async execute(args) {
1299
+ const rp = normalizeRemotePath(String(args.path || ''))
1300
+ const lp = String(args.localPath || '')
1301
+ if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1302
+ if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1303
+ let sftp
1304
+ try {
1305
+ sftp = await pool.sftp()
1306
+ } catch (err) {
1307
+ throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1308
+ }
1309
+ await mkdirRemoteDirs(sftp, rp)
1310
+ const buf = readFileSync(lp)
1311
+ await sftp.writeFile(rp, buf)
1312
+ const bytes = buf.byteLength
1313
+ return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
1314
+ },
1315
+ }),
1316
+
1317
+ defineTool({
1318
+ name: 'rw_disconnect',
1319
+ description:
1320
+ 'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
1321
+ parameters: {},
1322
+ output: {
1323
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
1324
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1325
+ },
1326
+ async execute() {
1327
+ pool.close()
1328
+ return { ok: true, text: 'disconnected' }
1329
+ },
1330
+ }),
1331
+ ]
1332
+
1333
+ for (const t of tools) {
1334
+ ctx.tools.register(t)
1335
+ }
1336
+
1337
+ // ── system-prompt injection: the current remote workspace ─────────────────
1338
+ ctx.systemPrompt.section({
1339
+ name: 'dsh-remote',
1340
+ order: 88,
1341
+ text: () => {
1342
+ const w = wsPath()
1343
+ if (!w || !config.host) return ''
1344
+ return (
1345
+ '## Remote workspace\n' +
1346
+ `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
1347
+ 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_exec / rw_search) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
1348
+ )
1349
+ },
1350
+ })
1351
+
1352
+ // ── slash command: /remote reports status + connection hints ──────────────
1353
+ const commands = ctx.get('commands')
1354
+ if (commands !== undefined) {
1355
+ commands.register({
1356
+ name: 'remote',
1357
+ description: 'Show the current remote workspace / connection status and how to use remote tools.',
1358
+ handler: (invocation) => {
1359
+ const s = status()
1360
+ return {
1361
+ kind: 'success',
1362
+ text:
1363
+ `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
1364
+ `Remote workspace: ${s.workspace || '(none)'}\n` +
1365
+ `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1366
+ (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1367
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
1368
+ (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
1369
+ }
1370
+ },
1371
+ })
1372
+ commands.register({
1373
+ name: 'remote-forget-key',
1374
+ description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1375
+ handler: () => {
1376
+ createHostKeyGuard(config).forgetHost()
1377
+ return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1378
+ },
1379
+ })
1380
+ }
1381
+
1382
+ // ── JSON endpoints for settings UI ─────────────────────────────────────────
1383
+ const webServer = ctx.get('webServer')
1384
+ if (webServer === undefined) return
1385
+
1386
+ const sendJson = (res, status, body) => {
1387
+ res.statusCode = status
1388
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
1389
+ res.end(JSON.stringify(body))
1390
+ }
1391
+ // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1392
+ // unbounded memory on a local-only route.
1393
+ const MAX_BODY_BYTES = 1024 * 1024
1394
+ const readBody = (req) =>
1395
+ new Promise((resolve) => {
1396
+ const chunks = []
1397
+ let total = 0
1398
+ req.on('data', (c) => {
1399
+ total += c.length
1400
+ if (total > MAX_BODY_BYTES) {
1401
+ req.removeAllListeners('data')
1402
+ resolve('{}')
1403
+ return
1404
+ }
1405
+ chunks.push(c)
1406
+ })
1407
+ req.on('end', () => resolve(chunks.join('')))
1408
+ })
1409
+
1410
+ // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1411
+ // 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
1412
+ // loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
1413
+ // 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
1414
+ // native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
1415
+ const PICK_TIMEOUT_MS = 120000
1416
+ const runPick = (bin, args) =>
1417
+ new Promise((resolve, reject) => {
1418
+ execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1419
+ if (err) {
1420
+ const code = err.code
1421
+ const msg = String(stderr || '')
1422
+ // 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
1423
+ if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1424
+ if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1425
+ if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1426
+ return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1427
+ }
1428
+ const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1429
+ resolve(p ? { path: p } : { cancelled: true })
1430
+ })
1431
+ })
1432
+ const pickLocalNative = async () => {
1433
+ const platform = process.platform
1434
+ if (platform === 'darwin') {
1435
+ return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1436
+ }
1437
+ if (platform === 'linux') {
1438
+ try {
1439
+ return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1440
+ } catch (err) {
1441
+ if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1442
+ throw err
1443
+ }
1444
+ }
1445
+ throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1446
+ }
1447
+
1448
+ const routes = [
1449
+ {
1450
+ kind: 'exact',
1451
+ path: '/dsh-remote/status',
1452
+ handler: async (req, res) => {
1453
+ if (req.method === 'GET') return sendJson(res, 200, status())
1454
+ sendJson(res, 405, { error: 'method not allowed' })
1455
+ },
1456
+ },
1457
+ {
1458
+ kind: 'exact',
1459
+ path: '/dsh-remote/connect',
1460
+ handler: async (req, res) => {
1461
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1462
+ try {
1463
+ const payload = JSON.parse((await readBody(req)) || '{}')
1464
+ pool.setTarget({
1465
+ host: payload.host,
1466
+ port: payload.port,
1467
+ username: payload.username,
1468
+ password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
1469
+ privateKeyPath: payload.privateKeyPath,
1470
+ workspace: payload.workspace,
1471
+ })
1472
+ await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
1473
+ return sendJson(res, 200, { ok: true, ...status() })
1474
+ } catch (err) {
1475
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1476
+ }
1477
+ },
1478
+ },
1479
+ {
1480
+ kind: 'exact',
1481
+ path: '/dsh-remote/ls',
1482
+ handler: async (req, res) => {
1483
+ try {
1484
+ // Proper URL parsing so `+` in a path decodes to a literal space
1485
+ // (the old regex missed it).
1486
+ const q = new URL(req.url, 'http://localhost').searchParams
1487
+ const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
1488
+ const out = await listDirStructured(p || '/')
1489
+ return sendJson(res, 200, { path: p, items: out.items })
1490
+ } catch (err) {
1491
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
1492
+ }
1493
+ },
1494
+ },
1495
+ {
1496
+ // Read a remote file over SFTP (live, not the local mirror) for the
1497
+ // sidebar's remote file viewer. Text files come back as UTF-8 content
1498
+ // (CRLF→LF), binary files are reported as binary with size+head bytes
1499
+ // (base64) so the client can sniff the format without loading the
1500
+ // whole file. `maxBytes` caps how much text is returned.
1501
+ kind: 'exact',
1502
+ path: '/dsh-remote/read',
1503
+ handler: async (req, res) => {
1504
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1505
+ try {
1506
+ const q = new URL(req.url, 'http://localhost').searchParams
1507
+ let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
1508
+ if (req.method === 'POST') {
1509
+ const body = JSON.parse((await readBody(req)) || '{}')
1510
+ if (!p) p = String(body.path || '')
1511
+ }
1512
+ if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
1513
+ const target = normalizeRemotePath(p)
1514
+ const maxBytes = Math.min(Math.max(Number(q.get('maxBytes')) || 256 * 1024, 1024), 2 * 1024 * 1024)
1515
+ let sftp
1516
+ try {
1517
+ sftp = await pool.sftp()
1518
+ } catch (err) {
1519
+ return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
1520
+ }
1521
+ let buf
1522
+ try {
1523
+ buf = await sftp.readFile(target)
1524
+ } catch (err) {
1525
+ return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1526
+ }
1527
+ // Binary sniff: NUL byte in the first 8KB (or the whole file when
1528
+ // smaller) marks the file binary — matches the sidebar's convention.
1529
+ const headN = Math.min(buf.length, 8192)
1530
+ let binary = false
1531
+ for (let i = 0; i < headN; i++) {
1532
+ if (buf[i] === 0) { binary = true; break }
1533
+ }
1534
+ if (binary) {
1535
+ return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
1536
+ }
1537
+ let content = buf.toString('utf8').replace(/\r\n/g, '\n')
1538
+ const truncated = content.length > maxBytes
1539
+ if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
1540
+ return sendJson(res, 200, { ok: true, binary: false, content, truncated })
1541
+ } catch (err) {
1542
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1543
+ }
1544
+ },
1545
+ },
1546
+ {
1547
+ kind: 'exact',
1548
+ path: '/dsh-remote/workspace',
1549
+ handler: async (req, res) => {
1550
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1551
+ try {
1552
+ const payload = JSON.parse((await readBody(req)) || '{}')
1553
+ const p = normalizeRemotePath(String(payload.path || ''))
1554
+ if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
1555
+ const okDir = await isRemoteDir(p)
1556
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1557
+ persistWorkspace(p)
1558
+ const local = ensureMirror(p, config.host, config.username, config.port)
1559
+ return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
1560
+ } catch (err) {
1561
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1562
+ }
1563
+ },
1564
+ },
1565
+ {
1566
+ kind: 'exact',
1567
+ path: '/dsh-remote/mirror',
1568
+ handler: async (req, res) => {
1569
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1570
+ try {
1571
+ const payload = JSON.parse((await readBody(req)) || '{}')
1572
+ const p = normalizeRemotePath(String(payload.path || ''))
1573
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
1574
+ if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
1575
+ // Always verify over SSH (connecting if needed) so an unconnected or
1576
+ // wrong host can never silently mint a mirror for a bogus path.
1577
+ const okDir = await isRemoteDir(p)
1578
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
1579
+ const local = ensureMirror(p, config.host, config.username, config.port)
1580
+ persistWorkspace(p)
1581
+ return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
1582
+ } catch (err) {
1583
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1584
+ }
1585
+ },
1586
+ },
1587
+ {
1588
+ kind: 'exact',
1589
+ path: '/dsh-remote/local-pick',
1590
+ handler: async (req, res) => {
1591
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1592
+ try {
1593
+ // 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
1594
+ // ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
1595
+ // "cannot get property ... without inject"(该名字未在本插件
1596
+ // inject 里声明),ctx.get() 无此要求、缺失返回 null。
1597
+ // 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
1598
+ // 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
1599
+ let outcome = null
1600
+ let via = 'service'
1601
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1602
+ if (dp && typeof dp.capability === 'function') {
1603
+ try {
1604
+ const cap = await Promise.resolve(dp.capability())
1605
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
1606
+ const pickAbort = new AbortController()
1607
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
1608
+ pickAbort.abort()
1609
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
1610
+ }
1611
+ } catch (err) {
1612
+ outcome = null // DSH 服务出错 → 落到自持兜底
1613
+ }
1614
+ }
1615
+ if (!outcome) {
1616
+ via = 'own'
1617
+ try {
1618
+ outcome = await pickLocalNative()
1619
+ } catch (err) {
1620
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
1621
+ }
1622
+ }
1623
+ if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
1624
+ return sendJson(res, 200, { ok: true, path: outcome.path, via })
1625
+ } catch (err) {
1626
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1627
+ }
1628
+ },
1629
+ },
1630
+ {
1631
+ kind: 'exact',
1632
+ path: '/dsh-remote/machines',
1633
+ handler: async (req, res) => {
1634
+ if (req.method === 'GET') {
1635
+ return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
1636
+ }
1637
+ if (req.method === 'POST') {
1638
+ try {
1639
+ const body = JSON.parse((await readBody(req)) || '{}')
1640
+ const action = body.action || 'add'
1641
+ if (action === 'add' || action === 'update') {
1642
+ const host = String(body.host || '').trim()
1643
+ if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
1644
+ const rec = {
1645
+ id: body.id || machineId(),
1646
+ name: String(body.name || '').trim() || host,
1647
+ host,
1648
+ port: Number(body.port) || 22,
1649
+ username: String(body.username || '').trim() || 'root',
1650
+ password: body.password || '',
1651
+ privateKeyPath: String(body.privateKeyPath || '').trim(),
1652
+ passphrase: body.passphrase || '',
1653
+ workspace: String(body.workspace || '').trim(),
1654
+ }
1655
+ const i = machineIndex(rec.id)
1656
+ if (i >= 0) machines[i] = rec; else machines.push(rec)
1657
+ if (!store.currentId) { store.currentId = rec.id }
1658
+ saveMachines(machines, store.currentId)
1659
+ if (store.currentId === rec.id) applyMachine(config, rec)
1660
+ return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
1661
+ }
1662
+ if (action === 'delete') {
1663
+ const i = machineIndex(String(body.id || ''))
1664
+ if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
1665
+ machines.splice(i, 1)
1666
+ if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
1667
+ saveMachines(machines, store.currentId)
1668
+ return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
1669
+ }
1670
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
1671
+ } catch (err) {
1672
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1673
+ }
1674
+ }
1675
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1676
+ },
1677
+ },
1678
+ {
1679
+ kind: 'exact',
1680
+ path: '/dsh-remote/test-connect',
1681
+ handler: async (req, res) => {
1682
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1683
+ try {
1684
+ const body = JSON.parse((await readBody(req)) || '{}')
1685
+ const probe = new SshPool({
1686
+ ...config,
1687
+ host: String(body.host || config.host),
1688
+ port: Number(body.port) || config.port,
1689
+ username: String(body.username || config.username),
1690
+ password: String(body.password || ''),
1691
+ privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
1692
+ passphrase: String(body.passphrase || ''),
1693
+ connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
1694
+ commandTimeoutMs: 10000,
1695
+ })
1696
+ const started = Date.now()
1697
+ await probe.connect()
1698
+ await probe.exec('true', 10000)
1699
+ probe.close()
1700
+ return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
1701
+ } catch (err) {
1702
+ return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
1703
+ }
1704
+ },
1705
+ },
1706
+ {
1707
+ kind: 'exact',
1708
+ path: '/dsh-remote/current',
1709
+ handler: async (req, res) => {
1710
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1711
+ try {
1712
+ const body = JSON.parse((await readBody(req)) || '{}')
1713
+ const okSet = setCurrent(String(body.id || ''))
1714
+ if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
1715
+ return sendJson(res, 200, { ok: true, ...status() })
1716
+ } catch (err) {
1717
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1718
+ }
1719
+ },
1720
+ },
1721
+ {
1722
+ kind: 'exact',
1723
+ path: '/dsh-remote/forget-key',
1724
+ handler: async (req, res) => {
1725
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1726
+ // Drop the trusted host-key record for the CURRENT target so the next
1727
+ // connect re-records it (use after an intentional host reinstall or a
1728
+ // false MITM alarm).
1729
+ createHostKeyGuard(config).forgetHost()
1730
+ return sendJson(res, 200, { ok: true, ...status() })
1731
+ },
1732
+ },
1733
+ ]
1734
+
1735
+ const disposers = routes.map((r) => webServer.register(r))
1736
+ ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
1737
1737
  }