dsh-remote 0.5.2 → 0.5.4

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,915 +1,1025 @@
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')
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 path from 'node:path'
24
+
25
+ const { Client } = ssh2
26
+
27
+ export const name = 'dsh-remote'
28
+
29
+ // tools + systemPrompt + webServer are required. webServer is INJECTED (not just
30
+ // lazily read) so this plugin activates only after the web server is up — otherwise
31
+ // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
32
+ // (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
33
+ export const inject = ['tools', 'systemPrompt', 'webServer']
34
+
35
+ export const Config = z.object({
36
+ /** Remote SSH host (empty → the plugin starts disconnected). */
37
+ host: z.string().default(''),
38
+ /** Remote SSH port (22 unless the machine uses a custom port). */
39
+ port: z.number().step(1).min(1).max(65535).default(22),
40
+ /** SSH login user. */
41
+ username: z.string().default(''),
42
+ /** Password login (only when the remote has no key. Override the fallback below). */
43
+ password: z.string().default(''),
44
+ /** Absolute private-key path; empty → ~/.ssh/id_rsa. */
45
+ privateKeyPath: z.string().default(''),
46
+ /** Key passphrase when the key is encrypted. */
47
+ passphrase: z.string().default(''),
48
+ /** Initial remote workspace path (absolute dir the agent should treat as root). */
49
+ workspace: z.string().default(''),
50
+ /** Per-command timeout. */
51
+ commandTimeoutMs: z.number().step(1).min(1000).default(20000),
52
+ /** SSH connection establishment timeout. */
53
+ connectTimeoutMs: z.number().step(1).min(1000).default(15000),
54
+ /** Hard ceiling on collected remote output per call. */
55
+ maxOutputChars: z.number().step(1).min(1024).default(200000),
56
+ })
57
+
58
+ // ── shell / path helpers (proven in the read tooling) ───────────────────────
59
+
60
+ /** Single-quote one shell argument verbatim. */
61
+ function shq(s) {
62
+ return "'" + String(s).replace(/'/g, "'\\''") + "'"
63
+ }
64
+
65
+ /** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
66
+ function normalizeRemotePath(p) {
67
+ const parts = []
68
+ for (const seg of String(p).split('/')) {
69
+ if (seg === '' || seg === '.') continue
70
+ if (seg === '..') {
71
+ parts.pop()
72
+ continue
73
+ }
74
+ parts.push(seg)
75
+ }
76
+ return '/' + parts.join('/')
77
+ }
78
+
79
+ /** Parent dir of a remote absolute path (string-level). */
80
+ function remoteDirname(p) {
81
+ const norm = normalizeRemotePath(p)
82
+ if (norm === '/') return '/'
83
+ const idx = norm.lastIndexOf('/')
84
+ return idx <= 0 ? '/' : norm.slice(0, idx)
85
+ }
86
+
87
+ function truncate(s, max) {
88
+ if (s.length <= max) return s
89
+ return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
90
+ }
91
+
92
+ // ── local mirror of an OS remote workspace ────────────────────────────────
93
+ // Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
94
+ // so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
95
+ // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
96
+
97
+ const remotePathBase = (p) => {
98
+ const norm = normalizeRemotePath(p).replace(/\/+$/, '')
99
+ const base = norm.split('/').pop()
100
+ return base || 'workspace'
101
+ }
102
+
103
+ /** Stable local root for one remote host's mirrors */
104
+ function mirrorRootFor(host, user, port) {
105
+ const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
106
+ return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
107
+ }
108
+
109
+ /** Local mirror dir for a specific remote path (idempotent → returns same dir).
110
+ * Named after the remote directory's basename so the harness workspace label
111
+ * reads cleanly (e.g. .../project) instead of a concatenated hash string. */
112
+ function mirrorDirFor(remotePath, host, user, port) {
113
+ const base = remotePathBase(remotePath)
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 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
+ /** Structured-listing of a remote dir: name + usable type. `dir` is decided by
447
+ * `[ -d ]`, which FOLLOWS symlinks — so a symlink to a directory is enterable
448
+ * (the picker can drill into it), while a file symlink stays a file. */
449
+ const listDirStructured = async (p, timeoutMs) => {
450
+ const target = p || '/'
451
+ const cmd =
452
+ `cd ${shq(target)} 2>/dev/null && for f in .[!.]* *; do ` +
453
+ `[ -e "$f" ] || [ -L "$f" ] || continue; ` +
454
+ `if [ -d "$f" ]; then printf 'd\\t%s\\n' "$f"; else printf 'f\\t%s\\n' "$f"; fi; done`
455
+ const res = await pool.exec(cmd, timeoutMs || config.commandTimeoutMs)
456
+ const items = []
457
+ if (res.code !== 0 && res.stderr) {
458
+ throw new Error('ls failed: ' + (res.stderr || '').trim())
459
+ }
460
+ for (const line of String(res.stdout || '').split('\n')) {
461
+ const idx = line.indexOf('\t')
462
+ if (idx < 0) continue
463
+ const type = line.slice(0, idx)
464
+ const name = line.slice(idx + 1)
465
+ if (name === '.' || name === '..' || !name) continue
466
+ items.push({ type: type === 'd' ? 'dir' : 'file', name })
467
+ }
468
+ return { path: target, items }
469
+ }
470
+
471
+ // ── remote workspace state ────────────────────────────────────────────────
472
+
473
+ const wsPath = () => (config.workspace || '').trim()
474
+ const status = () => ({
475
+ host: config.host,
476
+ port: config.port,
477
+ username: config.username,
478
+ connected: !!pool.client,
479
+ workspace: wsPath(),
480
+ localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
481
+ currentId: store.currentId || null,
482
+ machines: machines.map(sanitizeMachine),
483
+ })
484
+
485
+ // ── tools ─────────────────────────────────────────────────────────────────
486
+
487
+ const renderErr = (err) => ({
488
+ kind: 'error',
489
+ text: String((err && err.message) || err),
490
+ })
491
+
492
+ const tools = [
493
+ defineTool({
494
+ name: 'rw_info',
495
+ description:
496
+ '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.',
497
+ parameters: {},
498
+ output: {
499
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
500
+ render: (_a, v) => [{ type: 'text', text: v.text }],
501
+ },
502
+ async execute() {
503
+ const s = status()
504
+ const lines = [
505
+ `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
506
+ `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
507
+ `Connected: ${s.connected ? 'yes' : 'no'}`,
508
+ '',
509
+ ]
510
+ if (s.host && s.workspace) {
511
+ try {
512
+ const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
513
+ if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
514
+ else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
515
+ else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
516
+ } catch (err) {
517
+ lines.push('Ping: FAILED — ' + ((err && err.message) || err))
518
+ }
519
+ } else {
520
+ lines.push('No host + workspace configured — call rw_connect with a host to get started.')
521
+ }
522
+ return { text: lines.join('\n') }
523
+ },
524
+ }),
525
+
526
+ defineTool({
527
+ name: 'rw_connect',
528
+ description:
529
+ '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.',
530
+ parameters: {
531
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
532
+ username: { type: 'string', description: 'SSH user (default from config or root)' },
533
+ port: { type: 'integer', description: 'SSH port (default 22)' },
534
+ password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
535
+ privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
536
+ },
537
+ output: {
538
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
539
+ render: (_a, a) => [{ type: 'text', text: a.text }],
540
+ },
541
+ async execute(args) {
542
+ const host = String(args.host || '').trim()
543
+ if (!host) throw new Error('rw_connect: host is required')
544
+ pool.setTarget({
545
+ host,
546
+ username: args.username || config.username || 'root',
547
+ port: args.port || undefined,
548
+ password: args.password !== undefined ? args.password : undefined,
549
+ privateKeyPath: args.privateKeyPath || undefined,
550
+ })
551
+ try {
552
+ const res = await pool.exec('echo ok; hostname', 8000)
553
+ if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
554
+ 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>).` }
555
+ } catch (err) {
556
+ throw err
557
+ }
558
+ },
559
+ }),
560
+
561
+ defineTool({
562
+ name: 'rw_pick_workspace',
563
+ description:
564
+ '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.',
565
+ parameters: {
566
+ path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
567
+ },
568
+ output: {
569
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
570
+ render: (_a, a) => [{ type: 'text', text: a.text }],
571
+ },
572
+ async execute(args) {
573
+ const p = normalizeRemotePath(String(args.path || ''))
574
+ if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
575
+ const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
576
+ const ok = res.stdout.trim() === 'DIR'
577
+ if (!ok) return { text: `not a directory (or missing) on ${p}` }
578
+ config.workspace = p
579
+ const local = ensureMirror(p, config.host, config.username, config.port)
580
+ return {
581
+ 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.`,
582
+ }
583
+ },
584
+ }),
585
+
586
+ defineTool({
587
+ name: 'rw_sync',
588
+ description:
589
+ '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.',
590
+ parameters: {
591
+ depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
592
+ maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
593
+ },
594
+ output: {
595
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
596
+ render: (_a, a) => [{ type: 'text', text: a.text }],
597
+ },
598
+ async execute(args) {
599
+ const p = wsPath()
600
+ if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
601
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
602
+ mkdirSync(local, { recursive: true })
603
+ const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
604
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
605
+ let sftp
606
+ try {
607
+ sftp = await pool.sftp()
608
+ } catch (err) {
609
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
610
+ }
611
+ const { files, touched } = await syncTree(sftp, p, local, depth, maxFiles)
612
+ return { text: `Downloaded ${files} file(s) from ${p} → ${local}${files >= maxFiles ? ' (hit download cap)' : ''}.` }
613
+ },
614
+ }),
615
+
616
+ defineTool({
617
+ name: 'rw_push',
618
+ description:
619
+ '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).',
620
+ parameters: {
621
+ maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
622
+ },
623
+ output: {
624
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
625
+ render: (_a, a) => [{ type: 'text', text: a.text }],
626
+ },
627
+ async execute(args) {
628
+ const p = wsPath()
629
+ if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
630
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
631
+ if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
632
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
633
+ let sftp
634
+ try {
635
+ sftp = await pool.sftp()
636
+ } catch (err) {
637
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
638
+ }
639
+ const { files } = await pushTree(sftp, local, p, p, maxFiles)
640
+ return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
641
+ },
642
+ }),
643
+
644
+ defineTool({
645
+ name: 'rw_list_dir',
646
+ description:
647
+ 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
648
+ parameters: {
649
+ path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
650
+ },
651
+ output: {
652
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
653
+ render: (_a, a) => [{ type: 'text', text: a.text }],
654
+ },
655
+ async execute(args) {
656
+ const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
657
+ if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
658
+ return { text: await run(`ls -la --color=never ${shq(p)}`) }
659
+ },
660
+ }),
661
+
662
+ defineTool({
663
+ name: 'rw_read_file',
664
+ description:
665
+ 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
666
+ parameters: {
667
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
668
+ startLine: { type: 'integer', description: '1-based first line (default 1)' },
669
+ endLine: { type: 'integer', description: '1-based last line (inclusive)' },
670
+ maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
671
+ },
672
+ output: {
673
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
674
+ render: (_a, a) => [{ type: 'text', text: a.text }],
675
+ },
676
+ async execute(args) {
677
+ const p = normalizeRemotePath(String(args.path || ''))
678
+ if (!p) throw new Error('rw_read_file: path is required')
679
+ const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
680
+ let from = Math.max(Number(args.startLine) || 1, 1)
681
+ let to = Number(args.endLine) || 0
682
+ if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
683
+ const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
684
+ const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
685
+ let text = numbered === '' ? '(empty or out of range)' : numbered
686
+ if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
687
+ return { text }
688
+ },
689
+ }),
690
+
691
+ defineTool({
692
+ name: 'rw_exec',
693
+ description:
694
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
695
+ parameters: {
696
+ command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
697
+ },
698
+ output: {
699
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
700
+ render: (_a, a) => [{ type: 'text', text: a.text }],
701
+ },
702
+ async execute(args) {
703
+ const cmd = String(args.command || '')
704
+ if (!cmd) throw new Error('rw_exec: command is required')
705
+ return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
706
+ },
707
+ }),
708
+
709
+ defineTool({
710
+ name: 'rw_write_file',
711
+ description:
712
+ '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.',
713
+ parameters: {
714
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
715
+ content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
716
+ mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
717
+ },
718
+ output: {
719
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
720
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
721
+ },
722
+ async execute(args) {
723
+ const p = normalizeRemotePath(String(args.path || ''))
724
+ if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
725
+ const content = String(args.content == null ? '' : args.content)
726
+ let sftp
727
+ try {
728
+ sftp = await pool.sftp()
729
+ } catch (err) {
730
+ throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
731
+ }
732
+ const mkdir = args.mkdir !== false
733
+ if (mkdir) {
734
+ const parent = remoteDirname(p)
735
+ // create each level from the root so mkdir -p semantics survive even
736
+ // when intermediate privileged-parent dirs don't allow create (best effort)
737
+ const segs = parent.split('/').filter(Boolean)
738
+ let cur = ''
739
+ for (const s of segs) {
740
+ cur += '/' + s
741
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
742
+ }
743
+ }
744
+ const buf = Buffer.from(content, 'utf8')
745
+ await sftp.writeFile(p, buf)
746
+ const bytes = Buffer.byteLength(content, 'utf8')
747
+ return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
748
+ },
749
+ }),
750
+
751
+ defineTool({
752
+ name: 'rw_disconnect',
753
+ description:
754
+ 'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
755
+ parameters: {},
756
+ output: {
757
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
758
+ render: (_a, a) => [{ type: 'text', text: a.text }],
759
+ },
760
+ async execute() {
761
+ pool.close()
762
+ return { ok: true, text: 'disconnected' }
763
+ },
764
+ }),
765
+ ]
766
+
767
+ for (const t of tools) {
768
+ ctx.tools.register(t)
769
+ }
770
+
771
+ // ── system-prompt injection: the current remote workspace ─────────────────
772
+ ctx.systemPrompt.section({
773
+ name: 'dsh-remote',
774
+ order: 88,
775
+ text: () => {
776
+ const w = wsPath()
777
+ if (!w || !config.host) return ''
778
+ return (
779
+ '## Remote workspace\n' +
780
+ `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
781
+ '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.'
782
+ )
783
+ },
784
+ })
785
+
786
+ // ── slash command: /remote reports status + connection hints ──────────────
787
+ const commands = ctx.get('commands')
788
+ if (commands !== undefined) {
789
+ commands.register({
790
+ name: 'remote',
791
+ description: 'Show the current remote workspace / connection status and how to use remote tools.',
792
+ handler: (invocation) => {
793
+ const s = status()
794
+ return {
795
+ kind: 'success',
796
+ text:
797
+ `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
798
+ `Remote workspace: ${s.workspace || '(none)'}\n` +
799
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
800
+ (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
801
+ }
802
+ },
803
+ })
804
+ }
805
+
806
+ // ── JSON endpoints for settings UI ─────────────────────────────────────────
807
+ const webServer = ctx.get('webServer')
808
+ if (webServer === undefined) return
809
+
810
+ const sendJson = (res, status, body) => {
811
+ res.statusCode = status
812
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
813
+ res.end(JSON.stringify(body))
814
+ }
815
+ const readBody = (req) =>
816
+ new Promise((resolve) => {
817
+ const chunks = []
818
+ req.on('data', (c) => chunks.push(c))
819
+ req.on('end', () => resolve(chunks.join('')))
820
+ })
821
+
822
+ const routes = [
823
+ {
824
+ kind: 'exact',
825
+ path: '/dsh-remote/status',
826
+ handler: async (req, res) => {
827
+ if (req.method === 'GET') return sendJson(res, 200, status())
828
+ sendJson(res, 405, { error: 'method not allowed' })
829
+ },
830
+ },
831
+ {
832
+ kind: 'exact',
833
+ path: '/dsh-remote/connect',
834
+ handler: async (req, res) => {
835
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
836
+ try {
837
+ const payload = JSON.parse((await readBody(req)) || '{}')
838
+ pool.setTarget({
839
+ host: payload.host,
840
+ port: payload.port,
841
+ username: payload.username,
842
+ password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
843
+ privateKeyPath: payload.privateKeyPath,
844
+ workspace: payload.workspace,
845
+ })
846
+ await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
847
+ return sendJson(res, 200, { ok: true, ...status() })
848
+ } catch (err) {
849
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
850
+ }
851
+ },
852
+ },
853
+ {
854
+ kind: 'exact',
855
+ path: '/dsh-remote/ls',
856
+ handler: async (req, res) => {
857
+ try {
858
+ const m = (req.url || '').match(/path=([^&]*)/)
859
+ const p = m ? decodeURIComponent(m[1]) : wsPath()
860
+ const out = await listDirStructured(p || '/')
861
+ return sendJson(res, 200, { path: p, items: out.items })
862
+ } catch (err) {
863
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
864
+ }
865
+ },
866
+ },
867
+ {
868
+ kind: 'exact',
869
+ path: '/dsh-remote/workspace',
870
+ handler: async (req, res) => {
871
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
872
+ try {
873
+ const payload = JSON.parse((await readBody(req)) || '{}')
874
+ const p = normalizeRemotePath(String(payload.path || ''))
875
+ if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
876
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
877
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
878
+ config.workspace = p
879
+ const local = ensureMirror(p, config.host, config.username, config.port)
880
+ return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
881
+ } catch (err) {
882
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
883
+ }
884
+ },
885
+ },
886
+ {
887
+ kind: 'exact',
888
+ path: '/dsh-remote/mirror',
889
+ handler: async (req, res) => {
890
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
891
+ try {
892
+ const payload = JSON.parse((await readBody(req)) || '{}')
893
+ const p = normalizeRemotePath(String(payload.path || ''))
894
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
895
+ if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
896
+ // optional: verify it's a directory over SSH when connected
897
+ if (pool.client) {
898
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
899
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
900
+ }
901
+ const local = ensureMirror(p, config.host, config.username, config.port)
902
+ config.workspace = p
903
+ return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
904
+ } catch (err) {
905
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
906
+ }
907
+ },
908
+ },
909
+ {
910
+ kind: 'exact',
911
+ path: '/dsh-remote/local-pick',
912
+ handler: async (req, res) => {
913
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
914
+ try {
915
+ const dp = (ctx && ctx.get && ctx.get('directoryPicker')) || (ctx && ctx.directoryPicker) || null
916
+ if (!dp || typeof dp.capability !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器服务不可用(缺少 DSH directory-picker backend)' })
917
+ const cap = await Promise.resolve(dp.capability())
918
+ if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器不可用(当前为非原生/浏览后端,请在输入框手动填本地路径)' })
919
+ const pickAbort = new AbortController()
920
+ const signal = pickAbort.signal || null
921
+ const picked = await Promise.resolve(cap.pick(signal))
922
+ pickAbort.abort()
923
+ if (!picked || typeof picked !== 'string') return sendJson(res, 200, { ok: true, cancelled: true })
924
+ return sendJson(res, 200, { ok: true, path: picked })
925
+ } catch (err) {
926
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
927
+ }
928
+ },
929
+ },
930
+ {
931
+ kind: 'exact',
932
+ path: '/dsh-remote/machines',
933
+ handler: async (req, res) => {
934
+ if (req.method === 'GET') {
935
+ return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
936
+ }
937
+ if (req.method === 'POST') {
938
+ try {
939
+ const body = JSON.parse((await readBody(req)) || '{}')
940
+ const action = body.action || 'add'
941
+ if (action === 'add' || action === 'update') {
942
+ const host = String(body.host || '').trim()
943
+ if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
944
+ const rec = {
945
+ id: body.id || machineId(),
946
+ name: String(body.name || '').trim() || host,
947
+ host,
948
+ port: Number(body.port) || 22,
949
+ username: String(body.username || '').trim() || 'root',
950
+ password: body.password || '',
951
+ privateKeyPath: String(body.privateKeyPath || '').trim(),
952
+ passphrase: body.passphrase || '',
953
+ workspace: String(body.workspace || '').trim(),
954
+ }
955
+ const i = machineIndex(rec.id)
956
+ if (i >= 0) machines[i] = rec; else machines.push(rec)
957
+ if (!store.currentId) { store.currentId = rec.id }
958
+ saveMachines(machines, store.currentId)
959
+ if (store.currentId === rec.id) applyMachine(config, rec)
960
+ return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
961
+ }
962
+ if (action === 'delete') {
963
+ const i = machineIndex(String(body.id || ''))
964
+ if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
965
+ machines.splice(i, 1)
966
+ if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
967
+ saveMachines(machines, store.currentId)
968
+ return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
969
+ }
970
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
971
+ } catch (err) {
972
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
973
+ }
974
+ }
975
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
976
+ },
977
+ },
978
+ {
979
+ kind: 'exact',
980
+ path: '/dsh-remote/test-connect',
981
+ handler: async (req, res) => {
982
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
983
+ try {
984
+ const body = JSON.parse((await readBody(req)) || '{}')
985
+ const probe = new SshPool({
986
+ ...config,
987
+ host: String(body.host || config.host),
988
+ port: Number(body.port) || config.port,
989
+ username: String(body.username || config.username),
990
+ password: String(body.password || ''),
991
+ privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
992
+ passphrase: String(body.passphrase || ''),
993
+ connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
994
+ commandTimeoutMs: 10000,
995
+ })
996
+ const started = Date.now()
997
+ await probe.connect()
998
+ await probe.exec('true', 10000)
999
+ probe.close()
1000
+ return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
1001
+ } catch (err) {
1002
+ return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
1003
+ }
1004
+ },
1005
+ },
1006
+ {
1007
+ kind: 'exact',
1008
+ path: '/dsh-remote/current',
1009
+ handler: async (req, res) => {
1010
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1011
+ try {
1012
+ const body = JSON.parse((await readBody(req)) || '{}')
1013
+ const okSet = setCurrent(String(body.id || ''))
1014
+ if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
1015
+ return sendJson(res, 200, { ok: true, ...status() })
1016
+ } catch (err) {
1017
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1018
+ }
1019
+ },
1020
+ },
1021
+ ]
1022
+
1023
+ const disposers = routes.map((r) => webServer.register(r))
1024
+ ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
915
1025
  }