dsh-remote 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -30
- package/README.zh.md +9 -29
- package/lib/client.js +61 -250
- package/lib/index.js +914 -1031
- package/package.json +52 -61
package/lib/index.js
CHANGED
|
@@ -1,1032 +1,915 @@
|
|
|
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 { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
22
|
-
import { homedir } from 'node:os'
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
host
|
|
38
|
-
|
|
39
|
-
port
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
passphrase
|
|
48
|
-
|
|
49
|
-
workspace
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (seg === '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
return path.join(mirrorRootFor(host, user, port), base)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
118
|
-
function ensureMirror(remotePath, host, user, port) {
|
|
119
|
-
const dir = mirrorDirFor(remotePath, host, user, port)
|
|
120
|
-
mkdirSync(dir, { recursive: true })
|
|
121
|
-
writeFileSync(
|
|
122
|
-
path.join(dir, '.dsh-remote-meta.json'),
|
|
123
|
-
JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
|
|
124
|
-
)
|
|
125
|
-
return dir
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Recursively sync remote → local mirror. Bounded by depth/files. */
|
|
129
|
-
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
|
|
130
|
-
const entries = await sftp.readdir(remoteDir).then(
|
|
131
|
-
(list) => list,
|
|
132
|
-
() => [],
|
|
133
|
-
)
|
|
134
|
-
let files = 0
|
|
135
|
-
const touched = []
|
|
136
|
-
for (const e of entries) {
|
|
137
|
-
const name = String(e.filename)
|
|
138
|
-
if (name === '.' || name === '..') continue
|
|
139
|
-
const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
|
|
140
|
-
const lp = path.join(localDir, name)
|
|
141
|
-
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
142
|
-
if (isDir) {
|
|
143
|
-
if (maxDepth <= 0) continue
|
|
144
|
-
mkdirSync(lp, { recursive: true })
|
|
145
|
-
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
|
|
146
|
-
files += sub.files
|
|
147
|
-
touched.push(...sub.touched)
|
|
148
|
-
continue
|
|
149
|
-
}
|
|
150
|
-
if (files >= maxFiles) break
|
|
151
|
-
try {
|
|
152
|
-
const buf = await sftp.readFile(rp)
|
|
153
|
-
writeFileSync(lp, buf)
|
|
154
|
-
touched.push(rp)
|
|
155
|
-
files++
|
|
156
|
-
} catch {
|
|
157
|
-
/* skip unreadable */
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return { files, touched }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
|
|
164
|
-
async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
|
|
165
|
-
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
166
|
-
let files = 0
|
|
167
|
-
let dirsCreated = 0
|
|
168
|
-
const pushed = []
|
|
169
|
-
for (const e of entries) {
|
|
170
|
-
if (e.isDirectory()) {
|
|
171
|
-
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
172
|
-
try {
|
|
173
|
-
await sftp.mkdir(rp)
|
|
174
|
-
dirsCreated++
|
|
175
|
-
} catch { /* already exists */ }
|
|
176
|
-
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, remoteBaseDir, maxFiles)
|
|
177
|
-
files += sub.files
|
|
178
|
-
pushed.push(...sub.pushed)
|
|
179
|
-
continue
|
|
180
|
-
}
|
|
181
|
-
if (files >= maxFiles) break
|
|
182
|
-
const lp = path.join(localDir, e.name)
|
|
183
|
-
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
184
|
-
try {
|
|
185
|
-
const buf = readFileSync(lp)
|
|
186
|
-
await sftp.writeFile(rp, buf)
|
|
187
|
-
pushed.push(rp)
|
|
188
|
-
files++
|
|
189
|
-
} catch {
|
|
190
|
-
/* skip unreadable / unwritable */
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return { files, pushed }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
197
|
-
const MACHINES_FILE = 'machines.json'
|
|
198
|
-
const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
|
|
199
|
-
function loadMachines() {
|
|
200
|
-
try {
|
|
201
|
-
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
202
|
-
if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
|
|
203
|
-
} catch {}
|
|
204
|
-
return { list: [], currentId: null }
|
|
205
|
-
}
|
|
206
|
-
function saveMachines(list, currentId) {
|
|
207
|
-
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
208
|
-
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
209
|
-
}
|
|
210
|
-
function sanitizeMachine(m) {
|
|
211
|
-
if (!m) return m
|
|
212
|
-
const { password, ...rest } = m
|
|
213
|
-
return { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
214
|
-
}
|
|
215
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
216
|
-
function applyMachine(config, m) {
|
|
217
|
-
if (!m) return
|
|
218
|
-
config.host = m.host
|
|
219
|
-
config.port = Number(m.port) || 22
|
|
220
|
-
config.username = m.username || ''
|
|
221
|
-
config.password = m.password || ''
|
|
222
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
223
|
-
config.passphrase = m.passphrase || ''
|
|
224
|
-
config.workspace = m.workspace || (config.workspace || '')
|
|
225
|
-
}
|
|
226
|
-
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
227
|
-
|
|
228
|
-
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
class SshPool {
|
|
231
|
-
constructor(config) {
|
|
232
|
-
this.config = config
|
|
233
|
-
this.client = null
|
|
234
|
-
this.connecting = null
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
resolveKeyPath() {
|
|
238
|
-
const p = this.config.privateKeyPath
|
|
239
|
-
if (!p) return ''
|
|
240
|
-
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
241
|
-
return p
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
245
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
246
|
-
if (host !== undefined) this.config.host = String(host)
|
|
247
|
-
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
248
|
-
if (username !== undefined) this.config.username = String(username)
|
|
249
|
-
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
250
|
-
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
251
|
-
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
252
|
-
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
253
|
-
this.close()
|
|
254
|
-
return this
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
connect() {
|
|
258
|
-
if (this.client) return Promise.resolve(this.client)
|
|
259
|
-
if (this.connecting) return this.connecting
|
|
260
|
-
this.connecting = this._doConnect().finally(() => {
|
|
261
|
-
this.connecting = null
|
|
262
|
-
})
|
|
263
|
-
return this.connecting
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
_doConnect() {
|
|
267
|
-
return new Promise((resolve, reject) => {
|
|
268
|
-
const client = new Client()
|
|
269
|
-
let settled = false
|
|
270
|
-
const fail = (err) => {
|
|
271
|
-
if (settled) return
|
|
272
|
-
settled = true
|
|
273
|
-
if (this.client === client) this.client = null
|
|
274
|
-
reject(err)
|
|
275
|
-
}
|
|
276
|
-
client.on('ready', () => {
|
|
277
|
-
if (settled) return
|
|
278
|
-
settled = true
|
|
279
|
-
this.client = client
|
|
280
|
-
resolve(client)
|
|
281
|
-
})
|
|
282
|
-
client.on('error', fail)
|
|
283
|
-
client.on('close', () => {
|
|
284
|
-
if (this.client === client) this.client = null
|
|
285
|
-
fail(new Error('ssh connection closed'))
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
const opts = {
|
|
289
|
-
host: this.config.host,
|
|
290
|
-
port: this.config.port,
|
|
291
|
-
username: this.config.username,
|
|
292
|
-
readyTimeout: this.config.connectTimeoutMs,
|
|
293
|
-
keepaliveInterval: 15000,
|
|
294
|
-
keepaliveCountMax: 3,
|
|
295
|
-
}
|
|
296
|
-
if (this.config.password) {
|
|
297
|
-
opts.password = this.config.password
|
|
298
|
-
} else {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
let
|
|
324
|
-
let
|
|
325
|
-
let
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
maxFiles: { type: 'integer', description: 'Max files to
|
|
597
|
-
},
|
|
598
|
-
output: {
|
|
599
|
-
schema: { type: 'object', additionalProperties: false,
|
|
600
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
601
|
-
},
|
|
602
|
-
async execute(args) {
|
|
603
|
-
const p = wsPath()
|
|
604
|
-
if (!p) throw new Error('
|
|
605
|
-
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (!
|
|
662
|
-
return { text
|
|
663
|
-
},
|
|
664
|
-
}),
|
|
665
|
-
|
|
666
|
-
defineTool({
|
|
667
|
-
name: '
|
|
668
|
-
description:
|
|
669
|
-
'
|
|
670
|
-
parameters: {
|
|
671
|
-
required:
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
},
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
'
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
},
|
|
916
|
-
{
|
|
917
|
-
kind: 'exact',
|
|
918
|
-
path: '/dsh-remote/local-pick',
|
|
919
|
-
handler: async (req, res) => {
|
|
920
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
921
|
-
try {
|
|
922
|
-
const dp = (ctx && ctx.get && ctx.get('directoryPicker')) || (ctx && ctx.directoryPicker) || null
|
|
923
|
-
if (!dp || typeof dp.capability !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器服务不可用(缺少 DSH directory-picker backend)' })
|
|
924
|
-
const cap = await Promise.resolve(dp.capability())
|
|
925
|
-
if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器不可用(当前为非原生/浏览后端,请在输入框手动填本地路径)' })
|
|
926
|
-
const pickAbort = new AbortController()
|
|
927
|
-
const signal = pickAbort.signal || null
|
|
928
|
-
const picked = await Promise.resolve(cap.pick(signal))
|
|
929
|
-
pickAbort.abort()
|
|
930
|
-
if (!picked || typeof picked !== 'string') return sendJson(res, 200, { ok: true, cancelled: true })
|
|
931
|
-
return sendJson(res, 200, { ok: true, path: picked })
|
|
932
|
-
} catch (err) {
|
|
933
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
},
|
|
937
|
-
{
|
|
938
|
-
kind: 'exact',
|
|
939
|
-
path: '/dsh-remote/machines',
|
|
940
|
-
handler: async (req, res) => {
|
|
941
|
-
if (req.method === 'GET') {
|
|
942
|
-
return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
943
|
-
}
|
|
944
|
-
if (req.method === 'POST') {
|
|
945
|
-
try {
|
|
946
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
947
|
-
const action = body.action || 'add'
|
|
948
|
-
if (action === 'add' || action === 'update') {
|
|
949
|
-
const host = String(body.host || '').trim()
|
|
950
|
-
if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
|
|
951
|
-
const rec = {
|
|
952
|
-
id: body.id || machineId(),
|
|
953
|
-
name: String(body.name || '').trim() || host,
|
|
954
|
-
host,
|
|
955
|
-
port: Number(body.port) || 22,
|
|
956
|
-
username: String(body.username || '').trim() || 'root',
|
|
957
|
-
password: body.password || '',
|
|
958
|
-
privateKeyPath: String(body.privateKeyPath || '').trim(),
|
|
959
|
-
passphrase: body.passphrase || '',
|
|
960
|
-
workspace: String(body.workspace || '').trim(),
|
|
961
|
-
}
|
|
962
|
-
const i = machineIndex(rec.id)
|
|
963
|
-
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
964
|
-
if (!store.currentId) { store.currentId = rec.id }
|
|
965
|
-
saveMachines(machines, store.currentId)
|
|
966
|
-
if (store.currentId === rec.id) applyMachine(config, rec)
|
|
967
|
-
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
968
|
-
}
|
|
969
|
-
if (action === 'delete') {
|
|
970
|
-
const i = machineIndex(String(body.id || ''))
|
|
971
|
-
if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
972
|
-
machines.splice(i, 1)
|
|
973
|
-
if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
|
|
974
|
-
saveMachines(machines, store.currentId)
|
|
975
|
-
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
976
|
-
}
|
|
977
|
-
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
978
|
-
} catch (err) {
|
|
979
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
983
|
-
},
|
|
984
|
-
},
|
|
985
|
-
{
|
|
986
|
-
kind: 'exact',
|
|
987
|
-
path: '/dsh-remote/test-connect',
|
|
988
|
-
handler: async (req, res) => {
|
|
989
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
990
|
-
try {
|
|
991
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
992
|
-
const probe = new SshPool({
|
|
993
|
-
...config,
|
|
994
|
-
host: String(body.host || config.host),
|
|
995
|
-
port: Number(body.port) || config.port,
|
|
996
|
-
username: String(body.username || config.username),
|
|
997
|
-
password: String(body.password || ''),
|
|
998
|
-
privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
|
|
999
|
-
passphrase: String(body.passphrase || ''),
|
|
1000
|
-
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
1001
|
-
commandTimeoutMs: 10000,
|
|
1002
|
-
})
|
|
1003
|
-
const started = Date.now()
|
|
1004
|
-
await probe.connect()
|
|
1005
|
-
await probe.exec('true', 10000)
|
|
1006
|
-
probe.close()
|
|
1007
|
-
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
|
|
1008
|
-
} catch (err) {
|
|
1009
|
-
return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
|
|
1010
|
-
}
|
|
1011
|
-
},
|
|
1012
|
-
},
|
|
1013
|
-
{
|
|
1014
|
-
kind: 'exact',
|
|
1015
|
-
path: '/dsh-remote/current',
|
|
1016
|
-
handler: async (req, res) => {
|
|
1017
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1018
|
-
try {
|
|
1019
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1020
|
-
const okSet = setCurrent(String(body.id || ''))
|
|
1021
|
-
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1022
|
-
return sendJson(res, 200, { ok: true, ...status() })
|
|
1023
|
-
} catch (err) {
|
|
1024
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1025
|
-
}
|
|
1026
|
-
},
|
|
1027
|
-
},
|
|
1028
|
-
]
|
|
1029
|
-
|
|
1030
|
-
const disposers = routes.map((r) => webServer.register(r))
|
|
1031
|
-
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 { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
|
|
26
|
+
const { Client } = ssh2
|
|
27
|
+
|
|
28
|
+
export const name = 'dsh-remote'
|
|
29
|
+
|
|
30
|
+
// tools + systemPrompt + webServer are required. webServer is INJECTED (not just
|
|
31
|
+
// lazily read) so this plugin activates only after the web server is up — otherwise
|
|
32
|
+
// apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
|
|
33
|
+
// (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
|
|
34
|
+
export const inject = ['tools', 'systemPrompt', 'webServer']
|
|
35
|
+
|
|
36
|
+
export const Config = z.object({
|
|
37
|
+
/** Remote SSH host (empty → the plugin starts disconnected). */
|
|
38
|
+
host: z.string().default(''),
|
|
39
|
+
/** Remote SSH port (22 unless the machine uses a custom port). */
|
|
40
|
+
port: z.number().step(1).min(1).max(65535).default(22),
|
|
41
|
+
/** SSH login user. */
|
|
42
|
+
username: z.string().default(''),
|
|
43
|
+
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
44
|
+
password: z.string().default(''),
|
|
45
|
+
/** Absolute private-key path; empty → ~/.ssh/id_rsa. */
|
|
46
|
+
privateKeyPath: z.string().default(''),
|
|
47
|
+
/** Key passphrase when the key is encrypted. */
|
|
48
|
+
passphrase: z.string().default(''),
|
|
49
|
+
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
50
|
+
workspace: z.string().default(''),
|
|
51
|
+
/** Per-command timeout. */
|
|
52
|
+
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
53
|
+
/** SSH connection establishment timeout. */
|
|
54
|
+
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
55
|
+
/** Hard ceiling on collected remote output per call. */
|
|
56
|
+
maxOutputChars: z.number().step(1).min(1024).default(200000),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
60
|
+
|
|
61
|
+
/** Single-quote one shell argument verbatim. */
|
|
62
|
+
function shq(s) {
|
|
63
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
|
|
67
|
+
function normalizeRemotePath(p) {
|
|
68
|
+
const parts = []
|
|
69
|
+
for (const seg of String(p).split('/')) {
|
|
70
|
+
if (seg === '' || seg === '.') continue
|
|
71
|
+
if (seg === '..') {
|
|
72
|
+
parts.pop()
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
parts.push(seg)
|
|
76
|
+
}
|
|
77
|
+
return '/' + parts.join('/')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parent dir of a remote absolute path (string-level). */
|
|
81
|
+
function remoteDirname(p) {
|
|
82
|
+
const norm = normalizeRemotePath(p)
|
|
83
|
+
if (norm === '/') return '/'
|
|
84
|
+
const idx = norm.lastIndexOf('/')
|
|
85
|
+
return idx <= 0 ? '/' : norm.slice(0, idx)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function truncate(s, max) {
|
|
89
|
+
if (s.length <= max) return s
|
|
90
|
+
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── local mirror of an OS remote workspace ────────────────────────────────
|
|
94
|
+
// Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
|
|
95
|
+
// so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
|
|
96
|
+
// workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
|
|
97
|
+
|
|
98
|
+
const remotePathBase = (p) => {
|
|
99
|
+
const norm = normalizeRemotePath(p).replace(/\/+$/, '')
|
|
100
|
+
const base = norm.split('/').pop()
|
|
101
|
+
return base || 'workspace'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable local root for one remote host's mirrors */
|
|
105
|
+
function mirrorRootFor(host, user, port) {
|
|
106
|
+
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
107
|
+
return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Local mirror dir for a specific remote path (idempotent → returns same dir). */
|
|
111
|
+
function mirrorDirFor(remotePath, host, user, port) {
|
|
112
|
+
const base = remotePathBase(remotePath)
|
|
113
|
+
const h = createHash('sha1').update(`${host}:${port}:${remotePath}`).digest('hex').slice(0, 8)
|
|
114
|
+
return path.join(mirrorRootFor(host, user, port), `${base}-${h}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
118
|
+
function ensureMirror(remotePath, host, user, port) {
|
|
119
|
+
const dir = mirrorDirFor(remotePath, host, user, port)
|
|
120
|
+
mkdirSync(dir, { recursive: true })
|
|
121
|
+
writeFileSync(
|
|
122
|
+
path.join(dir, '.dsh-remote-meta.json'),
|
|
123
|
+
JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
|
|
124
|
+
)
|
|
125
|
+
return dir
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Recursively sync remote → local mirror. Bounded by depth/files. */
|
|
129
|
+
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
|
|
130
|
+
const entries = await sftp.readdir(remoteDir).then(
|
|
131
|
+
(list) => list,
|
|
132
|
+
() => [],
|
|
133
|
+
)
|
|
134
|
+
let files = 0
|
|
135
|
+
const touched = []
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
const name = String(e.filename)
|
|
138
|
+
if (name === '.' || name === '..') continue
|
|
139
|
+
const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
|
|
140
|
+
const lp = path.join(localDir, name)
|
|
141
|
+
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
142
|
+
if (isDir) {
|
|
143
|
+
if (maxDepth <= 0) continue
|
|
144
|
+
mkdirSync(lp, { recursive: true })
|
|
145
|
+
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
|
|
146
|
+
files += sub.files
|
|
147
|
+
touched.push(...sub.touched)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (files >= maxFiles) break
|
|
151
|
+
try {
|
|
152
|
+
const buf = await sftp.readFile(rp)
|
|
153
|
+
writeFileSync(lp, buf)
|
|
154
|
+
touched.push(rp)
|
|
155
|
+
files++
|
|
156
|
+
} catch {
|
|
157
|
+
/* skip unreadable */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { files, touched }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
|
|
164
|
+
async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
|
|
165
|
+
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
166
|
+
let files = 0
|
|
167
|
+
let dirsCreated = 0
|
|
168
|
+
const pushed = []
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
if (e.isDirectory()) {
|
|
171
|
+
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
172
|
+
try {
|
|
173
|
+
await sftp.mkdir(rp)
|
|
174
|
+
dirsCreated++
|
|
175
|
+
} catch { /* already exists */ }
|
|
176
|
+
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, remoteBaseDir, maxFiles)
|
|
177
|
+
files += sub.files
|
|
178
|
+
pushed.push(...sub.pushed)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (files >= maxFiles) break
|
|
182
|
+
const lp = path.join(localDir, e.name)
|
|
183
|
+
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
184
|
+
try {
|
|
185
|
+
const buf = readFileSync(lp)
|
|
186
|
+
await sftp.writeFile(rp, buf)
|
|
187
|
+
pushed.push(rp)
|
|
188
|
+
files++
|
|
189
|
+
} catch {
|
|
190
|
+
/* skip unreadable / unwritable */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { files, pushed }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
197
|
+
const MACHINES_FILE = 'machines.json'
|
|
198
|
+
const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
|
|
199
|
+
function loadMachines() {
|
|
200
|
+
try {
|
|
201
|
+
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
202
|
+
if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
|
|
203
|
+
} catch {}
|
|
204
|
+
return { list: [], currentId: null }
|
|
205
|
+
}
|
|
206
|
+
function saveMachines(list, currentId) {
|
|
207
|
+
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
208
|
+
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
209
|
+
}
|
|
210
|
+
function sanitizeMachine(m) {
|
|
211
|
+
if (!m) return m
|
|
212
|
+
const { password, ...rest } = m
|
|
213
|
+
return { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
214
|
+
}
|
|
215
|
+
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
216
|
+
function applyMachine(config, m) {
|
|
217
|
+
if (!m) return
|
|
218
|
+
config.host = m.host
|
|
219
|
+
config.port = Number(m.port) || 22
|
|
220
|
+
config.username = m.username || ''
|
|
221
|
+
config.password = m.password || ''
|
|
222
|
+
config.privateKeyPath = m.privateKeyPath || ''
|
|
223
|
+
config.passphrase = m.passphrase || ''
|
|
224
|
+
config.workspace = m.workspace || (config.workspace || '')
|
|
225
|
+
}
|
|
226
|
+
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
227
|
+
|
|
228
|
+
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
class SshPool {
|
|
231
|
+
constructor(config) {
|
|
232
|
+
this.config = config
|
|
233
|
+
this.client = null
|
|
234
|
+
this.connecting = null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
resolveKeyPath() {
|
|
238
|
+
const p = this.config.privateKeyPath
|
|
239
|
+
if (!p) return path.join(homedir(), '.ssh', 'id_rsa')
|
|
240
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
241
|
+
return p
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
245
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
246
|
+
if (host !== undefined) this.config.host = String(host)
|
|
247
|
+
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
248
|
+
if (username !== undefined) this.config.username = String(username)
|
|
249
|
+
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
250
|
+
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
251
|
+
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
252
|
+
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
253
|
+
this.close()
|
|
254
|
+
return this
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
connect() {
|
|
258
|
+
if (this.client) return Promise.resolve(this.client)
|
|
259
|
+
if (this.connecting) return this.connecting
|
|
260
|
+
this.connecting = this._doConnect().finally(() => {
|
|
261
|
+
this.connecting = null
|
|
262
|
+
})
|
|
263
|
+
return this.connecting
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_doConnect() {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const client = new Client()
|
|
269
|
+
let settled = false
|
|
270
|
+
const fail = (err) => {
|
|
271
|
+
if (settled) return
|
|
272
|
+
settled = true
|
|
273
|
+
if (this.client === client) this.client = null
|
|
274
|
+
reject(err)
|
|
275
|
+
}
|
|
276
|
+
client.on('ready', () => {
|
|
277
|
+
if (settled) return
|
|
278
|
+
settled = true
|
|
279
|
+
this.client = client
|
|
280
|
+
resolve(client)
|
|
281
|
+
})
|
|
282
|
+
client.on('error', fail)
|
|
283
|
+
client.on('close', () => {
|
|
284
|
+
if (this.client === client) this.client = null
|
|
285
|
+
fail(new Error('ssh connection closed'))
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const opts = {
|
|
289
|
+
host: this.config.host,
|
|
290
|
+
port: this.config.port,
|
|
291
|
+
username: this.config.username,
|
|
292
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
293
|
+
keepaliveInterval: 15000,
|
|
294
|
+
keepaliveCountMax: 3,
|
|
295
|
+
}
|
|
296
|
+
if (this.config.password) {
|
|
297
|
+
opts.password = this.config.password
|
|
298
|
+
} else {
|
|
299
|
+
let key
|
|
300
|
+
try {
|
|
301
|
+
key = readFileSync(this.resolveKeyPath())
|
|
302
|
+
} catch (err) {
|
|
303
|
+
return fail(
|
|
304
|
+
new Error(`cannot read private key "${this.resolveKeyPath()}": ${err && err.message}`),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
opts.privateKey = key
|
|
308
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
309
|
+
}
|
|
310
|
+
client.connect(opts)
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }. */
|
|
315
|
+
exec(command, timeoutMs) {
|
|
316
|
+
return this.connect().then(
|
|
317
|
+
(client) =>
|
|
318
|
+
new Promise((resolve, reject) => {
|
|
319
|
+
client.exec(command, (err, stream) => {
|
|
320
|
+
if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
321
|
+
let stdout = ''
|
|
322
|
+
let stderr = ''
|
|
323
|
+
let settled = false
|
|
324
|
+
let exitCode = null
|
|
325
|
+
let exitSignal = null
|
|
326
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
327
|
+
const settle = () => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
settled = true
|
|
330
|
+
clearTimeout(timer)
|
|
331
|
+
resolve({
|
|
332
|
+
code: exitCode,
|
|
333
|
+
signal: exitSignal,
|
|
334
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
335
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
const timer = setTimeout(() => {
|
|
339
|
+
if (settled) return
|
|
340
|
+
exitCode = -1
|
|
341
|
+
exitSignal = 'TIMEOUT'
|
|
342
|
+
try {
|
|
343
|
+
stream.close()
|
|
344
|
+
} catch {}
|
|
345
|
+
settle()
|
|
346
|
+
}, timeoutMs || this.config.commandTimeoutMs)
|
|
347
|
+
stream.on('close', (code, signal) => {
|
|
348
|
+
if (settled) return
|
|
349
|
+
exitCode = code
|
|
350
|
+
exitSignal = signal
|
|
351
|
+
settle()
|
|
352
|
+
})
|
|
353
|
+
stream.on('data', (d) => {
|
|
354
|
+
if (stdout.length < hardCap) stdout += d
|
|
355
|
+
})
|
|
356
|
+
stream.stderr.on('data', (d) => {
|
|
357
|
+
if (stderr.length < hardCap) stderr += d
|
|
358
|
+
})
|
|
359
|
+
stream.on('error', (e) => {
|
|
360
|
+
if (settled) return
|
|
361
|
+
settled = true
|
|
362
|
+
clearTimeout(timer)
|
|
363
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Resolve a promisified SFTP client for binary file transfers (sync). */
|
|
371
|
+
sftp() {
|
|
372
|
+
return this.connect().then(
|
|
373
|
+
(client) =>
|
|
374
|
+
new Promise((resolve, reject) => {
|
|
375
|
+
client.sftp((err, sftp) => {
|
|
376
|
+
if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
377
|
+
resolve({
|
|
378
|
+
readdir: (dir) => new Promise((r2, j2) => sftp.readdir(dir, (e, list) => (e ? j2(e) : r2(list)))),
|
|
379
|
+
stat: (p) => new Promise((r2, j2) => sftp.stat(p, (e, st) => (e ? j2(e) : r2(st)))),
|
|
380
|
+
mkdir: (dir) => new Promise((r2, j2) => sftp.mkdir(dir, (e) => (e ? j2(e) : r2()) )),
|
|
381
|
+
readFile: (p) => new Promise((r2, j2) => sftp.readFile(p, (e, buf) => (e ? j2(e) : r2(buf)))),
|
|
382
|
+
writeFile: (p, data) => new Promise((r2, j2) => sftp.writeFile(p, data, (e) => (e ? j2(e) : r2()))),
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
close() {
|
|
390
|
+
const client = this.client
|
|
391
|
+
this.client = null
|
|
392
|
+
if (client) {
|
|
393
|
+
try {
|
|
394
|
+
client.end()
|
|
395
|
+
} catch {}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── helper: run + text ─────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
export async function apply(ctx, config) {
|
|
403
|
+
const pool = new SshPool(config)
|
|
404
|
+
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
405
|
+
|
|
406
|
+
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
407
|
+
const store = loadMachines()
|
|
408
|
+
const machines = store.list
|
|
409
|
+
const machineIndex = (id) => machines.findIndex((m) => m.id === id)
|
|
410
|
+
const currentMachine = () => {
|
|
411
|
+
if (store.currentId) {
|
|
412
|
+
const i = machineIndex(store.currentId)
|
|
413
|
+
if (i >= 0) return machines[i]
|
|
414
|
+
}
|
|
415
|
+
// fall back to config-derived default (host set via cordis config)
|
|
416
|
+
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 }
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
const setCurrent = (id) => {
|
|
420
|
+
const i = machineIndex(id)
|
|
421
|
+
if (i < 0) return false
|
|
422
|
+
store.currentId = id
|
|
423
|
+
saveMachines(machines, id)
|
|
424
|
+
applyMachine(config, machines[i])
|
|
425
|
+
pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
// If no stored current, adopt a CLI-provided default as the active machine.
|
|
429
|
+
{
|
|
430
|
+
const cur = currentMachine()
|
|
431
|
+
if (cur && cur.host) applyMachine(config, cur)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const run = async (cmd, opts = {}) => {
|
|
435
|
+
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
436
|
+
const parts = []
|
|
437
|
+
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
438
|
+
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
439
|
+
if (!parts.length) parts.push('(no output)')
|
|
440
|
+
let text = parts.join('\n')
|
|
441
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
|
|
442
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
443
|
+
return text
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── remote workspace state ────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
const wsPath = () => (config.workspace || '').trim()
|
|
449
|
+
const status = () => ({
|
|
450
|
+
host: config.host,
|
|
451
|
+
port: config.port,
|
|
452
|
+
username: config.username,
|
|
453
|
+
connected: !!pool.client,
|
|
454
|
+
workspace: wsPath(),
|
|
455
|
+
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
456
|
+
currentId: store.currentId || null,
|
|
457
|
+
machines: machines.map(sanitizeMachine),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ── tools ─────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
const renderErr = (err) => ({
|
|
463
|
+
kind: 'error',
|
|
464
|
+
text: String((err && err.message) || err),
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
const tools = [
|
|
468
|
+
defineTool({
|
|
469
|
+
name: 'rw_info',
|
|
470
|
+
description:
|
|
471
|
+
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when a remote_* call fails to check connectivity.',
|
|
472
|
+
parameters: {},
|
|
473
|
+
output: {
|
|
474
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
475
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
476
|
+
},
|
|
477
|
+
async execute() {
|
|
478
|
+
const s = status()
|
|
479
|
+
const lines = [
|
|
480
|
+
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
481
|
+
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
482
|
+
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
483
|
+
'',
|
|
484
|
+
]
|
|
485
|
+
if (s.host && s.workspace) {
|
|
486
|
+
try {
|
|
487
|
+
const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
|
|
488
|
+
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
489
|
+
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
490
|
+
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
491
|
+
} catch (err) {
|
|
492
|
+
lines.push('Ping: FAILED — ' + ((err && err.message) || err))
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
496
|
+
}
|
|
497
|
+
return { text: lines.join('\n') }
|
|
498
|
+
},
|
|
499
|
+
}),
|
|
500
|
+
|
|
501
|
+
defineTool({
|
|
502
|
+
name: 'rw_connect',
|
|
503
|
+
description:
|
|
504
|
+
'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.',
|
|
505
|
+
parameters: {
|
|
506
|
+
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
507
|
+
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
508
|
+
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
509
|
+
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
510
|
+
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
511
|
+
},
|
|
512
|
+
output: {
|
|
513
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
514
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
515
|
+
},
|
|
516
|
+
async execute(args) {
|
|
517
|
+
const host = String(args.host || '').trim()
|
|
518
|
+
if (!host) throw new Error('rw_connect: host is required')
|
|
519
|
+
pool.setTarget({
|
|
520
|
+
host,
|
|
521
|
+
username: args.username || config.username || 'root',
|
|
522
|
+
port: args.port || undefined,
|
|
523
|
+
password: args.password !== undefined ? args.password : undefined,
|
|
524
|
+
privateKeyPath: args.privateKeyPath || undefined,
|
|
525
|
+
})
|
|
526
|
+
try {
|
|
527
|
+
const res = await pool.exec('echo ok; hostname', 8000)
|
|
528
|
+
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
529
|
+
return { text: `Connected to ${host} as ${config.username}.\nhostname: ${res.stdout.replace(/\s+/g, ' ').trim()}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
530
|
+
} catch (err) {
|
|
531
|
+
throw err
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
}),
|
|
535
|
+
|
|
536
|
+
defineTool({
|
|
537
|
+
name: 'rw_pick_workspace',
|
|
538
|
+
description:
|
|
539
|
+
'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.',
|
|
540
|
+
parameters: {
|
|
541
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
542
|
+
},
|
|
543
|
+
output: {
|
|
544
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
545
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
546
|
+
},
|
|
547
|
+
async execute(args) {
|
|
548
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
549
|
+
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
550
|
+
const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
551
|
+
const ok = res.stdout.trim() === 'DIR'
|
|
552
|
+
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
553
|
+
config.workspace = p
|
|
554
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
555
|
+
return {
|
|
556
|
+
text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
|
|
561
|
+
defineTool({
|
|
562
|
+
name: 'rw_sync',
|
|
563
|
+
description:
|
|
564
|
+
'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.',
|
|
565
|
+
parameters: {
|
|
566
|
+
depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
|
|
567
|
+
maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
|
|
568
|
+
},
|
|
569
|
+
output: {
|
|
570
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
571
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
572
|
+
},
|
|
573
|
+
async execute(args) {
|
|
574
|
+
const p = wsPath()
|
|
575
|
+
if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
|
|
576
|
+
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
577
|
+
mkdirSync(local, { recursive: true })
|
|
578
|
+
const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
|
|
579
|
+
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
580
|
+
let sftp
|
|
581
|
+
try {
|
|
582
|
+
sftp = await pool.sftp()
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
585
|
+
}
|
|
586
|
+
const { files, touched } = await syncTree(sftp, p, local, depth, maxFiles)
|
|
587
|
+
return { text: `Downloaded ${files} file(s) from ${p} → ${local}${files >= maxFiles ? ' (hit download cap)' : ''}.` }
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
|
|
591
|
+
defineTool({
|
|
592
|
+
name: 'rw_push',
|
|
593
|
+
description:
|
|
594
|
+
'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).',
|
|
595
|
+
parameters: {
|
|
596
|
+
maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
|
|
597
|
+
},
|
|
598
|
+
output: {
|
|
599
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
600
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
601
|
+
},
|
|
602
|
+
async execute(args) {
|
|
603
|
+
const p = wsPath()
|
|
604
|
+
if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
|
|
605
|
+
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
606
|
+
if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
|
|
607
|
+
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
608
|
+
let sftp
|
|
609
|
+
try {
|
|
610
|
+
sftp = await pool.sftp()
|
|
611
|
+
} catch (err) {
|
|
612
|
+
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
613
|
+
}
|
|
614
|
+
const { files } = await pushTree(sftp, local, p, p, maxFiles)
|
|
615
|
+
return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
|
|
616
|
+
},
|
|
617
|
+
}),
|
|
618
|
+
|
|
619
|
+
defineTool({
|
|
620
|
+
name: 'rw_list_dir',
|
|
621
|
+
description:
|
|
622
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
623
|
+
parameters: {
|
|
624
|
+
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
625
|
+
},
|
|
626
|
+
output: {
|
|
627
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
628
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
629
|
+
},
|
|
630
|
+
async execute(args) {
|
|
631
|
+
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
632
|
+
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
633
|
+
return { text: await run(`ls -la --color=never ${shq(p)}`) }
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
|
|
637
|
+
defineTool({
|
|
638
|
+
name: 'rw_read_file',
|
|
639
|
+
description:
|
|
640
|
+
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
|
|
641
|
+
parameters: {
|
|
642
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
643
|
+
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
644
|
+
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
645
|
+
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
646
|
+
},
|
|
647
|
+
output: {
|
|
648
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
649
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
650
|
+
},
|
|
651
|
+
async execute(args) {
|
|
652
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
653
|
+
if (!p) throw new Error('rw_read_file: path is required')
|
|
654
|
+
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
655
|
+
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
656
|
+
let to = Number(args.endLine) || 0
|
|
657
|
+
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
658
|
+
const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
|
|
659
|
+
const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
660
|
+
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
661
|
+
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
662
|
+
return { text }
|
|
663
|
+
},
|
|
664
|
+
}),
|
|
665
|
+
|
|
666
|
+
defineTool({
|
|
667
|
+
name: 'rw_exec',
|
|
668
|
+
description:
|
|
669
|
+
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
|
|
670
|
+
parameters: {
|
|
671
|
+
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
672
|
+
},
|
|
673
|
+
output: {
|
|
674
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
675
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
676
|
+
},
|
|
677
|
+
async execute(args) {
|
|
678
|
+
const cmd = String(args.command || '')
|
|
679
|
+
if (!cmd) throw new Error('rw_exec: command is required')
|
|
680
|
+
return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
|
|
681
|
+
},
|
|
682
|
+
}),
|
|
683
|
+
]
|
|
684
|
+
|
|
685
|
+
for (const t of tools) {
|
|
686
|
+
ctx.tools.register(t)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── system-prompt injection: the current remote workspace ─────────────────
|
|
690
|
+
ctx.systemPrompt.section({
|
|
691
|
+
name: 'dsh-remote',
|
|
692
|
+
order: 88,
|
|
693
|
+
text: () => {
|
|
694
|
+
const w = wsPath()
|
|
695
|
+
if (!w || !config.host) return ''
|
|
696
|
+
return (
|
|
697
|
+
'## Remote workspace\n' +
|
|
698
|
+
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
699
|
+
'Use the remote_* tools (rw_list_dir / rw_read_file / rw_exec) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
|
|
700
|
+
)
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// ── slash command: /remote reports status + connection hints ──────────────
|
|
705
|
+
const commands = ctx.get('commands')
|
|
706
|
+
if (commands !== undefined) {
|
|
707
|
+
commands.register({
|
|
708
|
+
name: 'remote',
|
|
709
|
+
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
710
|
+
handler: (invocation) => {
|
|
711
|
+
const s = status()
|
|
712
|
+
return {
|
|
713
|
+
kind: 'success',
|
|
714
|
+
text:
|
|
715
|
+
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
716
|
+
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
717
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
|
|
718
|
+
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
725
|
+
const webServer = ctx.get('webServer')
|
|
726
|
+
if (webServer === undefined) return
|
|
727
|
+
|
|
728
|
+
const sendJson = (res, status, body) => {
|
|
729
|
+
res.statusCode = status
|
|
730
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
731
|
+
res.end(JSON.stringify(body))
|
|
732
|
+
}
|
|
733
|
+
const readBody = (req) =>
|
|
734
|
+
new Promise((resolve) => {
|
|
735
|
+
const chunks = []
|
|
736
|
+
req.on('data', (c) => chunks.push(c))
|
|
737
|
+
req.on('end', () => resolve(chunks.join('')))
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
const routes = [
|
|
741
|
+
{
|
|
742
|
+
kind: 'exact',
|
|
743
|
+
path: '/dsh-remote/status',
|
|
744
|
+
handler: async (req, res) => {
|
|
745
|
+
if (req.method === 'GET') return sendJson(res, 200, status())
|
|
746
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
kind: 'exact',
|
|
751
|
+
path: '/dsh-remote/connect',
|
|
752
|
+
handler: async (req, res) => {
|
|
753
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
754
|
+
try {
|
|
755
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
756
|
+
pool.setTarget({
|
|
757
|
+
host: payload.host,
|
|
758
|
+
port: payload.port,
|
|
759
|
+
username: payload.username,
|
|
760
|
+
password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
|
|
761
|
+
privateKeyPath: payload.privateKeyPath,
|
|
762
|
+
workspace: payload.workspace,
|
|
763
|
+
})
|
|
764
|
+
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
765
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
766
|
+
} catch (err) {
|
|
767
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
kind: 'exact',
|
|
773
|
+
path: '/dsh-remote/ls',
|
|
774
|
+
handler: async (req, res) => {
|
|
775
|
+
try {
|
|
776
|
+
const m = (req.url || '').match(/path=([^&]*)/)
|
|
777
|
+
const p = m ? decodeURIComponent(m[1]) : wsPath()
|
|
778
|
+
const out = await run(`ls -la --color=never ${shq(p || '/')}`)
|
|
779
|
+
return sendJson(res, 200, { path: p, text: out })
|
|
780
|
+
} catch (err) {
|
|
781
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
kind: 'exact',
|
|
787
|
+
path: '/dsh-remote/workspace',
|
|
788
|
+
handler: async (req, res) => {
|
|
789
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
790
|
+
try {
|
|
791
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
792
|
+
const p = normalizeRemotePath(String(payload.path || ''))
|
|
793
|
+
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
794
|
+
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
795
|
+
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
796
|
+
config.workspace = p
|
|
797
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
798
|
+
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
799
|
+
} catch (err) {
|
|
800
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
kind: 'exact',
|
|
806
|
+
path: '/dsh-remote/mirror',
|
|
807
|
+
handler: async (req, res) => {
|
|
808
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
809
|
+
try {
|
|
810
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
811
|
+
const p = normalizeRemotePath(String(payload.path || ''))
|
|
812
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
813
|
+
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
814
|
+
// optional: verify it's a directory over SSH when connected
|
|
815
|
+
if (pool.client) {
|
|
816
|
+
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
817
|
+
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
818
|
+
}
|
|
819
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
820
|
+
config.workspace = p
|
|
821
|
+
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
822
|
+
} catch (err) {
|
|
823
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
kind: 'exact',
|
|
829
|
+
path: '/dsh-remote/local-pick',
|
|
830
|
+
handler: async (req, res) => {
|
|
831
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
832
|
+
try {
|
|
833
|
+
const dp = (ctx && ctx.get && ctx.get('directoryPicker')) || (ctx && ctx.directoryPicker) || null
|
|
834
|
+
if (!dp || typeof dp.capability !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器服务不可用(缺少 DSH directory-picker backend)' })
|
|
835
|
+
const cap = await Promise.resolve(dp.capability())
|
|
836
|
+
if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器不可用(当前为非原生/浏览后端,请在输入框手动填本地路径)' })
|
|
837
|
+
const pickAbort = new AbortController()
|
|
838
|
+
const signal = pickAbort.signal || null
|
|
839
|
+
const picked = await Promise.resolve(cap.pick(signal))
|
|
840
|
+
pickAbort.abort()
|
|
841
|
+
if (!picked || typeof picked !== 'string') return sendJson(res, 200, { ok: true, cancelled: true })
|
|
842
|
+
return sendJson(res, 200, { ok: true, path: picked })
|
|
843
|
+
} catch (err) {
|
|
844
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
kind: 'exact',
|
|
850
|
+
path: '/dsh-remote/machines',
|
|
851
|
+
handler: async (req, res) => {
|
|
852
|
+
if (req.method === 'GET') {
|
|
853
|
+
return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
854
|
+
}
|
|
855
|
+
if (req.method === 'POST') {
|
|
856
|
+
try {
|
|
857
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
858
|
+
const action = body.action || 'add'
|
|
859
|
+
if (action === 'add' || action === 'update') {
|
|
860
|
+
const host = String(body.host || '').trim()
|
|
861
|
+
if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
|
|
862
|
+
const rec = {
|
|
863
|
+
id: body.id || machineId(),
|
|
864
|
+
name: String(body.name || '').trim() || host,
|
|
865
|
+
host,
|
|
866
|
+
port: Number(body.port) || 22,
|
|
867
|
+
username: String(body.username || '').trim() || 'root',
|
|
868
|
+
password: body.password || '',
|
|
869
|
+
privateKeyPath: String(body.privateKeyPath || '').trim(),
|
|
870
|
+
passphrase: body.passphrase || '',
|
|
871
|
+
workspace: String(body.workspace || '').trim(),
|
|
872
|
+
}
|
|
873
|
+
const i = machineIndex(rec.id)
|
|
874
|
+
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
875
|
+
if (!store.currentId) { store.currentId = rec.id }
|
|
876
|
+
saveMachines(machines, store.currentId)
|
|
877
|
+
if (store.currentId === rec.id) applyMachine(config, rec)
|
|
878
|
+
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
879
|
+
}
|
|
880
|
+
if (action === 'delete') {
|
|
881
|
+
const i = machineIndex(String(body.id || ''))
|
|
882
|
+
if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
883
|
+
machines.splice(i, 1)
|
|
884
|
+
if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
|
|
885
|
+
saveMachines(machines, store.currentId)
|
|
886
|
+
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
887
|
+
}
|
|
888
|
+
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
889
|
+
} catch (err) {
|
|
890
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
kind: 'exact',
|
|
898
|
+
path: '/dsh-remote/current',
|
|
899
|
+
handler: async (req, res) => {
|
|
900
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
901
|
+
try {
|
|
902
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
903
|
+
const okSet = setCurrent(String(body.id || ''))
|
|
904
|
+
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
905
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
906
|
+
} catch (err) {
|
|
907
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
]
|
|
912
|
+
|
|
913
|
+
const disposers = routes.map((r) => webServer.register(r))
|
|
914
|
+
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
1032
915
|
}
|