dsh-remote 0.8.2 → 0.8.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,2431 +1,2431 @@
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
- // SSH agent, keyboard-interactive, proxy jump),
6
- // • a "current remote workspace" — a remote directory the agent treats as the
7
- // active project root (user@host:/path) — injected into every system prompt,
8
- // • model tools `rw_*` (info/connect/workspace/list/read/write/edit/append/mkdir/
9
- // remove/move/stat/exec/search/download/upload/sync/push/forward/disconnect),
10
- // • JSON endpoints the client settings page + sidebar use over the harness
11
- // `webServer` (machines / ls / read / write / fs / forwards / task / audit /
12
- // ssh-config / local-pick / …),
13
- // • local mirror of the remote workspace (three-way conflict-aware SFTP sync),
14
- // • optional OS-keychain password storage, command audit log, TOFU host keys.
15
- //
16
- // The engine (path guard + shell quoting + ssh pool + exec) keeps the proven
17
- // foundation; `ctx.fs` / the local workspace registry stay untouched — this is a
18
- // REMOTE workspace presented as such to the model and UI.
19
- //
20
- // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
21
- import z from '@deepseek-ai/schemastery'
22
- import { defineTool } from '@deepseek-ai/dsh-tools'
23
- import ssh2 from 'ssh2'
24
- import { execFile } from 'node:child_process'
25
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs'
26
- import { homedir } from 'node:os'
27
- import path from 'node:path'
28
- import iconv from 'iconv-lite'
29
-
30
- import {
31
- shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
32
- toSftpPath, remotePathBase, truncate, shortHash, relPathUnder,
33
- } from './paths.js'
34
- import { blobAlgorithm, keyFingerprint } from './hostkey.js'
35
- import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
36
- import { friendlyMessage } from './errors.js'
37
- import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js'
38
- import { saveSecret, getSecret, deleteSecret, platformBackend } from './credential.js'
39
- import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
40
- import { searchTree } from './search.js'
41
- import { TaskManager } from './tasks.js'
42
- import { ForwardManager } from './forwards.js'
43
- import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
44
-
45
- const { Client } = ssh2
46
-
47
- export const name = 'dsh-remote'
48
-
49
- // tools + systemPrompt + webServer are required. webServer is INJECTED (not just
50
- // lazily read) so this plugin activates only after the web server is up — otherwise
51
- // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register.
52
- export const inject = ['tools', 'systemPrompt', 'webServer']
53
-
54
- export const Config = z.object({
55
- /** Remote SSH host (empty → the plugin starts disconnected). */
56
- host: z.string().default(''),
57
- /** Remote SSH port (22 unless the machine uses a custom port). */
58
- port: z.number().step(1).min(1).max(65535).default(22),
59
- /** SSH login user. */
60
- username: z.string().default(''),
61
- /** Password login (only when the remote has no key. Override the fallback below). */
62
- password: z.string().default(''),
63
- /** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
64
- privateKeyPath: z.string().default(''),
65
- /** Key passphrase when the key is encrypted. */
66
- passphrase: z.string().default(''),
67
- /** Initial remote workspace path (absolute dir the agent should treat as root). */
68
- workspace: z.string().default(''),
69
- /** Per-command timeout. */
70
- commandTimeoutMs: z.number().step(1).min(1000).default(20000),
71
- /** SSH connection establishment timeout. */
72
- connectTimeoutMs: z.number().step(1).min(1000).default(15000),
73
- /** Hard ceiling on collected remote output per call. */
74
- maxOutputChars: z.number().step(1).min(1024).default(200000),
75
- /** Skip mirroring files larger than this many bytes (0 = no cap). */
76
- maxFileBytes: z.number().step(1).min(0).default(52428800),
77
- /** Host-key policy: `accept-new` (default) records a host's key on first
78
- * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
79
- * accept-new); `verify` also rejects hosts never seen before; `off` skips
80
- * verification entirely (MITM-unsafe, not recommended). */
81
- hostKeyMode: z.string().default('accept-new'),
82
- /** Use the OpenSSH agent (SSH_AUTH_SOCK) when no password/key is configured. */
83
- useAgent: z.boolean().default(false),
84
- /** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
85
- keyboardInteractive: z.boolean().default(false),
86
- /** Jump host / bastion: connect through this machine first. All fields have
87
- * defaults so this works on schemastery versions without `.optional()`; an
88
- * empty `host` means "no jump host". */
89
- proxy: z.object({
90
- host: z.string().default(''),
91
- port: z.number().step(1).min(1).max(65535).default(22),
92
- username: z.string().default(''),
93
- password: z.string().default(''),
94
- privateKeyPath: z.string().default(''),
95
- }),
96
- /** Auto-push edited mirror files back to the remote (watcher, debounced). Default off. */
97
- autoPush: z.boolean().default(false),
98
- /** Append executed commands to the audit log under the harness home. */
99
- auditLog: z.boolean().default(true),
100
- /** Text encoding for remote file reads/writes (utf-8 default; gbk etc.). */
101
- encoding: z.string().default('utf-8'),
102
- /** Update mode: `manual` (default) only checks when asked; `auto` checks on
103
- * load and periodically, applying a newer npm release automatically;
104
- * `off` disables version checks entirely. (schemastery 3.18 has no .enum —
105
- * keep string and validate in code.) */
106
- updateMode: z.string().default('manual'),
107
- /** How often (ms) auto mode checks the npm registry for a newer release. */
108
- updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
109
- })
110
-
111
- // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
112
-
113
- /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
114
- * own `userData/harness`), otherwise fall back to `~/.dsh`. */
115
- function dshBase() {
116
- const env = process.env.DSH_HOME
117
- if (env && String(env).trim()) return path.resolve(String(env).trim())
118
- return path.join(homedir(), '.dsh')
119
- }
120
-
121
- /** Root holding every remote host's mirrors + the machine registry. */
122
- function remoteWorkspacesRoot() {
123
- return path.join(dshBase(), 'remote-workspaces')
124
- }
125
-
126
- /** Local mirrors of one remote host. */
127
- function mirrorRootFor(host, user, port) {
128
- const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
129
- return path.join(remoteWorkspacesRoot(), tag)
130
- }
131
-
132
- /** Local mirror dir for a specific remote path (idempotent → returns same dir). */
133
- function mirrorDirFor(remotePath, host, user, port) {
134
- const base = remotePathBase(remotePath)
135
- const root = mirrorRootFor(host, user, port)
136
- const plain = path.join(root, base)
137
- const norm = normalizeRemotePath(remotePath)
138
- // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
139
- try {
140
- const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
141
- if (meta.remotePath === norm) return plain
142
- } catch {
143
- /* no mirror yet → fall through */
144
- }
145
- if (!existsSync(plain)) return plain
146
- return path.join(root, base + '-' + shortHash(norm))
147
- }
148
-
149
- /** Create the local mirror dir + a meta file describing its remote origin. */
150
- function ensureMirror(remotePath, host, user, port) {
151
- const dir = mirrorDirFor(remotePath, host, user, port)
152
- mkdirSync(dir, { recursive: true })
153
- writeFileSync(
154
- path.join(dir, '.dsh-remote-meta.json'),
155
- JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
156
- )
157
- return dir
158
- }
159
-
160
- /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
161
- function copyDirSync(from, to) {
162
- mkdirSync(to, { recursive: true })
163
- for (const e of readdirSync(from, { withFileTypes: true })) {
164
- const s = path.join(from, e.name)
165
- const d = path.join(to, e.name)
166
- if (e.isDirectory()) copyDirSync(s, d)
167
- else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
168
- }
169
- }
170
-
171
- /** One-time migration of pre-0.6 data (~/.dsh/remote-workspaces) into DSH_HOME.
172
- * Only runs when the harness is on its DEFAULT home: an explicitly-set DSH_HOME
173
- * pointing elsewhere means ~/.dsh belongs to another installation — migrating
174
- * would RENAME that other installation's live data out from under it. */
175
- function migrateLegacyData() {
176
- const env = process.env.DSH_HOME
177
- if (env && String(env).trim() && path.resolve(String(env).trim()) !== path.join(homedir(), '.dsh')) return
178
- const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
179
- const target = remoteWorkspacesRoot()
180
- if (legacy === target || !existsSync(legacy) || existsSync(target)) return
181
- try {
182
- mkdirSync(path.dirname(target), { recursive: true })
183
- try {
184
- renameSync(legacy, target)
185
- } catch (err) {
186
- if (err.code !== 'EXDEV') throw err
187
- copyDirSync(legacy, target)
188
- }
189
- } catch {
190
- // Migration is best-effort: a fresh registry is created on next save.
191
- }
192
- }
193
-
194
- // ── persistent multi-machine registry ─────────────────────────────────────
195
- const MACHINES_FILE = 'machines.json'
196
- const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
197
- const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
198
- const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
199
- const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
200
- const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
201
-
202
- function loadMachines() {
203
- try {
204
- const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
205
- if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
206
- } catch {}
207
- return { list: [], currentId: null }
208
- }
209
- function saveMachines(list, currentId) {
210
- try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
211
- writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
212
- }
213
- function sanitizeMachine(m) {
214
- if (!m) return m
215
- const { password, proxy, ...rest } = m
216
- const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
217
- if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
218
- return out
219
- }
220
- /** Apply a machine's fields onto the live config object (pool + tools read it). */
221
- function applyMachine(config, m) {
222
- if (!m) return
223
- config.host = m.host
224
- config.port = Number(m.port) || 22
225
- config.username = m.username || ''
226
- config.password = m.password || ''
227
- config.privateKeyPath = m.privateKeyPath || ''
228
- config.passphrase = m.passphrase || ''
229
- config.workspace = m.workspace || (config.workspace || '')
230
- if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
231
- if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
232
- if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
233
- config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
234
- }
235
- function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
236
-
237
- // ── host-key registry (TOFU) ──────────────────────────────────────────────
238
- const KNOWN_HOSTS_FILE = 'known_hosts.json'
239
- const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
240
-
241
- function loadKnownHosts() {
242
- try {
243
- const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
244
- if (j && typeof j === 'object' && !Array.isArray(j)) return j
245
- } catch {}
246
- return {}
247
- }
248
-
249
- /** Build an ssh2 `hostVerifier` bound to the current config. */
250
- function createHostKeyGuard(config) {
251
- const id = () => `${config.host}:${config.port}`
252
- const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
253
- ? config.hostKeyMode
254
- : 'accept-new'
255
- const guard = {
256
- mode,
257
- lastError: null,
258
- knownHosts: loadKnownHosts,
259
- forgetHost() {
260
- const kh = loadKnownHosts()
261
- delete kh[id()]
262
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
263
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
264
- },
265
- verifier(key) {
266
- if (mode === 'off') return true
267
- const fp = keyFingerprint(key)
268
- const kh = loadKnownHosts()
269
- const stored = kh[id()]
270
- if (stored) {
271
- if (stored.fingerprint === fp) return true
272
- guard.lastError =
273
- `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
274
- 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
275
- return false
276
- }
277
- if (mode === 'verify') {
278
- guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
279
- return false
280
- }
281
- kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
282
- try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
283
- writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
284
- return true
285
- },
286
- }
287
- return guard
288
- }
289
-
290
- /** Whether the current target's key has been recorded/trusted before. */
291
- function isHostKeyKnown(host, port) {
292
- return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
293
- }
294
-
295
- // ── SSH pool (key / password / agent / keyboard-interactive / proxy) ──────
296
-
297
- class SshPool {
298
- constructor(config) {
299
- this.config = config
300
- this.client = null
301
- this.connecting = null
302
- this.proxyPool = null
303
- // Generational token: bumped on every target change / close so a stale
304
- // in-flight connect can never hand this pool a connection to an old host.
305
- this.epoch = 0
306
- /** Optional async resolver for a machine-stored (keychain) password. */
307
- this.passwordResolver = null
308
- /** Optional hook called with the live client after a successful connect. */
309
- this.onReady = null
310
- /** Optional hook called when the pool closes. */
311
- this.onCloseHook = null
312
- }
313
-
314
- resolveKeyPath() {
315
- const p = this.config.privateKeyPath
316
- if (!p) return ''
317
- if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
318
- return p
319
- }
320
-
321
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
322
- if (host !== undefined) this.config.host = String(host)
323
- if (port !== undefined && Number(port)) this.config.port = Number(port)
324
- if (username !== undefined) this.config.username = String(username)
325
- if (password !== undefined && password !== null) this.config.password = String(password)
326
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
327
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
328
- if (workspace !== undefined) this.config.workspace = String(workspace)
329
- if (useAgent !== undefined) this.config.useAgent = !!useAgent
330
- if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
331
- if (proxy !== undefined) this.config.proxy = proxy
332
- if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
333
- this.close()
334
- return this
335
- }
336
-
337
- connect() {
338
- if (this.client) return Promise.resolve(this.client)
339
- if (this.connecting) return this.connecting
340
- const epoch = this.epoch
341
- const pending = this._doConnect(epoch)
342
- this.connecting = pending
343
- const clear = () => {
344
- if (this.epoch === epoch && this.connecting === pending) this.connecting = null
345
- }
346
- pending.then(clear, clear)
347
- return pending
348
- }
349
-
350
- async _doConnect(epoch) {
351
- const isCurrent = () => this.epoch === epoch
352
- const guard = createHostKeyGuard(this.config)
353
- const client = new Client()
354
- let settled = false
355
- const fail = (err) => {
356
- if (settled) return
357
- settled = true
358
- if (isCurrent() && this.client === client) this.client = null
359
- throw guard.lastError ? new Error(guard.lastError) : err
360
- }
361
-
362
- // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
363
- let sock = null
364
- const proxyCfg = this.config.proxy
365
- if (proxyCfg && proxyCfg.host) {
366
- try {
367
- this.proxyPool = new SshPool({
368
- ...this.config,
369
- host: proxyCfg.host,
370
- port: Number(proxyCfg.port) || 22,
371
- username: proxyCfg.username || this.config.username || 'root',
372
- password: proxyCfg.password || '',
373
- privateKeyPath: proxyCfg.privateKeyPath || '',
374
- passphrase: proxyCfg.passphrase || '',
375
- proxy: undefined,
376
- })
377
- const pclient = await this.proxyPool.connect()
378
- if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
379
- sock = await new Promise((res, rej) => {
380
- pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
381
- })
382
- } catch (err) {
383
- return fail(err)
384
- }
385
- }
386
-
387
- return new Promise((resolve, reject) => {
388
- const rejectOnce = (err) => {
389
- if (settled) return
390
- settled = true
391
- if (isCurrent() && this.client === client) this.client = null
392
- reject(guard.lastError ? new Error(guard.lastError) : err)
393
- }
394
- client.on('ready', () => {
395
- if (settled) return
396
- settled = true
397
- if (!isCurrent()) {
398
- try { client.end() } catch {}
399
- reject(new Error('ssh target changed during connect'))
400
- return
401
- }
402
- this.client = client
403
- resolve(client)
404
- if (this.onReady) { try { this.onReady(client) } catch {} }
405
- })
406
- client.on('error', (e) => rejectOnce(e))
407
- client.on('close', () => {
408
- if (isCurrent() && this.client === client) this.client = null
409
- rejectOnce(new Error('ssh connection closed'))
410
- })
411
-
412
- const buildOpts = async () => {
413
- const opts = {
414
- host: this.config.host,
415
- port: this.config.port,
416
- username: this.config.username,
417
- readyTimeout: this.config.connectTimeoutMs,
418
- keepaliveInterval: 15000,
419
- keepaliveCountMax: 3,
420
- hostVerifier: (key) => guard.verifier(key),
421
- }
422
- if (sock) opts.sock = sock
423
- if (this.config.useAgent) {
424
- const sockPath = process.env.SSH_AUTH_SOCK
425
- if (sockPath) opts.agent = sockPath
426
- }
427
- let password = this.config.password || ''
428
- if (!password && this.passwordResolver) {
429
- try { password = (await this.passwordResolver()) || '' } catch {}
430
- }
431
- if (password) {
432
- opts.password = password
433
- opts.tryKeyboard = true
434
- } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
435
- opts.tryKeyboard = true
436
- }
437
- if (this.config.privateKeyPath) {
438
- const keyPath = this.resolveKeyPath()
439
- if (!keyPath) {
440
- throw new Error('no credentials: set a password or a privateKeyPath to connect')
441
- }
442
- let key
443
- try {
444
- key = readFileSync(keyPath)
445
- } catch (err) {
446
- throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
447
- }
448
- opts.privateKey = key
449
- opts.passphrase = this.config.passphrase || undefined
450
- } else if (!password && !opts.agent) {
451
- throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
452
- }
453
- return opts
454
- }
455
-
456
- buildOpts().then(
457
- (opts) => {
458
- if (opts.tryKeyboard) {
459
- client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
460
- finish(prompts.map(() => this.config.password || ''))
461
- })
462
- }
463
- client.connect(opts)
464
- },
465
- (err) => rejectOnce(err),
466
- )
467
- })
468
- }
469
-
470
- /** Run one remote command; resolves { code, signal, stdout, stderr }.
471
- * Accepts `exec(cmd, timeoutMs)` (legacy) or `exec(cmd, {timeoutMs, pty, env})`. */
472
- exec(command, timeoutMsOrOpts) {
473
- const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
474
- const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
475
- return this.connect().then(
476
- (client) =>
477
- new Promise((resolve, reject) => {
478
- let retried = false
479
- const runOn = (c) => {
480
- const execOpts = {}
481
- if (opts.pty) execOpts.pty = true
482
- if (opts.env && typeof opts.env === 'object') execOpts.env = opts.env
483
- c.exec(command, execOpts, (err, stream) => {
484
- if (err) {
485
- // Channel-open failure usually means the pooled connection died
486
- // server-side (idle timeout / network reset) while keepalive
487
- // hadn't noticed. Drop it and retry ONCE on a fresh connection.
488
- if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
489
- retried = true
490
- this.invalidate()
491
- return this.connect().then(
492
- (fresh) => runOn(fresh),
493
- (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
494
- )
495
- }
496
- return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
497
- }
498
- let stdout = ''
499
- let stderr = ''
500
- let settled = false
501
- let exitCode = null
502
- let exitSignal = null
503
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
504
- const settle = () => {
505
- if (settled) return
506
- settled = true
507
- clearTimeout(timer)
508
- resolve({
509
- code: exitCode,
510
- signal: exitSignal,
511
- stdout: truncate(stdout, this.config.maxOutputChars),
512
- stderr: truncate(stderr, this.config.maxOutputChars),
513
- })
514
- }
515
- const timer = setTimeout(() => {
516
- if (settled) return
517
- exitCode = -1
518
- exitSignal = 'TIMEOUT'
519
- // Kill the remote command (SIGTERM) rather than just dropping the
520
- // channel, so a runaway process cannot keep running and holding
521
- // the SSH connection after we've given up on its output.
522
- try {
523
- if (typeof stream.signal === 'function') stream.signal('SIGTERM')
524
- } catch {}
525
- const hardClose = setTimeout(() => {
526
- try { stream.close() } catch {}
527
- }, 800)
528
- if (typeof hardClose.unref === 'function') hardClose.unref()
529
- settle()
530
- }, timeoutMs)
531
- stream.on('close', (code, signal) => {
532
- if (settled) return
533
- exitCode = code
534
- exitSignal = signal
535
- settle()
536
- })
537
- stream.on('data', (d) => {
538
- if (stdout.length < hardCap) stdout += d
539
- })
540
- stream.stderr.on('data', (d) => {
541
- if (stderr.length < hardCap) stderr += d
542
- })
543
- stream.on('error', (e) => {
544
- if (settled) return
545
- settled = true
546
- clearTimeout(timer)
547
- reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
548
- })
549
- })
550
- }
551
- runOn(client)
552
- }),
553
- )
554
- }
555
-
556
- /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
557
- sftp() {
558
- return this.connect().then(
559
- (client) =>
560
- new Promise((resolve, reject) => {
561
- let retried = false
562
- const runOn = (c) => {
563
- c.sftp((err, sftp) => {
564
- if (err) {
565
- // Same dead-connection recovery as exec(): a channel open
566
- // failure means the pooled connection is stale — drop it and
567
- // retry ONCE on a fresh connection.
568
- if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
569
- retried = true
570
- this.invalidate()
571
- return this.connect().then(
572
- (fresh) => runOn(fresh),
573
- (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
574
- )
575
- }
576
- return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
577
- }
578
- const withTimeout = (fn) => (...args) =>
579
- new Promise((r2, j2) => {
580
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
581
- const done = (e, v) => {
582
- clearTimeout(timer)
583
- e ? j2(e) : r2(v)
584
- }
585
- try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
586
- })
587
- const P = (p) => toSftpPath(p)
588
- resolve({
589
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
590
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
591
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
592
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
593
- rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
594
- unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
595
- rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
596
- realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
597
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
598
- writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
599
- fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
600
- fastPut: (p, lp) => withTimeout((d, l, cb) => sftp.fastPut(d, l, cb))(P(p), lp),
601
- })
602
- })
603
- }
604
- runOn(client)
605
- }),
606
- )
607
- }
608
-
609
- /**
610
- * Drop the cached client and force a fresh connection on the next call.
611
- * Called when a channel open fails (e.g. "Channel open failure: open
612
- * failed") — the pooled SSH connection is usually dead server-side while
613
- * keepalive has not yet noticed, and reusing it keeps failing. The epoch
614
- * bump orphans any in-flight connect; the client is ended so ssh2 frees
615
- * its sockets.
616
- */
617
- invalidate() {
618
- this.epoch++
619
- const client = this.client
620
- this.client = null
621
- const pending = this.connecting
622
- this.connecting = null
623
- if (pending && typeof pending.catch === 'function') {
624
- try { pending.catch(() => {}) } catch {}
625
- }
626
- if (this.proxyPool) {
627
- try { this.proxyPool.close() } catch {}
628
- this.proxyPool = null
629
- }
630
- if (client) {
631
- try { client.end() } catch {}
632
- }
633
- }
634
-
635
- close() {
636
- this.epoch++
637
- const client = this.client
638
- this.client = null
639
- const pending = this.connecting
640
- this.connecting = null
641
- if (pending && typeof pending.catch === 'function') {
642
- try { pending.catch(() => {}) } catch {}
643
- }
644
- if (this.proxyPool) {
645
- try { this.proxyPool.close() } catch {}
646
- this.proxyPool = null
647
- }
648
- if (client) {
649
- try {
650
- client.end()
651
- } catch {}
652
- }
653
- if (this.onCloseHook) {
654
- try { this.onCloseHook() } catch {}
655
- }
656
- }
657
- }
658
-
659
- // ── encoding helpers ───────────────────────────────────────────────────────
660
-
661
- function decodeBuf(buf, enc) {
662
- const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
663
- return e ? iconv.decode(buf, e) : buf.toString('utf8')
664
- }
665
- function encodeText(s, enc) {
666
- const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
667
- return e ? iconv.encode(String(s), e) : Buffer.from(String(s), 'utf8')
668
- }
669
-
670
- // ── apply ─────────────────────────────────────────────────────────────────
671
-
672
- export async function apply(ctx, config) {
673
- const pool = new SshPool(config)
674
- ctx.effect(() => () => pool.close(), 'dsh-remote.close')
675
-
676
- migrateLegacyData()
677
-
678
- // ── machine registry (multi-host) ─────────────────────────────────────────
679
- const store = loadMachines()
680
- const machines = store.list
681
- const machineIndex = (id) => machines.findIndex((m) => m.id === id)
682
-
683
- /** The machine the pool is currently bound to: an ephemeral tool connection
684
- * (rw_connect save:false) wins, then the stored current, then config default. */
685
- let ephemeral = null
686
- const activeMachine = () => {
687
- if (ephemeral) return ephemeral
688
- if (store.currentId) {
689
- const i = machineIndex(store.currentId)
690
- if (i >= 0) return machines[i]
691
- }
692
- 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 }
693
- return null
694
- }
695
- const currentMachine = activeMachine
696
-
697
- /** Resolve a machine's effective password (keychain backend support). */
698
- const machinePassword = async (m) => {
699
- if (m && m.password) return m.password
700
- if (m && m.credentialBackend && m.credentialBackend !== 'plain') {
701
- const p = await getSecret(m.id, secretsDir())
702
- if (p) return p
703
- }
704
- return ''
705
- }
706
-
707
- // The pool resolves keychain-stored passwords lazily at connect time.
708
- pool.passwordResolver = async () => {
709
- const m = activeMachine()
710
- return machinePassword(m)
711
- }
712
-
713
- const applyActiveMachine = async () => {
714
- const m = activeMachine()
715
- if (!m || !m.host) return
716
- const pw = await machinePassword(m)
717
- applyMachine(config, { ...m, password: pw || m.password || '' })
718
- pool.setTarget({
719
- host: config.host, port: config.port, username: config.username,
720
- password: config.password, privateKeyPath: config.privateKeyPath,
721
- passphrase: config.passphrase, workspace: config.workspace,
722
- useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
723
- proxy: config.proxy, hostKeyMode: config.hostKeyMode,
724
- })
725
- }
726
-
727
- const setCurrent = async (id) => {
728
- const i = machineIndex(id)
729
- if (i < 0) return false
730
- store.currentId = id
731
- ephemeral = null
732
- saveMachines(machines, id)
733
- await applyActiveMachine()
734
- return true
735
- }
736
-
737
- // If no stored current, adopt a CLI-provided default as the active machine.
738
- {
739
- const cur = currentMachine()
740
- if (cur && cur.host && !store.currentId) applyMachine(config, cur)
741
- }
742
-
743
- /** Persist the active workspace on the machine the pool is actually bound to
744
- * (fixes the old bug where a tool-connected machine saved its workspace onto
745
- * the registry's current machine instead). */
746
- const persistWorkspace = (p) => {
747
- config.workspace = p
748
- const m = activeMachine()
749
- if (m) {
750
- if (store.currentId && m.id === store.currentId && machineIndex(m.id) >= 0) {
751
- const rec = machines[machineIndex(m.id)]
752
- rec.workspace = p
753
- rec.recentWorkspaces = [p, ...(rec.recentWorkspaces || []).filter((x) => x !== p)].slice(0, 8)
754
- saveMachines(machines, store.currentId)
755
- } else if (ephemeral) {
756
- ephemeral.workspace = p
757
- }
758
- }
759
- }
760
-
761
- // ── audit log ─────────────────────────────────────────────────────────────
762
- const audit = (op, cmd, code) => {
763
- if (!config.auditLog) return
764
- try {
765
- const line = [new Date().toISOString(), `${config.username || '?'}@${config.host || '?'}:${config.port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
766
- appendFileSync(auditFile(), line, 'utf8')
767
- } catch {}
768
- }
769
- const readAudit = (limit) => {
770
- try {
771
- const text = readFileSync(auditFile(), 'utf8')
772
- const lines = text.split('\n').filter(Boolean)
773
- return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
774
- } catch {
775
- return []
776
- }
777
- }
778
-
779
- // ── ignore rules (defaults + user file) ───────────────────────────────────
780
- const ignoreMatcher = () => {
781
- try {
782
- const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
783
- return compileIgnore(DEFAULT_IGNORE.concat(fromFile))
784
- } catch {
785
- return compileIgnore(DEFAULT_IGNORE)
786
- }
787
- }
788
-
789
- // ── task + forward managers ───────────────────────────────────────────────
790
- const tasks = new TaskManager()
791
- const forwards = new ForwardManager(pool, { file: forwardsFile() })
792
- pool.onReady = (client) => forwards.attach(client)
793
- pool.onCloseHook = () => forwards.detach()
794
-
795
- // ── auto-push watcher (config.autoPush, default off) ──────────────────────
796
- // Watches the local mirror; local edits are pushed back to the remote after a
797
- // 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
798
- // change is never clobbered — it is recorded as a conflict in the audit log).
799
- const autoPushWatchers = new Map() // localDir → { watcher, pending:Set, timer }
800
- const flushAutoPush = async (localDir) => {
801
- const entry = autoPushWatchers.get(localDir)
802
- if (!entry || !entry.pending.size) return
803
- const rels = [...entry.pending]
804
- entry.pending.clear()
805
- const ws = wsPath()
806
- if (!ws || mirrorDirFor(ws, config.host, config.username, config.port) !== localDir) return
807
- let sftp
808
- try { sftp = await pool.sftp() } catch { return }
809
- const matcher = ignoreMatcher()
810
- const state = loadSyncState(localDir)
811
- const next = { ...state }
812
- if (rels.includes('*')) {
813
- const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
814
- Object.assign(next, r.nextState)
815
- for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1)
816
- } else {
817
- for (const rel of rels) {
818
- const r = await pushOneFile(sftp, localDir, ws, rel, { maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
819
- if (r.status === 'pushed' && r.state) Object.assign(next, r.state)
820
- else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1)
821
- }
822
- }
823
- saveSyncState(localDir, next)
824
- }
825
- const startAutoPush = (localDir) => {
826
- if (!config.autoPush || autoPushWatchers.has(localDir)) return
827
- const entry = { pending: new Set(), timer: null, watcher: null }
828
- const schedule = () => {
829
- if (entry.timer) clearTimeout(entry.timer)
830
- entry.timer = setTimeout(() => flushAutoPush(localDir), 3000)
831
- }
832
- const onEvent = (eventType, filename) => {
833
- if (!filename) { entry.pending.add('*'); return schedule() }
834
- const rel = String(filename).replace(/\\/g, '/')
835
- if (rel === '.dsh-remote-meta.json' || rel === '.dsh-remote-sync-state.json' || rel.startsWith('.dsh-remote-sync-state.json.tmp')) return
836
- entry.pending.add(rel)
837
- schedule()
838
- }
839
- try {
840
- entry.watcher = watch(localDir, { recursive: true }, onEvent)
841
- } catch {
842
- try { entry.watcher = watch(localDir, onEvent) } catch { return }
843
- }
844
- autoPushWatchers.set(localDir, entry)
845
- }
846
- ctx.effect(() => () => {
847
- for (const e of autoPushWatchers.values()) {
848
- if (e.timer) clearTimeout(e.timer)
849
- try { e.watcher && e.watcher.close() } catch {}
850
- }
851
- autoPushWatchers.clear()
852
- }, 'dsh-remote.autopush')
853
-
854
- const run = async (cmd, opts = {}) => {
855
- const res = await pool.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
856
- const parts = []
857
- if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
858
- if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
859
- if (!parts.length) parts.push('(no output)')
860
- let text = parts.join('\n')
861
- if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
862
- else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
863
- return text
864
- }
865
-
866
- /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
867
- * works on any remote: POSIX / cmd.exe / PowerShell). */
868
- const listDirStructured = async (p) => {
869
- const target = normalizeRemotePath(p || '/')
870
- let sftp
871
- try {
872
- sftp = await pool.sftp()
873
- } catch (err) {
874
- throw new Error('browse failed: ' + ((err && err.message) || err))
875
- }
876
- let list
877
- try {
878
- list = await sftp.readdir(target)
879
- } catch (err) {
880
- throw new Error('browse failed: ' + ((err && err.message) || err))
881
- }
882
- const items = []
883
- const symIdx = []
884
- for (const e of list) {
885
- const name = String(e.filename)
886
- if (name === '.' || name === '..' || !name) continue
887
- const a = e.attrs || {}
888
- let type
889
- if (a.isSymbolicLink && a.isSymbolicLink()) {
890
- type = 'symlink'
891
- symIdx.push(items.length)
892
- } else if (a.isDirectory && a.isDirectory()) {
893
- type = 'dir'
894
- } else {
895
- type = 'file'
896
- }
897
- items.push({
898
- type,
899
- name,
900
- size: typeof a.size === 'number' ? a.size : 0,
901
- mtime: typeof a.mtime === 'number' ? a.mtime : 0,
902
- mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
903
- })
904
- }
905
- // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
906
- if (symIdx.length) {
907
- await Promise.all(symIdx.map(async (i) => {
908
- const full = joinRemotePath(target, items[i].name)
909
- try {
910
- const st = await sftp.lstat(full)
911
- items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
912
- } catch { /* degrade to file */ }
913
- }))
914
- }
915
- return { path: target, items }
916
- }
917
-
918
- const isRemoteDir = async (p) => {
919
- const target = normalizeRemotePath(p)
920
- try {
921
- const sftp = await pool.sftp()
922
- const st = await sftp.stat(target)
923
- return !!(st && st.isDirectory && st.isDirectory())
924
- } catch {
925
- return false
926
- }
927
- }
928
-
929
- /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
930
- const removeRemoteTree = async (sftp, p, maxFiles = 2000) => {
931
- let removed = 0
932
- const walk = async (dir) => {
933
- if (removed >= maxFiles) return
934
- let entries = []
935
- try { entries = (await sftp.readdir(dir)) || [] } catch { return }
936
- for (const e of entries) {
937
- if (removed >= maxFiles) return
938
- const name = String(e.filename)
939
- if (name === '.' || name === '..') continue
940
- const fp = joinRemotePath(dir, name)
941
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
942
- if (isDir) await walk(fp)
943
- else {
944
- try { await sftp.unlink(fp); removed++ } catch {}
945
- }
946
- }
947
- try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
948
- }
949
- await walk(p)
950
- return removed
951
- }
952
-
953
- // ── remote workspace state ────────────────────────────────────────────────
954
- const wsPath = () => (config.workspace || '').trim()
955
- if (config.autoPush && wsPath()) {
956
- startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
957
- }
958
- const status = () => ({
959
- host: config.host,
960
- port: config.port,
961
- username: config.username,
962
- connected: !!pool.client,
963
- workspace: wsPath(),
964
- localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
965
- currentId: store.currentId || null,
966
- activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
967
- machines: machines.map(sanitizeMachine),
968
- hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
969
- hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
970
- forwards: forwards.list(),
971
- auditEnabled: !!config.auditLog,
972
- backend: platformBackend(),
973
- })
974
-
975
- // ── tools ─────────────────────────────────────────────────────────────────
976
-
977
- const textOut = {
978
- schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
979
- render: (_a, v) => [{ type: 'text', text: v.text }],
980
- }
981
- const okOut = {
982
- schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
983
- render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'ok' : 'failed') }],
984
- }
985
-
986
- const tools = [
987
- defineTool({
988
- name: 'rw_info',
989
- description:
990
- 'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
991
- parameters: {},
992
- output: textOut,
993
- async execute() {
994
- const s = status()
995
- const lines = [
996
- `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
997
- `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
998
- `Local mirror: ${s.localMirror || '(none)'}`,
999
- `Connected: ${s.connected ? 'yes' : 'no'}`,
1000
- `Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
1001
- `Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
1002
- '',
1003
- ]
1004
- if (s.host && s.workspace) {
1005
- try {
1006
- const res = await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1007
- if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
1008
- else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
1009
- else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
1010
- } catch (err) {
1011
- lines.push('Ping: FAILED — ' + friendlyMessage(err, s))
1012
- }
1013
- } else {
1014
- lines.push('No host + workspace configured — call rw_connect with a host to get started.')
1015
- }
1016
- return { text: lines.join('\n') }
1017
- },
1018
- }),
1019
-
1020
- defineTool({
1021
- name: 'rw_connect',
1022
- description:
1023
- 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
1024
- parameters: {
1025
- host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
1026
- username: { type: 'string', description: 'SSH user (default from config or root)' },
1027
- port: { type: 'integer', description: 'SSH port (default 22)' },
1028
- password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
1029
- privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
1030
- save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
1031
- },
1032
- output: textOut,
1033
- async execute(args) {
1034
- const host = String(args.host || '').trim()
1035
- if (!host) throw new Error('rw_connect: host is required')
1036
- const user = args.username || config.username || 'root'
1037
- const port = Number(args.port) || undefined
1038
- const rec = {
1039
- host,
1040
- port: port || 22,
1041
- username: user,
1042
- password: args.password !== undefined ? String(args.password) : '',
1043
- privateKeyPath: args.privateKeyPath || '',
1044
- }
1045
- if (args.save !== false) {
1046
- // Upsert into the registry and make it the current machine so the
1047
- // settings UI and the tools always agree on who is active.
1048
- const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
1049
- if (i >= 0) {
1050
- machines[i] = { ...machines[i], ...rec, id: machines[i].id, password: rec.password || machines[i].password || '' }
1051
- store.currentId = machines[i].id
1052
- saveMachines(machines, store.currentId)
1053
- } else {
1054
- const id = machineId()
1055
- machines.push({ id, name: host, ...rec })
1056
- store.currentId = id
1057
- saveMachines(machines, store.currentId)
1058
- }
1059
- ephemeral = null
1060
- await applyActiveMachine()
1061
- } else {
1062
- ephemeral = { id: machineId(), name: host, ...rec }
1063
- pool.setTarget({
1064
- host, port: port || 22, username: user,
1065
- password: rec.password, privateKeyPath: rec.privateKeyPath,
1066
- workspace: config.workspace,
1067
- })
1068
- }
1069
- try {
1070
- const res = await pool.exec('echo ok', { timeoutMs: 8000 })
1071
- if (res.code !== 0 && !res.stdout) {
1072
- audit('connect', `connect ${user}@${host}:${port || 22}`, res.code)
1073
- return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
1074
- }
1075
- audit('connect', `connect ${user}@${host}:${port || 22}`, 0)
1076
- return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1077
- } catch (err) {
1078
- throw new Error(friendlyMessage(err, { host, port }))
1079
- }
1080
- },
1081
- }),
1082
-
1083
- defineTool({
1084
- name: 'rw_pick_workspace',
1085
- description:
1086
- '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.',
1087
- parameters: {
1088
- path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
1089
- },
1090
- output: textOut,
1091
- async execute(args) {
1092
- const p = normalizeRemotePath(String(args.path || ''))
1093
- if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1094
- const ok = await isRemoteDir(p)
1095
- if (!ok) return { text: `not a directory (or missing) on ${p}` }
1096
- persistWorkspace(p)
1097
- const local = ensureMirror(p, config.host, config.username, config.port)
1098
- startAutoPush(local)
1099
- return {
1100
- text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1101
- }
1102
- },
1103
- }),
1104
-
1105
- defineTool({
1106
- name: 'rw_sync',
1107
- description:
1108
- 'Download the current remote workspace into its local mirror directory over SFTP (bounded, three-way conflict-aware). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1109
- parameters: {
1110
- depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
1111
- maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
1112
- dryRun: { type: 'boolean', description: 'Compute the plan without downloading (default false)' },
1113
- force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1114
- async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1115
- },
1116
- output: textOut,
1117
- async execute(args) {
1118
- const p = wsPath()
1119
- if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
1120
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1121
- mkdirSync(local, { recursive: true })
1122
- const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1123
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1124
- const isIgnored = ignoreMatcher()
1125
- const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1126
- const runSync = async () => {
1127
- let sftp
1128
- try {
1129
- sftp = await pool.sftp()
1130
- } catch (err) {
1131
- throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1132
- }
1133
- const state = loadSyncState(local)
1134
- const { stats, nextState } = await syncTree(sftp, p, local, { ...body, state, maxFileBytes: config.maxFileBytes })
1135
- if (!args.dryRun) saveSyncState(local, nextState)
1136
- let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}${stats.files >= maxFiles ? ' (hit download cap)' : ''}.`
1137
- if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1138
- if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1139
- if (stats.staleRemote) text += ` ${stats.staleRemote} remote entries gone (kept locally; use rw_push to mirror deletions).`
1140
- if (stats.conflicts.length) {
1141
- text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
1142
- for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
1143
- if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
1144
- text += '\n(use force=true to override)'
1145
- }
1146
- return { text }
1147
- }
1148
- if (args.async) {
1149
- const t = tasks.start('sync', `sync ${p}`, runSync)
1150
- return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1151
- }
1152
- return runSync()
1153
- },
1154
- }),
1155
-
1156
- defineTool({
1157
- name: 'rw_push',
1158
- description:
1159
- 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded, three-way conflict-aware). Use after editing files in the local mirror so the remote reflects your changes. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1160
- parameters: {
1161
- maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
1162
- dryRun: { type: 'boolean', description: 'Compute the plan without uploading (default false)' },
1163
- force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1164
- async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1165
- },
1166
- output: textOut,
1167
- async execute(args) {
1168
- const p = wsPath()
1169
- if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
1170
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1171
- if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1172
- const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1173
- const isIgnored = ignoreMatcher()
1174
- const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1175
- const runPush = async () => {
1176
- let sftp
1177
- try {
1178
- sftp = await pool.sftp()
1179
- } catch (err) {
1180
- throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1181
- }
1182
- const state = loadSyncState(local)
1183
- const { stats, nextState } = await pushTree(sftp, local, p, { ...body, state, maxFileBytes: config.maxFileBytes })
1184
- if (!args.dryRun) saveSyncState(local, nextState)
1185
- let text = `${args.dryRun ? 'WOULD upload' : 'Uploaded'} ${stats.files} file(s) from ${local} → ${p}.`
1186
- if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1187
- if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1188
- if (stats.staleLocal) text += ` ${stats.staleLocal} local entries gone remotely (kept remotely; use rw_remove to mirror deletions).`
1189
- if (stats.conflicts.length) {
1190
- text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
1191
- for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
1192
- if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
1193
- text += '\n(use force=true to override)'
1194
- }
1195
- return { text }
1196
- }
1197
- if (args.async) {
1198
- const t = tasks.start('push', `push ${p}`, runPush)
1199
- return { text: `push started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1200
- }
1201
- return runPush()
1202
- },
1203
- }),
1204
-
1205
- defineTool({
1206
- name: 'rw_list_dir',
1207
- description:
1208
- 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace. Shows type, size, mtime.',
1209
- parameters: {
1210
- path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
1211
- },
1212
- output: textOut,
1213
- async execute(args) {
1214
- const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
1215
- if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1216
- let list
1217
- try {
1218
- const sftp = await pool.sftp()
1219
- list = await sftp.readdir(p)
1220
- } catch (err) {
1221
- throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1222
- }
1223
- const fmtMtime = (t) => {
1224
- if (!t) return '?'
1225
- const d = new Date(t * 1000)
1226
- const pad = (n) => String(n).padStart(2, '0')
1227
- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
1228
- }
1229
- const lines = list
1230
- .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1231
- .map((e) => {
1232
- const a = e.attrs || {}
1233
- const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1234
- const size = typeof a.size === 'number' ? String(a.size) : '?'
1235
- return `${type} ${size.padStart(10)} ${fmtMtime(a.mtime).padEnd(17)} ${String(e.filename)}`
1236
- })
1237
- return { text: lines.length ? lines.join('\n') : '(empty directory)' }
1238
- },
1239
- }),
1240
-
1241
- defineTool({
1242
- name: 'rw_stat',
1243
- description:
1244
- 'Show detailed stat of a remote file or directory: type, size, mtime, mode (SFTP attrs). Use to verify a remote path exists or to compare files.',
1245
- parameters: {
1246
- path: { type: 'string', required: true, description: 'Absolute remote path' },
1247
- },
1248
- output: textOut,
1249
- async execute(args) {
1250
- const p = normalizeRemotePath(String(args.path || ''))
1251
- if (!p) throw new Error('rw_stat: path is required')
1252
- const sftp = await pool.sftp()
1253
- let st
1254
- try {
1255
- st = await sftp.stat(p)
1256
- } catch (err) {
1257
- throw new Error('rw_stat: not found or unreadable: ' + ((err && err.message) || err))
1258
- }
1259
- const type = st.isDirectory && st.isDirectory() ? 'directory' : (st.isSymbolicLink && st.isSymbolicLink() ? 'symlink' : 'file')
1260
- const lines = [
1261
- `path: ${p}`,
1262
- `type: ${type}`,
1263
- `size: ${st.size} bytes`,
1264
- `mtime: ${new Date(st.mtime * 1000).toISOString()}`,
1265
- `mode: ${typeof st.mode === 'number' ? st.mode.toString(8) : '?'}`,
1266
- ]
1267
- return { text: lines.join('\n') }
1268
- },
1269
- }),
1270
-
1271
- defineTool({
1272
- name: 'rw_read_file',
1273
- description:
1274
- 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine and an encoding param (utf-8 default, gbk etc). Path is absolute.',
1275
- parameters: {
1276
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1277
- startLine: { type: 'integer', description: '1-based first line (default 1)' },
1278
- endLine: { type: 'integer', description: '1-based last line (inclusive)' },
1279
- maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
1280
- encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1281
- },
1282
- output: textOut,
1283
- async execute(args) {
1284
- const p = normalizeRemotePath(String(args.path || ''))
1285
- if (!p) throw new Error('rw_read_file: path is required')
1286
- const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1287
- let from = Math.max(Number(args.startLine) || 1, 1)
1288
- let to = Number(args.endLine) || 0
1289
- if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
1290
- const sftp = await pool.sftp()
1291
- let st
1292
- try { st = await sftp.stat(p) } catch (err) { throw new Error('rw_read_file: ' + ((err && err.message) || err)) }
1293
- if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
1294
- throw new Error(`rw_read_file: file is ${st.size} bytes (over ${config.maxFileBytes} cap); use rw_download or rw_exec to read it`)
1295
- }
1296
- let buf
1297
- try {
1298
- buf = await sftp.readFile(p)
1299
- } catch (err) {
1300
- throw new Error('rw_read_file: ' + ((err && err.message) || err))
1301
- }
1302
- const content = decodeBuf(buf, args.encoding || config.encoding).replace(/\r\n/g, '\n')
1303
- const allLines = content.split('\n')
1304
- const page = allLines.slice(from - 1, to)
1305
- const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1306
- let text = numbered === '' ? '(empty or out of range)' : numbered
1307
- if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
1308
- return { text }
1309
- },
1310
- }),
1311
-
1312
- defineTool({
1313
- name: 'rw_write_file',
1314
- description:
1315
- '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.',
1316
- parameters: {
1317
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1318
- content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
1319
- mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
1320
- encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1321
- },
1322
- output: okOut,
1323
- async execute(args) {
1324
- const p = normalizeRemotePath(String(args.path || ''))
1325
- if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1326
- const content = String(args.content == null ? '' : args.content)
1327
- const sftp = await pool.sftp()
1328
- if (args.mkdir !== false) await mkdirRemoteDirs(sftp, remoteDirname(p))
1329
- const buf = encodeText(content, args.encoding || config.encoding)
1330
- await sftp.writeFile(p, buf)
1331
- const bytes = buf.byteLength
1332
- audit('write_file', `write ${p} (${bytes}B)`, 0)
1333
- return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
1334
- },
1335
- }),
1336
-
1337
- defineTool({
1338
- name: 'rw_edit',
1339
- description:
1340
- 'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
1341
- parameters: {
1342
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1343
- old: { type: 'string', required: true, description: 'Literal text to replace (must appear exactly once unless count is given)' },
1344
- new: { type: 'string', required: true, description: 'Replacement text' },
1345
- count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
1346
- encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1347
- },
1348
- output: okOut,
1349
- async execute(args) {
1350
- const p = normalizeRemotePath(String(args.path || ''))
1351
- if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1352
- const oldS = String(args.old ?? '')
1353
- const newS = String(args.new ?? '')
1354
- if (oldS === '') throw new Error('rw_edit: old text must not be empty')
1355
- const sftp = await pool.sftp()
1356
- const st0 = await sftp.stat(p)
1357
- const buf = await sftp.readFile(p)
1358
- const content = decodeBuf(buf, args.encoding || config.encoding)
1359
- const count = args.count == null ? 0 : Math.max(Number(args.count) || 1, 1)
1360
- const idxs = []
1361
- let from = 0
1362
- let hit
1363
- while ((hit = content.indexOf(oldS, from)) !== -1) { idxs.push(hit); from = hit + oldS.length }
1364
- if (!idxs.length) throw new Error(`rw_edit: old text not found in ${p}`)
1365
- if (count === 0 && idxs.length > 1) {
1366
- throw new Error(`rw_edit: "old" appears ${idxs.length} times in ${p} — pass count=<n> to pick how many to replace`)
1367
- }
1368
- const n = count === 0 ? 1 : Math.min(count, idxs.length)
1369
- let out = content
1370
- for (let i = n - 1; i >= 0; i--) {
1371
- out = out.slice(0, idxs[i]) + newS + out.slice(idxs[i] + oldS.length)
1372
- }
1373
- // Optimistic lock: the remote must not have changed since we read it.
1374
- const st1 = await sftp.stat(p)
1375
- if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
1376
- throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
1377
- }
1378
- await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
1379
- audit('edit', `edit ${p} (${n} occurrence(s))`, 0)
1380
- return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
1381
- },
1382
- }),
1383
-
1384
- defineTool({
1385
- name: 'rw_append',
1386
- description:
1387
- 'Append text to a remote file (creates it when missing). Path is absolute.',
1388
- parameters: {
1389
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1390
- content: { type: 'string', required: true, description: 'Text to append' },
1391
- encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1392
- },
1393
- output: okOut,
1394
- async execute(args) {
1395
- const p = normalizeRemotePath(String(args.path || ''))
1396
- if (!p || p === '/') throw new Error('rw_append: a file path is required')
1397
- const sftp = await pool.sftp()
1398
- let existing = ''
1399
- try { existing = decodeBuf(await sftp.readFile(p), args.encoding || config.encoding) } catch { /* new file */ }
1400
- const content = existing + String(args.content ?? '')
1401
- await sftp.writeFile(p, encodeText(content, args.encoding || config.encoding))
1402
- const bytes = Buffer.byteLength(content)
1403
- audit('append', `append ${p}`, 0)
1404
- return { ok: true, bytes, text: `appended to ${p} (now ${bytes} bytes)` }
1405
- },
1406
- }),
1407
-
1408
- defineTool({
1409
- name: 'rw_mkdir',
1410
- description:
1411
- 'Create a remote directory (mkdir -p semantics, all levels). Path is absolute.',
1412
- parameters: {
1413
- path: { type: 'string', required: true, description: 'Absolute remote directory path' },
1414
- },
1415
- output: textOut,
1416
- async execute(args) {
1417
- const p = normalizeRemotePath(String(args.path || ''))
1418
- if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
1419
- const sftp = await pool.sftp()
1420
- await mkdirRemoteDirs(sftp, p)
1421
- audit('mkdir', `mkdir ${p}`, 0)
1422
- return { text: `created ${p}` }
1423
- },
1424
- }),
1425
-
1426
- defineTool({
1427
- name: 'rw_remove',
1428
- description:
1429
- 'Delete a remote file (or an empty directory). recursive=true removes a directory tree (bounded). Path is absolute. This is destructive — the agent should confirm intent before calling.',
1430
- parameters: {
1431
- path: { type: 'string', required: true, description: 'Absolute remote path to delete' },
1432
- recursive: { type: 'boolean', description: 'Recursively delete a directory (default false)' },
1433
- },
1434
- output: okOut,
1435
- async execute(args) {
1436
- const p = normalizeRemotePath(String(args.path || ''))
1437
- if (!p || p === '/') throw new Error('rw_remove: a path is required')
1438
- const sftp = await pool.sftp()
1439
- const st = await sftp.stat(p).catch(() => null)
1440
- if (!st) return { ok: false, text: `not found: ${p}` }
1441
- if (st.isDirectory && st.isDirectory()) {
1442
- if (!args.recursive) throw new Error(`rw_remove: ${p} is a directory — pass recursive=true to delete its tree`)
1443
- const removed = await removeRemoteTree(sftp, p)
1444
- audit('remove', `remove -r ${p}`, 0)
1445
- return { ok: true, text: `removed ${removed} entries under ${p}` }
1446
- }
1447
- await sftp.unlink(p)
1448
- audit('remove', `remove ${p}`, 0)
1449
- return { ok: true, text: `removed ${p}` }
1450
- },
1451
- }),
1452
-
1453
- defineTool({
1454
- name: 'rw_move',
1455
- description:
1456
- 'Rename or move a remote file/directory (SFTP rename, same filesystem). Paths are absolute.',
1457
- parameters: {
1458
- path: { type: 'string', required: true, description: 'Current absolute remote path' },
1459
- dest: { type: 'string', required: true, description: 'Destination absolute remote path' },
1460
- },
1461
- output: textOut,
1462
- async execute(args) {
1463
- const p = normalizeRemotePath(String(args.path || ''))
1464
- const d = normalizeRemotePath(String(args.dest || ''))
1465
- if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
1466
- const sftp = await pool.sftp()
1467
- await mkdirRemoteDirs(sftp, remoteDirname(d))
1468
- await sftp.rename(p, d)
1469
- audit('move', `move ${p} → ${d}`, 0)
1470
- return { text: `moved ${p} → ${d}` }
1471
- },
1472
- }),
1473
-
1474
- defineTool({
1475
- name: 'rw_exec',
1476
- description:
1477
- 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables.',
1478
- parameters: {
1479
- command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1480
- cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
1481
- pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
1482
- env: { type: 'object', description: 'Extra environment variables (string values)' },
1483
- },
1484
- output: textOut,
1485
- async execute(args) {
1486
- const cmd = String(args.command || '')
1487
- if (!cmd) throw new Error('rw_exec: command is required')
1488
- const ws = wsPath()
1489
- const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1490
- const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1491
- try {
1492
- const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
1493
- audit('exec', cmd, res.code)
1494
- const parts = []
1495
- if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
1496
- if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
1497
- if (!parts.length) parts.push('(no output)')
1498
- let text = parts.join('\n')
1499
- if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
1500
- else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
1501
- return { text }
1502
- } catch (err) {
1503
- throw new Error(friendlyMessage(err, { host: config.host, port: config.port }))
1504
- }
1505
- },
1506
- }),
1507
-
1508
- defineTool({
1509
- name: 'rw_search',
1510
- description:
1511
- 'Search remote files for a pattern (recursive SFTP walk — works on ANY remote including Windows, honors ignore rules). Returns matching file:line rows; output is capped.',
1512
- parameters: {
1513
- pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1514
- path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1515
- glob: { type: 'string', description: 'Only files whose NAME matches this glob, e.g. *.ts (optional)' },
1516
- ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1517
- contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
1518
- maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
1519
- },
1520
- output: textOut,
1521
- async execute(args) {
1522
- const pattern = String(args.pattern || '')
1523
- if (!pattern) throw new Error('rw_search: pattern is required')
1524
- const ws = wsPath()
1525
- const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1526
- if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1527
- let regex
1528
- try {
1529
- regex = new RegExp(pattern, args.ignoreCase === false ? '' : 'i')
1530
- } catch (err) {
1531
- throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
1532
- }
1533
- const sftp = await pool.sftp()
1534
- const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
1535
- const matcher = ignoreMatcher()
1536
- const { matches, scanned, truncated } = await searchTree(sftp, dir, {
1537
- regex,
1538
- glob: args.glob,
1539
- contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
1540
- maxMatches,
1541
- maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
1542
- isIgnored: (name, isDir) => matcher(name, isDir),
1543
- })
1544
- if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` }
1545
- let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
1546
- text += `\n(${matches.length} match(es), ${scanned} files scanned${truncated ? ', TRUNCATED' : ''})`
1547
- return { text: truncate(text, config.maxOutputChars) }
1548
- },
1549
- }),
1550
-
1551
- defineTool({
1552
- name: 'rw_download',
1553
- description:
1554
- 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text. Streams to disk (fastGet).',
1555
- parameters: {
1556
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1557
- localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1558
- },
1559
- output: okOut,
1560
- async execute(args) {
1561
- const p = normalizeRemotePath(String(args.path || ''))
1562
- if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1563
- const sftp = await pool.sftp()
1564
- let local
1565
- if (args.localPath) {
1566
- local = path.resolve(String(args.localPath))
1567
- } else {
1568
- const ws = wsPath()
1569
- if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1570
- const base = mirrorDirFor(ws, config.host, config.username, config.port)
1571
- const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1572
- local = path.join(base, rel)
1573
- }
1574
- mkdirSync(path.dirname(local), { recursive: true })
1575
- const st = await sftp.stat(p).catch(() => null)
1576
- if (config.maxFileBytes > 0 && st && st.size > config.maxFileBytes) {
1577
- throw new Error(`rw_download: file is ${st.size} bytes (over ${config.maxFileBytes} cap)`)
1578
- }
1579
- await sftp.fastGet(p, local)
1580
- const bytes = existsSync(local) ? statSync(local).size : 0
1581
- return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1582
- },
1583
- }),
1584
-
1585
- defineTool({
1586
- name: 'rw_upload',
1587
- description:
1588
- 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1589
- parameters: {
1590
- localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1591
- path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1592
- },
1593
- output: okOut,
1594
- async execute(args) {
1595
- const rp = normalizeRemotePath(String(args.path || ''))
1596
- const lp = String(args.localPath || '')
1597
- if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1598
- if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1599
- const sftp = await pool.sftp()
1600
- await mkdirRemoteDirs(sftp, remoteDirname(rp))
1601
- const st = statSync(lp)
1602
- await sftp.fastPut(rp, lp)
1603
- audit('upload', `upload ${lp} → ${rp}`, 0)
1604
- return { ok: true, bytes: st.size, text: `uploaded ${st.size} bytes from ${lp} → ${rp}` }
1605
- },
1606
- }),
1607
-
1608
- defineTool({
1609
- name: 'rw_forward',
1610
- description:
1611
- 'Manage SSH port forwards. Direction "local" listens on 127.0.0.1:<listenPort> on THIS machine and forwards connections through SSH to <targetHost>:<targetPort> on the remote. Direction "reverse" asks the REMOTE to listen on 127.0.0.1:<listenPort> and pipes connections back to <targetHost>:<targetPort> on this machine. Call with a listenPort to create+start; with remove=true to delete.',
1612
- parameters: {
1613
- listenPort: { type: 'integer', required: true, description: 'Port to listen on (local for direction=local, remote for direction=reverse)' },
1614
- targetHost: { type: 'string', description: 'Forward target host (default 127.0.0.1)' },
1615
- targetPort: { type: 'integer', description: 'Forward target port (default: same as listenPort)' },
1616
- direction: { type: 'string', description: 'local (default) or reverse' },
1617
- autoStart: { type: 'boolean', description: 'Restart this forward automatically on future connects (default false)' },
1618
- remove: { type: 'boolean', description: 'Remove an existing forward by listenPort (default false)' },
1619
- },
1620
- output: textOut,
1621
- async execute(args) {
1622
- const port = Number(args.listenPort)
1623
- if (!port || port < 1 || port > 65535) throw new Error('rw_forward: a valid listenPort is required')
1624
- const dir = args.direction === 'reverse' ? 'reverse' : 'local'
1625
- const existing = forwards.list().find((f) => Number(f.listenPort) === port && f.direction === dir)
1626
- if (args.remove) {
1627
- if (!existing) return { text: `no forward on port ${port} to remove` }
1628
- forwards.remove(existing.id)
1629
- return { text: `removed ${existing.direction} forward on port ${port}` }
1630
- }
1631
- if (existing && existing.active) return { text: `already active: ${existing.direction} forward 127.0.0.1:${port} → ${existing.targetHost}:${existing.targetPort}` }
1632
- const d = existing || forwards.define({
1633
- direction: dir,
1634
- listenPort: port,
1635
- targetHost: args.targetHost || '127.0.0.1',
1636
- targetPort: Number(args.targetPort) || port,
1637
- autoStart: !!args.autoStart,
1638
- machineId: store.currentId,
1639
- })
1640
- const r = await forwards.start(d)
1641
- audit('forward', `${d.direction} forward ${port} → ${d.targetHost}:${d.targetPort}`, r.ok ? 0 : 1)
1642
- if (!r.ok) throw new Error(r.error)
1643
- return { text: `${d.direction} forward active: 127.0.0.1:${port} → ${d.targetHost}:${d.targetPort} (id=${d.id})` }
1644
- },
1645
- }),
1646
-
1647
- defineTool({
1648
- name: 'rw_disconnect',
1649
- description:
1650
- 'Close the current SSH connection to the remote host, releasing the persistent pool (and stopping all active port forwards). Useful to rotate connections or after a long idle.',
1651
- parameters: {},
1652
- output: okOut,
1653
- async execute() {
1654
- pool.close()
1655
- return { ok: true, text: 'disconnected (forwards stopped)' }
1656
- },
1657
- }),
1658
- ]
1659
-
1660
- for (const t of tools) {
1661
- ctx.tools.register(t)
1662
- }
1663
-
1664
- // ── system-prompt injection: the current remote workspace ─────────────────
1665
- ctx.systemPrompt.section({
1666
- name: 'dsh-remote',
1667
- order: 88,
1668
- text: () => {
1669
- const w = wsPath()
1670
- if (!w || !config.host) return ''
1671
- const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1672
- let extra = ''
1673
- if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1674
- return (
1675
- '## Remote workspace\n' +
1676
- `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
1677
- 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
1678
- extra
1679
- )
1680
- },
1681
- })
1682
-
1683
- // ── slash commands ─────────────────────────────────────────────────────────
1684
- const commands = ctx.get('commands')
1685
- if (commands !== undefined) {
1686
- commands.register({
1687
- name: 'remote',
1688
- description: 'Show the current remote workspace / connection status, active forwards, and how to use remote tools.',
1689
- handler: (invocation) => {
1690
- const s = status()
1691
- const fwd = s.forwards.filter((f) => f.active)
1692
- return {
1693
- kind: 'success',
1694
- text:
1695
- `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected}, source: ${s.activeSource})\n` +
1696
- `Remote workspace: ${s.workspace || '(none)'}\n` +
1697
- `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1698
- (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1699
- (fwd.length ? `Active forwards:\n${fwd.map((f) => ` ${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}`).join('\n')}\n` : '') +
1700
- `\nUse tools: rw_list_dir / rw_read_file / rw_edit / rw_exec / rw_search / rw_forward.` +
1701
- (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
1702
- }
1703
- },
1704
- })
1705
- commands.register({
1706
- name: 'remote-forget-key',
1707
- description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1708
- handler: () => {
1709
- createHostKeyGuard(config).forgetHost()
1710
- return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1711
- },
1712
- })
1713
- commands.register({
1714
- name: 'remote-ignore',
1715
- description: 'Show the mirror ignore rules file location and the current default patterns (gitignore syntax).',
1716
- handler: () => {
1717
- return {
1718
- kind: 'success',
1719
- text:
1720
- `Ignore file: ${ignoreFile()}\n(defaults merged with the file; gitignore syntax, '#' comments)\n\nDefault patterns:\n${DEFAULT_IGNORE.map((p) => ' ' + p).join('\n')}`,
1721
- }
1722
- },
1723
- })
1724
- }
1725
-
1726
- // ── JSON endpoints for settings UI ─────────────────────────────────────────
1727
- const webServer = ctx.get('webServer')
1728
- if (webServer === undefined) return
1729
-
1730
- const sendJson = (res, status, body) => {
1731
- res.statusCode = status
1732
- res.setHeader('Content-Type', 'application/json; charset=utf-8')
1733
- res.end(JSON.stringify(body))
1734
- }
1735
- const MAX_BODY_BYTES = 1024 * 1024
1736
- const readBody = (req) =>
1737
- new Promise((resolve) => {
1738
- const chunks = []
1739
- let total = 0
1740
- req.on('data', (c) => {
1741
- total += c.length
1742
- if (total > MAX_BODY_BYTES) {
1743
- req.removeAllListeners('data')
1744
- resolve('{}')
1745
- return
1746
- }
1747
- chunks.push(c)
1748
- })
1749
- req.on('end', () => resolve(chunks.join('')))
1750
- })
1751
-
1752
- // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1753
- const PICK_TIMEOUT_MS = 120000
1754
- const runPick = (bin, args) =>
1755
- new Promise((resolve, reject) => {
1756
- execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1757
- if (err) {
1758
- const code = err.code
1759
- const msg = String(stderr || '')
1760
- if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1761
- if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1762
- if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1763
- return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1764
- }
1765
- const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1766
- resolve(p === 'CANCELED' ? { cancelled: true } : (p ? { path: p } : { cancelled: true }))
1767
- })
1768
- })
1769
- const pickLocalNative = async () => {
1770
- const platform = process.platform
1771
- if (platform === 'darwin') {
1772
- return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1773
- }
1774
- if (platform === 'linux') {
1775
- try {
1776
- return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1777
- } catch (err) {
1778
- if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1779
- throw err
1780
- }
1781
- }
1782
- if (platform === 'win32') {
1783
- const script =
1784
- `Add-Type -AssemblyName System.Windows.Forms;` +
1785
- `$f = New-Object System.Windows.Forms.FolderBrowserDialog;` +
1786
- `$f.Description = 'Select Workspace Directory';` +
1787
- `if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { 'CANCELED' }`
1788
- return runPick('powershell', ['-NoProfile', '-STA', '-Command', script])
1789
- }
1790
- throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1791
- }
1792
-
1793
- // ── route helpers ─────────────────────────────────────────────────────────
1794
- const routes = [
1795
- {
1796
- kind: 'exact',
1797
- path: '/dsh-remote/status',
1798
- handler: async (req, res) => {
1799
- if (req.method === 'GET') return sendJson(res, 200, status())
1800
- sendJson(res, 405, { error: 'method not allowed' })
1801
- },
1802
- },
1803
- {
1804
- kind: 'exact',
1805
- path: '/dsh-remote/connect',
1806
- handler: async (req, res) => {
1807
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1808
- try {
1809
- const payload = JSON.parse((await readBody(req)) || '{}')
1810
- pool.setTarget({
1811
- host: payload.host,
1812
- port: payload.port,
1813
- username: payload.username,
1814
- password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
1815
- privateKeyPath: payload.privateKeyPath,
1816
- workspace: payload.workspace,
1817
- })
1818
- await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1819
- return sendJson(res, 200, { ok: true, ...status() })
1820
- } catch (err) {
1821
- return sendJson(res, 500, { ok: false, error: friendlyMessage(err, { host: payload && payload.host, port: payload && payload.port }) })
1822
- }
1823
- },
1824
- },
1825
- {
1826
- kind: 'exact',
1827
- path: '/dsh-remote/ls',
1828
- handler: async (req, res) => {
1829
- try {
1830
- const q = new URL(req.url, 'http://localhost').searchParams
1831
- const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
1832
- const out = await listDirStructured(p || '/')
1833
- return sendJson(res, 200, { path: out.path, items: out.items })
1834
- } catch (err) {
1835
- return sendJson(res, 500, { error: String((err && err.message) || err) })
1836
- }
1837
- },
1838
- },
1839
- {
1840
- // Read a remote file over SFTP (live). Text returns as content (CRLF→LF,
1841
- // encoding-aware), binary is reported with size+head (base64). A stat
1842
- // happens first; oversized files are previewed from a streamed head chunk
1843
- // instead of being fully buffered.
1844
- kind: 'exact',
1845
- path: '/dsh-remote/read',
1846
- handler: async (req, res) => {
1847
- if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1848
- try {
1849
- const q = new URL(req.url, 'http://localhost').searchParams
1850
- let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
1851
- let encoding = ''
1852
- let maxBytes = 256 * 1024
1853
- if (req.method === 'POST') {
1854
- const body = JSON.parse((await readBody(req)) || '{}')
1855
- if (!p) p = String(body.path || '')
1856
- if (body.encoding) encoding = String(body.encoding)
1857
- if (body.maxBytes) maxBytes = Number(body.maxBytes)
1858
- }
1859
- if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
1860
- if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
1861
- const target = normalizeRemotePath(p)
1862
- maxBytes = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
1863
- let sftp
1864
- try {
1865
- sftp = await pool.sftp()
1866
- } catch (err) {
1867
- return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
1868
- }
1869
- let st
1870
- try { st = await sftp.stat(target) } catch (err) {
1871
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1872
- }
1873
- if (st.size > maxBytes) {
1874
- // Head preview for oversized files: fastGet to a temp file, read the
1875
- // first maxBytes, delete the temp. Never buffers the whole file.
1876
- const tmp = path.join(dshBase(), '.dsh-remote-preview-' + process.pid)
1877
- try {
1878
- await sftp.fastGet(target, tmp)
1879
- const { open } = await import('node:fs/promises')
1880
- const fd = await open(tmp, 'r')
1881
- const head = Buffer.alloc(Math.min(maxBytes, st.size))
1882
- await fd.read(head, 0, head.length, 0)
1883
- await fd.close()
1884
- try { unlinkSync(tmp) } catch {}
1885
- const content = decodeBuf(head, encoding || config.encoding).replace(/\r\n/g, '\n')
1886
- return sendJson(res, 200, { ok: true, binary: false, content, truncated: true, size: st.size })
1887
- } catch (err) {
1888
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1889
- }
1890
- }
1891
- let buf
1892
- try {
1893
- buf = await sftp.readFile(target)
1894
- } catch (err) {
1895
- return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1896
- }
1897
- const headN = Math.min(buf.length, 8192)
1898
- let binary = false
1899
- for (let i = 0; i < headN; i++) {
1900
- if (buf[i] === 0) { binary = true; break }
1901
- }
1902
- if (binary) {
1903
- return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
1904
- }
1905
- let content = decodeBuf(buf, encoding || config.encoding).replace(/\r\n/g, '\n')
1906
- const truncated = content.length > maxBytes
1907
- if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
1908
- return sendJson(res, 200, { ok: true, binary: false, content, truncated })
1909
- } catch (err) {
1910
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1911
- }
1912
- },
1913
- },
1914
- {
1915
- // Write a remote file (sidebar editor save). `expectedMtime` is an
1916
- // optimistic lock: a 409 is returned when the remote changed meanwhile.
1917
- kind: 'exact',
1918
- path: '/dsh-remote/write',
1919
- handler: async (req, res) => {
1920
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1921
- try {
1922
- const body = JSON.parse((await readBody(req)) || '{}')
1923
- const p = normalizeRemotePath(String(body.path || ''))
1924
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
1925
- const sftp = await pool.sftp()
1926
- let st = null
1927
- try { st = await sftp.stat(p) } catch { /* new file */ }
1928
- if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
1929
- return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
1930
- }
1931
- const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
1932
- if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
1933
- await sftp.writeFile(p, buf)
1934
- audit('write', `write ${p}`, 0)
1935
- const st2 = await sftp.stat(p).catch(() => null)
1936
- return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
1937
- } catch (err) {
1938
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1939
- }
1940
- },
1941
- },
1942
- {
1943
- // Generic remote fs ops used by the sidebar context menu + picker:
1944
- // mkdir / rename / remove (recursive) / write / append.
1945
- kind: 'exact',
1946
- path: '/dsh-remote/fs',
1947
- handler: async (req, res) => {
1948
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1949
- try {
1950
- const body = JSON.parse((await readBody(req)) || '{}')
1951
- const op = String(body.op || '')
1952
- const sftp = await pool.sftp()
1953
- if (op === 'mkdir') {
1954
- const p = normalizeRemotePath(String(body.path || ''))
1955
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1956
- await mkdirRemoteDirs(sftp, p)
1957
- audit('mkdir', `mkdir ${p}`, 0)
1958
- return sendJson(res, 200, { ok: true })
1959
- }
1960
- if (op === 'rename') {
1961
- const p = normalizeRemotePath(String(body.path || ''))
1962
- const d = normalizeRemotePath(String(body.dest || ''))
1963
- if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
1964
- await sftp.rename(p, d)
1965
- audit('move', `move ${p} → ${d}`, 0)
1966
- return sendJson(res, 200, { ok: true })
1967
- }
1968
- if (op === 'remove') {
1969
- const p = normalizeRemotePath(String(body.path || ''))
1970
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1971
- const st = await sftp.stat(p).catch(() => null)
1972
- if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
1973
- if (st.isDirectory && st.isDirectory()) {
1974
- await removeRemoteTree(sftp, p)
1975
- } else {
1976
- await sftp.unlink(p)
1977
- }
1978
- audit('remove', `remove ${p}`, 0)
1979
- return sendJson(res, 200, { ok: true })
1980
- }
1981
- if (op === 'write' || op === 'append') {
1982
- const p = normalizeRemotePath(String(body.path || ''))
1983
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1984
- let content = ''
1985
- if (op === 'append') {
1986
- try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
1987
- }
1988
- const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
1989
- if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
1990
- await sftp.writeFile(p, buf)
1991
- audit(op, `${op} ${p}`, 0)
1992
- return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
1993
- }
1994
- if (op === 'download') {
1995
- // Download a remote file into the current workspace mirror.
1996
- const p = normalizeRemotePath(String(body.path || ''))
1997
- const ws = wsPath()
1998
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1999
- if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
2000
- const st = await sftp.stat(p).catch(() => null)
2001
- if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
2002
- if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
2003
- return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
2004
- }
2005
- const base = mirrorDirFor(ws, config.host, config.username, config.port)
2006
- const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
2007
- const local = path.join(base, rel)
2008
- mkdirSync(path.dirname(local), { recursive: true })
2009
- await sftp.fastGet(p, local)
2010
- audit('download', `download ${p}`, 0)
2011
- return sendJson(res, 200, { ok: true, bytes: st.size, local })
2012
- }
2013
- return sendJson(res, 400, { ok: false, error: 'unknown op' })
2014
- } catch (err) {
2015
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2016
- }
2017
- },
2018
- },
2019
- {
2020
- kind: 'exact',
2021
- path: '/dsh-remote/workspace',
2022
- handler: async (req, res) => {
2023
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2024
- try {
2025
- const payload = JSON.parse((await readBody(req)) || '{}')
2026
- const p = normalizeRemotePath(String(payload.path || ''))
2027
- if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
2028
- const okDir = await isRemoteDir(p)
2029
- if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
2030
- persistWorkspace(p)
2031
- const local = ensureMirror(p, config.host, config.username, config.port)
2032
- startAutoPush(local)
2033
- return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
2034
- } catch (err) {
2035
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2036
- }
2037
- },
2038
- },
2039
- {
2040
- kind: 'exact',
2041
- path: '/dsh-remote/mirror',
2042
- handler: async (req, res) => {
2043
- if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2044
- try {
2045
- const payload = JSON.parse((await readBody(req)) || '{}')
2046
- const p = normalizeRemotePath(String(payload.path || ''))
2047
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2048
- if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2049
- const okDir = await isRemoteDir(p)
2050
- if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
2051
- const local = ensureMirror(p, config.host, config.username, config.port)
2052
- persistWorkspace(p)
2053
- startAutoPush(local)
2054
- return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
2055
- } catch (err) {
2056
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2057
- }
2058
- },
2059
- },
2060
- {
2061
- kind: 'exact',
2062
- path: '/dsh-remote/local-pick',
2063
- handler: async (req, res) => {
2064
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2065
- try {
2066
- let outcome = null
2067
- let via = 'service'
2068
- const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
2069
- if (dp && typeof dp.capability === 'function') {
2070
- try {
2071
- const cap = await Promise.resolve(dp.capability())
2072
- if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2073
- const pickAbort = new AbortController()
2074
- const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
2075
- pickAbort.abort()
2076
- outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
2077
- }
2078
- } catch (err) {
2079
- outcome = null
2080
- }
2081
- }
2082
- if (!outcome) {
2083
- via = 'own'
2084
- try {
2085
- outcome = await pickLocalNative()
2086
- } catch (err) {
2087
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
2088
- }
2089
- }
2090
- if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
2091
- return sendJson(res, 200, { ok: true, path: outcome.path, via })
2092
- } catch (err) {
2093
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2094
- }
2095
- },
2096
- },
2097
- {
2098
- kind: 'exact',
2099
- path: '/dsh-remote/machines',
2100
- handler: async (req, res) => {
2101
- if (req.method === 'GET') {
2102
- return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
2103
- }
2104
- if (req.method === 'POST') {
2105
- try {
2106
- const body = JSON.parse((await readBody(req)) || '{}')
2107
- const action = body.action || 'add'
2108
- if (action === 'add' || action === 'update') {
2109
- const host = String(body.host || '').trim()
2110
- if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
2111
- const id = body.id || machineId()
2112
- const i = machineIndex(id)
2113
- const prev = i >= 0 ? machines[i] : null
2114
- const credBackend = body.credentialBackend === 'plain' ? 'plain'
2115
- : (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
2116
- const rec = {
2117
- id,
2118
- name: String(body.name || '').trim() || host,
2119
- host,
2120
- port: Number(body.port) || 22,
2121
- username: String(body.username || '').trim() || 'root',
2122
- password: '',
2123
- privateKeyPath: String(body.privateKeyPath || '').trim(),
2124
- passphrase: body.passphrase || '',
2125
- workspace: String(body.workspace || '').trim(),
2126
- hostKeyMode: body.hostKeyMode || '',
2127
- useAgent: !!body.useAgent,
2128
- keyboardInteractive: !!body.keyboardInteractive,
2129
- proxy: body.proxy && body.proxy.host ? {
2130
- host: String(body.proxy.host),
2131
- port: Number(body.proxy.port) || 22,
2132
- username: String(body.proxy.username || '').trim(),
2133
- password: String(body.proxy.password || ''),
2134
- privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
2135
- } : undefined,
2136
- credentialBackend: credBackend,
2137
- recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
2138
- lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
2139
- latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
2140
- }
2141
- if (body.password) {
2142
- if (credBackend !== 'plain') {
2143
- await saveSecret(id, String(body.password), secretsDir())
2144
- rec.password = ''
2145
- } else {
2146
- rec.password = String(body.password)
2147
- }
2148
- } else if (prev && prev.password) {
2149
- rec.password = prev.password
2150
- }
2151
- if (i >= 0) machines[i] = rec; else machines.push(rec)
2152
- if (!store.currentId) store.currentId = id
2153
- saveMachines(machines, store.currentId)
2154
- if (store.currentId === id) await applyActiveMachine()
2155
- return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
2156
- }
2157
- if (action === 'delete') {
2158
- const i = machineIndex(String(body.id || ''))
2159
- if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2160
- const m = machines[i]
2161
- if (m.credentialBackend && m.credentialBackend !== 'plain') {
2162
- await deleteSecret(m.id, secretsDir()).catch(() => {})
2163
- }
2164
- machines.splice(i, 1)
2165
- if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
2166
- saveMachines(machines, store.currentId)
2167
- if (store.currentId) await applyActiveMachine()
2168
- return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2169
- }
2170
- return sendJson(res, 400, { ok: false, error: 'unknown action' })
2171
- } catch (err) {
2172
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2173
- }
2174
- }
2175
- return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2176
- },
2177
- },
2178
- {
2179
- kind: 'exact',
2180
- path: '/dsh-remote/test-connect',
2181
- handler: async (req, res) => {
2182
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2183
- try {
2184
- const body = JSON.parse((await readBody(req)) || '{}')
2185
- const probe = new SshPool({
2186
- ...config,
2187
- host: String(body.host || config.host),
2188
- port: Number(body.port) || config.port,
2189
- username: String(body.username || config.username),
2190
- password: String(body.password || ''),
2191
- privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
2192
- passphrase: String(body.passphrase || ''),
2193
- proxy: body.proxy && body.proxy.host ? {
2194
- host: String(body.proxy.host),
2195
- port: Number(body.proxy.port) || 22,
2196
- username: String(body.proxy.username || ''),
2197
- password: String(body.proxy.password || ''),
2198
- privateKeyPath: String(body.proxy.privateKeyPath || ''),
2199
- } : undefined,
2200
- connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
2201
- commandTimeoutMs: 10000,
2202
- })
2203
- const started = Date.now()
2204
- await probe.connect()
2205
- await probe.exec('true', { timeoutMs: 10000 })
2206
- probe.close()
2207
- const latencyMs = Date.now() - started
2208
- const mi = machines.findIndex((m) => m.host === probe.config.host && m.username === probe.config.username && Number(m.port) === probe.config.port)
2209
- if (mi >= 0) {
2210
- machines[mi].lastConnectedAt = new Date().toISOString()
2211
- machines[mi].latencyMs = latencyMs
2212
- saveMachines(machines, store.currentId)
2213
- }
2214
- return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString() })
2215
- } catch (err) {
2216
- return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body && body.host, port: body && body.port }) })
2217
- }
2218
- },
2219
- },
2220
- {
2221
- kind: 'exact',
2222
- path: '/dsh-remote/current',
2223
- handler: async (req, res) => {
2224
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2225
- try {
2226
- const body = JSON.parse((await readBody(req)) || '{}')
2227
- const okSet = await setCurrent(String(body.id || ''))
2228
- if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2229
- return sendJson(res, 200, { ok: true, ...status() })
2230
- } catch (err) {
2231
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2232
- }
2233
- },
2234
- },
2235
- {
2236
- kind: 'exact',
2237
- path: '/dsh-remote/forget-key',
2238
- handler: async (req, res) => {
2239
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2240
- createHostKeyGuard(config).forgetHost()
2241
- return sendJson(res, 200, { ok: true, ...status() })
2242
- },
2243
- },
2244
- {
2245
- kind: 'exact',
2246
- path: '/dsh-remote/forwards',
2247
- handler: async (req, res) => {
2248
- if (req.method === 'GET') return sendJson(res, 200, { forwards: forwards.list() })
2249
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2250
- try {
2251
- const body = JSON.parse((await readBody(req)) || '{}')
2252
- const action = String(body.action || '')
2253
- if (action === 'define') {
2254
- const d = forwards.define({
2255
- direction: body.direction === 'reverse' ? 'reverse' : 'local',
2256
- listenPort: Number(body.listenPort),
2257
- targetHost: body.targetHost || '127.0.0.1',
2258
- targetPort: Number(body.targetPort) || Number(body.listenPort),
2259
- autoStart: !!body.autoStart,
2260
- machineId: store.currentId,
2261
- })
2262
- return sendJson(res, 200, { ok: true, forward: d, forwards: forwards.list() })
2263
- }
2264
- if (action === 'start' || action === 'stop') {
2265
- const d = forwards.list().find((f) => f.id === body.id)
2266
- if (!d) return sendJson(res, 404, { ok: false, error: 'forward not found' })
2267
- if (action === 'start') {
2268
- const r = await forwards.start(d)
2269
- if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error })
2270
- } else {
2271
- forwards.stop(d.id)
2272
- }
2273
- return sendJson(res, 200, { ok: true, forwards: forwards.list() })
2274
- }
2275
- if (action === 'remove') {
2276
- forwards.remove(String(body.id || ''))
2277
- return sendJson(res, 200, { ok: true, forwards: forwards.list() })
2278
- }
2279
- return sendJson(res, 400, { ok: false, error: 'unknown action' })
2280
- } catch (err) {
2281
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2282
- }
2283
- },
2284
- },
2285
- {
2286
- kind: 'exact',
2287
- path: '/dsh-remote/task',
2288
- handler: async (req, res) => {
2289
- if (req.method === 'GET') {
2290
- const q = new URL(req.url, 'http://localhost').searchParams
2291
- const id = q.get('id') || ''
2292
- const t = tasks.get(id)
2293
- if (!t) return sendJson(res, 404, { ok: false, error: 'task not found' })
2294
- return sendJson(res, 200, { ok: true, task: t })
2295
- }
2296
- if (req.method === 'POST') {
2297
- try {
2298
- const body = JSON.parse((await readBody(req)) || '{}')
2299
- const id = String(body.id || '')
2300
- if (body.action === 'cancel') {
2301
- const ok = tasks.cancel(id)
2302
- return sendJson(res, 200, { ok, task: tasks.get(id) })
2303
- }
2304
- return sendJson(res, 400, { ok: false, error: 'unknown action' })
2305
- } catch (err) {
2306
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2307
- }
2308
- }
2309
- return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2310
- },
2311
- },
2312
- {
2313
- kind: 'exact',
2314
- path: '/dsh-remote/tasks',
2315
- handler: async (req, res) => {
2316
- if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2317
- return sendJson(res, 200, { ok: true, tasks: tasks.list() })
2318
- },
2319
- },
2320
- {
2321
- kind: 'exact',
2322
- path: '/dsh-remote/audit',
2323
- handler: async (req, res) => {
2324
- if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2325
- const q = new URL(req.url, 'http://localhost').searchParams
2326
- return sendJson(res, 200, { ok: true, auditEnabled: !!config.auditLog, file: auditFile(), lines: readAudit(q.get('limit')) })
2327
- },
2328
- },
2329
- {
2330
- kind: 'exact',
2331
- path: '/dsh-remote/ssh-config',
2332
- handler: async (req, res) => {
2333
- if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2334
- try {
2335
- const text = readSshConfigText()
2336
- return sendJson(res, 200, { ok: true, file: sshConfigPath(), present: !!text, entries: importableEntries(text) })
2337
- } catch (err) {
2338
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2339
- }
2340
- },
2341
- },
2342
- {
2343
- kind: 'exact',
2344
- path: '/dsh-remote/home',
2345
- handler: async (req, res) => {
2346
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2347
- try {
2348
- const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
2349
- const home = String(out.stdout || '').replace(/\s+/g, '').trim()
2350
- if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
2351
- return sendJson(res, 200, { ok: true, home })
2352
- } catch (err) {
2353
- return sendJson(res, 200, { ok: true, home: null, error: String((err && err.message) || err) })
2354
- }
2355
- },
2356
- },
2357
- {
2358
- kind: 'exact',
2359
- path: '/dsh-remote/update-check',
2360
- handler: async (req, res) => {
2361
- if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2362
- const current = readVersion()
2363
- const latest = await fetchLatestVersion()
2364
- if (latest === null) return sendJson(res, 200, { ok: false, current, error: '无法连接 npm registry' })
2365
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2366
- return sendJson(res, 200, {
2367
- ok: true,
2368
- current,
2369
- latest,
2370
- updateAvailable: gtVersion(latest, current),
2371
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2372
- updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2373
- })
2374
- },
2375
- },
2376
- {
2377
- kind: 'exact',
2378
- path: '/dsh-remote/update-apply',
2379
- handler: async (req, res) => {
2380
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2381
- try {
2382
- const body = JSON.parse((await readBody(req)) || '{}')
2383
- const target = String(body.version || '')
2384
- if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2385
- const result = await applyUpdate(target)
2386
- return sendJson(res, 200, { ok: true, from: readVersion(), ...result })
2387
- } catch (err) {
2388
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2389
- }
2390
- },
2391
- },
2392
- {
2393
- kind: 'exact',
2394
- path: '/dsh-remote/update-mode',
2395
- handler: async (req, res) => {
2396
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2397
- try {
2398
- const body = JSON.parse((await readBody(req)) || '{}')
2399
- const mode = String(body.mode || '')
2400
- if (!['manual', 'auto', 'off'].includes(mode)) return sendJson(res, 400, { ok: false, error: 'mode must be manual | auto | off' })
2401
- if (!persistUpdateMode(mode)) return sendJson(res, 500, { ok: false, error: 'cannot persist mode' })
2402
- return sendJson(res, 200, { ok: true, mode })
2403
- } catch (err) {
2404
- return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2405
- }
2406
- },
2407
- },
2408
- ]
2409
-
2410
- const disposers = routes.map((r) => webServer.register(r))
2411
- ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
2412
-
2413
- // ── auto-update: check on load + on an interval; apply silently ───────────
2414
- // Failures are swallowed (never break the plugin). The persisted override
2415
- // (settings UI) wins over the profile-config default.
2416
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2417
- const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2418
- if (effectiveUpdateMode === 'auto') {
2419
- const currentVersion = readVersion()
2420
- const checkAndApply = async () => {
2421
- const latest = await fetchLatestVersion()
2422
- if (latest && gtVersion(latest, currentVersion)) {
2423
- try { await applyUpdate(latest) } catch {}
2424
- }
2425
- }
2426
- void checkAndApply()
2427
- const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
2428
- if (typeof updateTimer.unref === 'function') updateTimer.unref()
2429
- ctx.effect(() => () => clearInterval(updateTimer), 'dsh-remote.update-timer')
2430
- }
2431
- }
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
+ // SSH agent, keyboard-interactive, proxy jump),
6
+ // • a "current remote workspace" — a remote directory the agent treats as the
7
+ // active project root (user@host:/path) — injected into every system prompt,
8
+ // • model tools `rw_*` (info/connect/workspace/list/read/write/edit/append/mkdir/
9
+ // remove/move/stat/exec/search/download/upload/sync/push/forward/disconnect),
10
+ // • JSON endpoints the client settings page + sidebar use over the harness
11
+ // `webServer` (machines / ls / read / write / fs / forwards / task / audit /
12
+ // ssh-config / local-pick / …),
13
+ // • local mirror of the remote workspace (three-way conflict-aware SFTP sync),
14
+ // • optional OS-keychain password storage, command audit log, TOFU host keys.
15
+ //
16
+ // The engine (path guard + shell quoting + ssh pool + exec) keeps the proven
17
+ // foundation; `ctx.fs` / the local workspace registry stay untouched — this is a
18
+ // REMOTE workspace presented as such to the model and UI.
19
+ //
20
+ // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
21
+ import z from '@deepseek-ai/schemastery'
22
+ import { defineTool } from '@deepseek-ai/dsh-tools'
23
+ import ssh2 from 'ssh2'
24
+ import { execFile } from 'node:child_process'
25
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import path from 'node:path'
28
+ import iconv from 'iconv-lite'
29
+
30
+ import {
31
+ shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
32
+ toSftpPath, remotePathBase, truncate, shortHash, relPathUnder,
33
+ } from './paths.js'
34
+ import { blobAlgorithm, keyFingerprint } from './hostkey.js'
35
+ import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
36
+ import { friendlyMessage } from './errors.js'
37
+ import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js'
38
+ import { saveSecret, getSecret, deleteSecret, platformBackend } from './credential.js'
39
+ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
40
+ import { searchTree } from './search.js'
41
+ import { TaskManager } from './tasks.js'
42
+ import { ForwardManager } from './forwards.js'
43
+ import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
44
+
45
+ const { Client } = ssh2
46
+
47
+ export const name = 'dsh-remote'
48
+
49
+ // tools + systemPrompt + webServer are required. webServer is INJECTED (not just
50
+ // lazily read) so this plugin activates only after the web server is up — otherwise
51
+ // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register.
52
+ export const inject = ['tools', 'systemPrompt', 'webServer']
53
+
54
+ export const Config = z.object({
55
+ /** Remote SSH host (empty → the plugin starts disconnected). */
56
+ host: z.string().default(''),
57
+ /** Remote SSH port (22 unless the machine uses a custom port). */
58
+ port: z.number().step(1).min(1).max(65535).default(22),
59
+ /** SSH login user. */
60
+ username: z.string().default(''),
61
+ /** Password login (only when the remote has no key. Override the fallback below). */
62
+ password: z.string().default(''),
63
+ /** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
64
+ privateKeyPath: z.string().default(''),
65
+ /** Key passphrase when the key is encrypted. */
66
+ passphrase: z.string().default(''),
67
+ /** Initial remote workspace path (absolute dir the agent should treat as root). */
68
+ workspace: z.string().default(''),
69
+ /** Per-command timeout. */
70
+ commandTimeoutMs: z.number().step(1).min(1000).default(20000),
71
+ /** SSH connection establishment timeout. */
72
+ connectTimeoutMs: z.number().step(1).min(1000).default(15000),
73
+ /** Hard ceiling on collected remote output per call. */
74
+ maxOutputChars: z.number().step(1).min(1024).default(200000),
75
+ /** Skip mirroring files larger than this many bytes (0 = no cap). */
76
+ maxFileBytes: z.number().step(1).min(0).default(52428800),
77
+ /** Host-key policy: `accept-new` (default) records a host's key on first
78
+ * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
79
+ * accept-new); `verify` also rejects hosts never seen before; `off` skips
80
+ * verification entirely (MITM-unsafe, not recommended). */
81
+ hostKeyMode: z.string().default('accept-new'),
82
+ /** Use the OpenSSH agent (SSH_AUTH_SOCK) when no password/key is configured. */
83
+ useAgent: z.boolean().default(false),
84
+ /** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
85
+ keyboardInteractive: z.boolean().default(false),
86
+ /** Jump host / bastion: connect through this machine first. All fields have
87
+ * defaults so this works on schemastery versions without `.optional()`; an
88
+ * empty `host` means "no jump host". */
89
+ proxy: z.object({
90
+ host: z.string().default(''),
91
+ port: z.number().step(1).min(1).max(65535).default(22),
92
+ username: z.string().default(''),
93
+ password: z.string().default(''),
94
+ privateKeyPath: z.string().default(''),
95
+ }),
96
+ /** Auto-push edited mirror files back to the remote (watcher, debounced). Default off. */
97
+ autoPush: z.boolean().default(false),
98
+ /** Append executed commands to the audit log under the harness home. */
99
+ auditLog: z.boolean().default(true),
100
+ /** Text encoding for remote file reads/writes (utf-8 default; gbk etc.). */
101
+ encoding: z.string().default('utf-8'),
102
+ /** Update mode: `manual` (default) only checks when asked; `auto` checks on
103
+ * load and periodically, applying a newer npm release automatically;
104
+ * `off` disables version checks entirely. (schemastery 3.18 has no .enum —
105
+ * keep string and validate in code.) */
106
+ updateMode: z.string().default('manual'),
107
+ /** How often (ms) auto mode checks the npm registry for a newer release. */
108
+ updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
109
+ })
110
+
111
+ // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
112
+
113
+ /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
114
+ * own `userData/harness`), otherwise fall back to `~/.dsh`. */
115
+ function dshBase() {
116
+ const env = process.env.DSH_HOME
117
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
118
+ return path.join(homedir(), '.dsh')
119
+ }
120
+
121
+ /** Root holding every remote host's mirrors + the machine registry. */
122
+ function remoteWorkspacesRoot() {
123
+ return path.join(dshBase(), 'remote-workspaces')
124
+ }
125
+
126
+ /** Local mirrors of one remote host. */
127
+ function mirrorRootFor(host, user, port) {
128
+ const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
129
+ return path.join(remoteWorkspacesRoot(), tag)
130
+ }
131
+
132
+ /** Local mirror dir for a specific remote path (idempotent → returns same dir). */
133
+ function mirrorDirFor(remotePath, host, user, port) {
134
+ const base = remotePathBase(remotePath)
135
+ const root = mirrorRootFor(host, user, port)
136
+ const plain = path.join(root, base)
137
+ const norm = normalizeRemotePath(remotePath)
138
+ // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
139
+ try {
140
+ const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
141
+ if (meta.remotePath === norm) return plain
142
+ } catch {
143
+ /* no mirror yet → fall through */
144
+ }
145
+ if (!existsSync(plain)) return plain
146
+ return path.join(root, base + '-' + shortHash(norm))
147
+ }
148
+
149
+ /** Create the local mirror dir + a meta file describing its remote origin. */
150
+ function ensureMirror(remotePath, host, user, port) {
151
+ const dir = mirrorDirFor(remotePath, host, user, port)
152
+ mkdirSync(dir, { recursive: true })
153
+ writeFileSync(
154
+ path.join(dir, '.dsh-remote-meta.json'),
155
+ JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
156
+ )
157
+ return dir
158
+ }
159
+
160
+ /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
161
+ function copyDirSync(from, to) {
162
+ mkdirSync(to, { recursive: true })
163
+ for (const e of readdirSync(from, { withFileTypes: true })) {
164
+ const s = path.join(from, e.name)
165
+ const d = path.join(to, e.name)
166
+ if (e.isDirectory()) copyDirSync(s, d)
167
+ else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
168
+ }
169
+ }
170
+
171
+ /** One-time migration of pre-0.6 data (~/.dsh/remote-workspaces) into DSH_HOME.
172
+ * Only runs when the harness is on its DEFAULT home: an explicitly-set DSH_HOME
173
+ * pointing elsewhere means ~/.dsh belongs to another installation — migrating
174
+ * would RENAME that other installation's live data out from under it. */
175
+ function migrateLegacyData() {
176
+ const env = process.env.DSH_HOME
177
+ if (env && String(env).trim() && path.resolve(String(env).trim()) !== path.join(homedir(), '.dsh')) return
178
+ const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
179
+ const target = remoteWorkspacesRoot()
180
+ if (legacy === target || !existsSync(legacy) || existsSync(target)) return
181
+ try {
182
+ mkdirSync(path.dirname(target), { recursive: true })
183
+ try {
184
+ renameSync(legacy, target)
185
+ } catch (err) {
186
+ if (err.code !== 'EXDEV') throw err
187
+ copyDirSync(legacy, target)
188
+ }
189
+ } catch {
190
+ // Migration is best-effort: a fresh registry is created on next save.
191
+ }
192
+ }
193
+
194
+ // ── persistent multi-machine registry ─────────────────────────────────────
195
+ const MACHINES_FILE = 'machines.json'
196
+ const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
197
+ const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
198
+ const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
199
+ const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
200
+ const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
201
+
202
+ function loadMachines() {
203
+ try {
204
+ const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
205
+ if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
206
+ } catch {}
207
+ return { list: [], currentId: null }
208
+ }
209
+ function saveMachines(list, currentId) {
210
+ try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
211
+ writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
212
+ }
213
+ function sanitizeMachine(m) {
214
+ if (!m) return m
215
+ const { password, proxy, ...rest } = m
216
+ const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
217
+ if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
218
+ return out
219
+ }
220
+ /** Apply a machine's fields onto the live config object (pool + tools read it). */
221
+ function applyMachine(config, m) {
222
+ if (!m) return
223
+ config.host = m.host
224
+ config.port = Number(m.port) || 22
225
+ config.username = m.username || ''
226
+ config.password = m.password || ''
227
+ config.privateKeyPath = m.privateKeyPath || ''
228
+ config.passphrase = m.passphrase || ''
229
+ config.workspace = m.workspace || (config.workspace || '')
230
+ if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
231
+ if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
232
+ if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
233
+ config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
234
+ }
235
+ function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
236
+
237
+ // ── host-key registry (TOFU) ──────────────────────────────────────────────
238
+ const KNOWN_HOSTS_FILE = 'known_hosts.json'
239
+ const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
240
+
241
+ function loadKnownHosts() {
242
+ try {
243
+ const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
244
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
245
+ } catch {}
246
+ return {}
247
+ }
248
+
249
+ /** Build an ssh2 `hostVerifier` bound to the current config. */
250
+ function createHostKeyGuard(config) {
251
+ const id = () => `${config.host}:${config.port}`
252
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
253
+ ? config.hostKeyMode
254
+ : 'accept-new'
255
+ const guard = {
256
+ mode,
257
+ lastError: null,
258
+ knownHosts: loadKnownHosts,
259
+ forgetHost() {
260
+ const kh = loadKnownHosts()
261
+ delete kh[id()]
262
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
263
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
264
+ },
265
+ verifier(key) {
266
+ if (mode === 'off') return true
267
+ const fp = keyFingerprint(key)
268
+ const kh = loadKnownHosts()
269
+ const stored = kh[id()]
270
+ if (stored) {
271
+ if (stored.fingerprint === fp) return true
272
+ guard.lastError =
273
+ `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
274
+ 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
275
+ return false
276
+ }
277
+ if (mode === 'verify') {
278
+ guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
279
+ return false
280
+ }
281
+ kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
282
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
283
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
284
+ return true
285
+ },
286
+ }
287
+ return guard
288
+ }
289
+
290
+ /** Whether the current target's key has been recorded/trusted before. */
291
+ function isHostKeyKnown(host, port) {
292
+ return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
293
+ }
294
+
295
+ // ── SSH pool (key / password / agent / keyboard-interactive / proxy) ──────
296
+
297
+ class SshPool {
298
+ constructor(config) {
299
+ this.config = config
300
+ this.client = null
301
+ this.connecting = null
302
+ this.proxyPool = null
303
+ // Generational token: bumped on every target change / close so a stale
304
+ // in-flight connect can never hand this pool a connection to an old host.
305
+ this.epoch = 0
306
+ /** Optional async resolver for a machine-stored (keychain) password. */
307
+ this.passwordResolver = null
308
+ /** Optional hook called with the live client after a successful connect. */
309
+ this.onReady = null
310
+ /** Optional hook called when the pool closes. */
311
+ this.onCloseHook = null
312
+ }
313
+
314
+ resolveKeyPath() {
315
+ const p = this.config.privateKeyPath
316
+ if (!p) return ''
317
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
318
+ return p
319
+ }
320
+
321
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
322
+ if (host !== undefined) this.config.host = String(host)
323
+ if (port !== undefined && Number(port)) this.config.port = Number(port)
324
+ if (username !== undefined) this.config.username = String(username)
325
+ if (password !== undefined && password !== null) this.config.password = String(password)
326
+ if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
327
+ if (passphrase !== undefined) this.config.passphrase = String(passphrase)
328
+ if (workspace !== undefined) this.config.workspace = String(workspace)
329
+ if (useAgent !== undefined) this.config.useAgent = !!useAgent
330
+ if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
331
+ if (proxy !== undefined) this.config.proxy = proxy
332
+ if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
333
+ this.close()
334
+ return this
335
+ }
336
+
337
+ connect() {
338
+ if (this.client) return Promise.resolve(this.client)
339
+ if (this.connecting) return this.connecting
340
+ const epoch = this.epoch
341
+ const pending = this._doConnect(epoch)
342
+ this.connecting = pending
343
+ const clear = () => {
344
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
345
+ }
346
+ pending.then(clear, clear)
347
+ return pending
348
+ }
349
+
350
+ async _doConnect(epoch) {
351
+ const isCurrent = () => this.epoch === epoch
352
+ const guard = createHostKeyGuard(this.config)
353
+ const client = new Client()
354
+ let settled = false
355
+ const fail = (err) => {
356
+ if (settled) return
357
+ settled = true
358
+ if (isCurrent() && this.client === client) this.client = null
359
+ throw guard.lastError ? new Error(guard.lastError) : err
360
+ }
361
+
362
+ // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
363
+ let sock = null
364
+ const proxyCfg = this.config.proxy
365
+ if (proxyCfg && proxyCfg.host) {
366
+ try {
367
+ this.proxyPool = new SshPool({
368
+ ...this.config,
369
+ host: proxyCfg.host,
370
+ port: Number(proxyCfg.port) || 22,
371
+ username: proxyCfg.username || this.config.username || 'root',
372
+ password: proxyCfg.password || '',
373
+ privateKeyPath: proxyCfg.privateKeyPath || '',
374
+ passphrase: proxyCfg.passphrase || '',
375
+ proxy: undefined,
376
+ })
377
+ const pclient = await this.proxyPool.connect()
378
+ if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
379
+ sock = await new Promise((res, rej) => {
380
+ pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
381
+ })
382
+ } catch (err) {
383
+ return fail(err)
384
+ }
385
+ }
386
+
387
+ return new Promise((resolve, reject) => {
388
+ const rejectOnce = (err) => {
389
+ if (settled) return
390
+ settled = true
391
+ if (isCurrent() && this.client === client) this.client = null
392
+ reject(guard.lastError ? new Error(guard.lastError) : err)
393
+ }
394
+ client.on('ready', () => {
395
+ if (settled) return
396
+ settled = true
397
+ if (!isCurrent()) {
398
+ try { client.end() } catch {}
399
+ reject(new Error('ssh target changed during connect'))
400
+ return
401
+ }
402
+ this.client = client
403
+ resolve(client)
404
+ if (this.onReady) { try { this.onReady(client) } catch {} }
405
+ })
406
+ client.on('error', (e) => rejectOnce(e))
407
+ client.on('close', () => {
408
+ if (isCurrent() && this.client === client) this.client = null
409
+ rejectOnce(new Error('ssh connection closed'))
410
+ })
411
+
412
+ const buildOpts = async () => {
413
+ const opts = {
414
+ host: this.config.host,
415
+ port: this.config.port,
416
+ username: this.config.username,
417
+ readyTimeout: this.config.connectTimeoutMs,
418
+ keepaliveInterval: 15000,
419
+ keepaliveCountMax: 3,
420
+ hostVerifier: (key) => guard.verifier(key),
421
+ }
422
+ if (sock) opts.sock = sock
423
+ if (this.config.useAgent) {
424
+ const sockPath = process.env.SSH_AUTH_SOCK
425
+ if (sockPath) opts.agent = sockPath
426
+ }
427
+ let password = this.config.password || ''
428
+ if (!password && this.passwordResolver) {
429
+ try { password = (await this.passwordResolver()) || '' } catch {}
430
+ }
431
+ if (password) {
432
+ opts.password = password
433
+ opts.tryKeyboard = true
434
+ } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
435
+ opts.tryKeyboard = true
436
+ }
437
+ if (this.config.privateKeyPath) {
438
+ const keyPath = this.resolveKeyPath()
439
+ if (!keyPath) {
440
+ throw new Error('no credentials: set a password or a privateKeyPath to connect')
441
+ }
442
+ let key
443
+ try {
444
+ key = readFileSync(keyPath)
445
+ } catch (err) {
446
+ throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
447
+ }
448
+ opts.privateKey = key
449
+ opts.passphrase = this.config.passphrase || undefined
450
+ } else if (!password && !opts.agent) {
451
+ throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
452
+ }
453
+ return opts
454
+ }
455
+
456
+ buildOpts().then(
457
+ (opts) => {
458
+ if (opts.tryKeyboard) {
459
+ client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
460
+ finish(prompts.map(() => this.config.password || ''))
461
+ })
462
+ }
463
+ client.connect(opts)
464
+ },
465
+ (err) => rejectOnce(err),
466
+ )
467
+ })
468
+ }
469
+
470
+ /** Run one remote command; resolves { code, signal, stdout, stderr }.
471
+ * Accepts `exec(cmd, timeoutMs)` (legacy) or `exec(cmd, {timeoutMs, pty, env})`. */
472
+ exec(command, timeoutMsOrOpts) {
473
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
474
+ const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
475
+ return this.connect().then(
476
+ (client) =>
477
+ new Promise((resolve, reject) => {
478
+ let retried = false
479
+ const runOn = (c) => {
480
+ const execOpts = {}
481
+ if (opts.pty) execOpts.pty = true
482
+ if (opts.env && typeof opts.env === 'object') execOpts.env = opts.env
483
+ c.exec(command, execOpts, (err, stream) => {
484
+ if (err) {
485
+ // Channel-open failure usually means the pooled connection died
486
+ // server-side (idle timeout / network reset) while keepalive
487
+ // hadn't noticed. Drop it and retry ONCE on a fresh connection.
488
+ if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
489
+ retried = true
490
+ this.invalidate()
491
+ return this.connect().then(
492
+ (fresh) => runOn(fresh),
493
+ (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
494
+ )
495
+ }
496
+ return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
497
+ }
498
+ let stdout = ''
499
+ let stderr = ''
500
+ let settled = false
501
+ let exitCode = null
502
+ let exitSignal = null
503
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
504
+ const settle = () => {
505
+ if (settled) return
506
+ settled = true
507
+ clearTimeout(timer)
508
+ resolve({
509
+ code: exitCode,
510
+ signal: exitSignal,
511
+ stdout: truncate(stdout, this.config.maxOutputChars),
512
+ stderr: truncate(stderr, this.config.maxOutputChars),
513
+ })
514
+ }
515
+ const timer = setTimeout(() => {
516
+ if (settled) return
517
+ exitCode = -1
518
+ exitSignal = 'TIMEOUT'
519
+ // Kill the remote command (SIGTERM) rather than just dropping the
520
+ // channel, so a runaway process cannot keep running and holding
521
+ // the SSH connection after we've given up on its output.
522
+ try {
523
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
524
+ } catch {}
525
+ const hardClose = setTimeout(() => {
526
+ try { stream.close() } catch {}
527
+ }, 800)
528
+ if (typeof hardClose.unref === 'function') hardClose.unref()
529
+ settle()
530
+ }, timeoutMs)
531
+ stream.on('close', (code, signal) => {
532
+ if (settled) return
533
+ exitCode = code
534
+ exitSignal = signal
535
+ settle()
536
+ })
537
+ stream.on('data', (d) => {
538
+ if (stdout.length < hardCap) stdout += d
539
+ })
540
+ stream.stderr.on('data', (d) => {
541
+ if (stderr.length < hardCap) stderr += d
542
+ })
543
+ stream.on('error', (e) => {
544
+ if (settled) return
545
+ settled = true
546
+ clearTimeout(timer)
547
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
548
+ })
549
+ })
550
+ }
551
+ runOn(client)
552
+ }),
553
+ )
554
+ }
555
+
556
+ /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
557
+ sftp() {
558
+ return this.connect().then(
559
+ (client) =>
560
+ new Promise((resolve, reject) => {
561
+ let retried = false
562
+ const runOn = (c) => {
563
+ c.sftp((err, sftp) => {
564
+ if (err) {
565
+ // Same dead-connection recovery as exec(): a channel open
566
+ // failure means the pooled connection is stale — drop it and
567
+ // retry ONCE on a fresh connection.
568
+ if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
569
+ retried = true
570
+ this.invalidate()
571
+ return this.connect().then(
572
+ (fresh) => runOn(fresh),
573
+ (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
574
+ )
575
+ }
576
+ return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
577
+ }
578
+ const withTimeout = (fn) => (...args) =>
579
+ new Promise((r2, j2) => {
580
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
581
+ const done = (e, v) => {
582
+ clearTimeout(timer)
583
+ e ? j2(e) : r2(v)
584
+ }
585
+ try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
586
+ })
587
+ const P = (p) => toSftpPath(p)
588
+ resolve({
589
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
590
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
591
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
592
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
593
+ rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
594
+ unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
595
+ rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
596
+ realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
597
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
598
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
599
+ fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
600
+ fastPut: (p, lp) => withTimeout((d, l, cb) => sftp.fastPut(d, l, cb))(P(p), lp),
601
+ })
602
+ })
603
+ }
604
+ runOn(client)
605
+ }),
606
+ )
607
+ }
608
+
609
+ /**
610
+ * Drop the cached client and force a fresh connection on the next call.
611
+ * Called when a channel open fails (e.g. "Channel open failure: open
612
+ * failed") — the pooled SSH connection is usually dead server-side while
613
+ * keepalive has not yet noticed, and reusing it keeps failing. The epoch
614
+ * bump orphans any in-flight connect; the client is ended so ssh2 frees
615
+ * its sockets.
616
+ */
617
+ invalidate() {
618
+ this.epoch++
619
+ const client = this.client
620
+ this.client = null
621
+ const pending = this.connecting
622
+ this.connecting = null
623
+ if (pending && typeof pending.catch === 'function') {
624
+ try { pending.catch(() => {}) } catch {}
625
+ }
626
+ if (this.proxyPool) {
627
+ try { this.proxyPool.close() } catch {}
628
+ this.proxyPool = null
629
+ }
630
+ if (client) {
631
+ try { client.end() } catch {}
632
+ }
633
+ }
634
+
635
+ close() {
636
+ this.epoch++
637
+ const client = this.client
638
+ this.client = null
639
+ const pending = this.connecting
640
+ this.connecting = null
641
+ if (pending && typeof pending.catch === 'function') {
642
+ try { pending.catch(() => {}) } catch {}
643
+ }
644
+ if (this.proxyPool) {
645
+ try { this.proxyPool.close() } catch {}
646
+ this.proxyPool = null
647
+ }
648
+ if (client) {
649
+ try {
650
+ client.end()
651
+ } catch {}
652
+ }
653
+ if (this.onCloseHook) {
654
+ try { this.onCloseHook() } catch {}
655
+ }
656
+ }
657
+ }
658
+
659
+ // ── encoding helpers ───────────────────────────────────────────────────────
660
+
661
+ function decodeBuf(buf, enc) {
662
+ const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
663
+ return e ? iconv.decode(buf, e) : buf.toString('utf8')
664
+ }
665
+ function encodeText(s, enc) {
666
+ const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
667
+ return e ? iconv.encode(String(s), e) : Buffer.from(String(s), 'utf8')
668
+ }
669
+
670
+ // ── apply ─────────────────────────────────────────────────────────────────
671
+
672
+ export async function apply(ctx, config) {
673
+ const pool = new SshPool(config)
674
+ ctx.effect(() => () => pool.close(), 'dsh-remote.close')
675
+
676
+ migrateLegacyData()
677
+
678
+ // ── machine registry (multi-host) ─────────────────────────────────────────
679
+ const store = loadMachines()
680
+ const machines = store.list
681
+ const machineIndex = (id) => machines.findIndex((m) => m.id === id)
682
+
683
+ /** The machine the pool is currently bound to: an ephemeral tool connection
684
+ * (rw_connect save:false) wins, then the stored current, then config default. */
685
+ let ephemeral = null
686
+ const activeMachine = () => {
687
+ if (ephemeral) return ephemeral
688
+ if (store.currentId) {
689
+ const i = machineIndex(store.currentId)
690
+ if (i >= 0) return machines[i]
691
+ }
692
+ 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 }
693
+ return null
694
+ }
695
+ const currentMachine = activeMachine
696
+
697
+ /** Resolve a machine's effective password (keychain backend support). */
698
+ const machinePassword = async (m) => {
699
+ if (m && m.password) return m.password
700
+ if (m && m.credentialBackend && m.credentialBackend !== 'plain') {
701
+ const p = await getSecret(m.id, secretsDir())
702
+ if (p) return p
703
+ }
704
+ return ''
705
+ }
706
+
707
+ // The pool resolves keychain-stored passwords lazily at connect time.
708
+ pool.passwordResolver = async () => {
709
+ const m = activeMachine()
710
+ return machinePassword(m)
711
+ }
712
+
713
+ const applyActiveMachine = async () => {
714
+ const m = activeMachine()
715
+ if (!m || !m.host) return
716
+ const pw = await machinePassword(m)
717
+ applyMachine(config, { ...m, password: pw || m.password || '' })
718
+ pool.setTarget({
719
+ host: config.host, port: config.port, username: config.username,
720
+ password: config.password, privateKeyPath: config.privateKeyPath,
721
+ passphrase: config.passphrase, workspace: config.workspace,
722
+ useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
723
+ proxy: config.proxy, hostKeyMode: config.hostKeyMode,
724
+ })
725
+ }
726
+
727
+ const setCurrent = async (id) => {
728
+ const i = machineIndex(id)
729
+ if (i < 0) return false
730
+ store.currentId = id
731
+ ephemeral = null
732
+ saveMachines(machines, id)
733
+ await applyActiveMachine()
734
+ return true
735
+ }
736
+
737
+ // If no stored current, adopt a CLI-provided default as the active machine.
738
+ {
739
+ const cur = currentMachine()
740
+ if (cur && cur.host && !store.currentId) applyMachine(config, cur)
741
+ }
742
+
743
+ /** Persist the active workspace on the machine the pool is actually bound to
744
+ * (fixes the old bug where a tool-connected machine saved its workspace onto
745
+ * the registry's current machine instead). */
746
+ const persistWorkspace = (p) => {
747
+ config.workspace = p
748
+ const m = activeMachine()
749
+ if (m) {
750
+ if (store.currentId && m.id === store.currentId && machineIndex(m.id) >= 0) {
751
+ const rec = machines[machineIndex(m.id)]
752
+ rec.workspace = p
753
+ rec.recentWorkspaces = [p, ...(rec.recentWorkspaces || []).filter((x) => x !== p)].slice(0, 8)
754
+ saveMachines(machines, store.currentId)
755
+ } else if (ephemeral) {
756
+ ephemeral.workspace = p
757
+ }
758
+ }
759
+ }
760
+
761
+ // ── audit log ─────────────────────────────────────────────────────────────
762
+ const audit = (op, cmd, code) => {
763
+ if (!config.auditLog) return
764
+ try {
765
+ const line = [new Date().toISOString(), `${config.username || '?'}@${config.host || '?'}:${config.port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
766
+ appendFileSync(auditFile(), line, 'utf8')
767
+ } catch {}
768
+ }
769
+ const readAudit = (limit) => {
770
+ try {
771
+ const text = readFileSync(auditFile(), 'utf8')
772
+ const lines = text.split('\n').filter(Boolean)
773
+ return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
774
+ } catch {
775
+ return []
776
+ }
777
+ }
778
+
779
+ // ── ignore rules (defaults + user file) ───────────────────────────────────
780
+ const ignoreMatcher = () => {
781
+ try {
782
+ const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
783
+ return compileIgnore(DEFAULT_IGNORE.concat(fromFile))
784
+ } catch {
785
+ return compileIgnore(DEFAULT_IGNORE)
786
+ }
787
+ }
788
+
789
+ // ── task + forward managers ───────────────────────────────────────────────
790
+ const tasks = new TaskManager()
791
+ const forwards = new ForwardManager(pool, { file: forwardsFile() })
792
+ pool.onReady = (client) => forwards.attach(client)
793
+ pool.onCloseHook = () => forwards.detach()
794
+
795
+ // ── auto-push watcher (config.autoPush, default off) ──────────────────────
796
+ // Watches the local mirror; local edits are pushed back to the remote after a
797
+ // 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
798
+ // change is never clobbered — it is recorded as a conflict in the audit log).
799
+ const autoPushWatchers = new Map() // localDir → { watcher, pending:Set, timer }
800
+ const flushAutoPush = async (localDir) => {
801
+ const entry = autoPushWatchers.get(localDir)
802
+ if (!entry || !entry.pending.size) return
803
+ const rels = [...entry.pending]
804
+ entry.pending.clear()
805
+ const ws = wsPath()
806
+ if (!ws || mirrorDirFor(ws, config.host, config.username, config.port) !== localDir) return
807
+ let sftp
808
+ try { sftp = await pool.sftp() } catch { return }
809
+ const matcher = ignoreMatcher()
810
+ const state = loadSyncState(localDir)
811
+ const next = { ...state }
812
+ if (rels.includes('*')) {
813
+ const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
814
+ Object.assign(next, r.nextState)
815
+ for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1)
816
+ } else {
817
+ for (const rel of rels) {
818
+ const r = await pushOneFile(sftp, localDir, ws, rel, { maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
819
+ if (r.status === 'pushed' && r.state) Object.assign(next, r.state)
820
+ else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1)
821
+ }
822
+ }
823
+ saveSyncState(localDir, next)
824
+ }
825
+ const startAutoPush = (localDir) => {
826
+ if (!config.autoPush || autoPushWatchers.has(localDir)) return
827
+ const entry = { pending: new Set(), timer: null, watcher: null }
828
+ const schedule = () => {
829
+ if (entry.timer) clearTimeout(entry.timer)
830
+ entry.timer = setTimeout(() => flushAutoPush(localDir), 3000)
831
+ }
832
+ const onEvent = (eventType, filename) => {
833
+ if (!filename) { entry.pending.add('*'); return schedule() }
834
+ const rel = String(filename).replace(/\\/g, '/')
835
+ if (rel === '.dsh-remote-meta.json' || rel === '.dsh-remote-sync-state.json' || rel.startsWith('.dsh-remote-sync-state.json.tmp')) return
836
+ entry.pending.add(rel)
837
+ schedule()
838
+ }
839
+ try {
840
+ entry.watcher = watch(localDir, { recursive: true }, onEvent)
841
+ } catch {
842
+ try { entry.watcher = watch(localDir, onEvent) } catch { return }
843
+ }
844
+ autoPushWatchers.set(localDir, entry)
845
+ }
846
+ ctx.effect(() => () => {
847
+ for (const e of autoPushWatchers.values()) {
848
+ if (e.timer) clearTimeout(e.timer)
849
+ try { e.watcher && e.watcher.close() } catch {}
850
+ }
851
+ autoPushWatchers.clear()
852
+ }, 'dsh-remote.autopush')
853
+
854
+ const run = async (cmd, opts = {}) => {
855
+ const res = await pool.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
856
+ const parts = []
857
+ if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
858
+ if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
859
+ if (!parts.length) parts.push('(no output)')
860
+ let text = parts.join('\n')
861
+ if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
862
+ else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
863
+ return text
864
+ }
865
+
866
+ /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
867
+ * works on any remote: POSIX / cmd.exe / PowerShell). */
868
+ const listDirStructured = async (p) => {
869
+ const target = normalizeRemotePath(p || '/')
870
+ let sftp
871
+ try {
872
+ sftp = await pool.sftp()
873
+ } catch (err) {
874
+ throw new Error('browse failed: ' + ((err && err.message) || err))
875
+ }
876
+ let list
877
+ try {
878
+ list = await sftp.readdir(target)
879
+ } catch (err) {
880
+ throw new Error('browse failed: ' + ((err && err.message) || err))
881
+ }
882
+ const items = []
883
+ const symIdx = []
884
+ for (const e of list) {
885
+ const name = String(e.filename)
886
+ if (name === '.' || name === '..' || !name) continue
887
+ const a = e.attrs || {}
888
+ let type
889
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
890
+ type = 'symlink'
891
+ symIdx.push(items.length)
892
+ } else if (a.isDirectory && a.isDirectory()) {
893
+ type = 'dir'
894
+ } else {
895
+ type = 'file'
896
+ }
897
+ items.push({
898
+ type,
899
+ name,
900
+ size: typeof a.size === 'number' ? a.size : 0,
901
+ mtime: typeof a.mtime === 'number' ? a.mtime : 0,
902
+ mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
903
+ })
904
+ }
905
+ // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
906
+ if (symIdx.length) {
907
+ await Promise.all(symIdx.map(async (i) => {
908
+ const full = joinRemotePath(target, items[i].name)
909
+ try {
910
+ const st = await sftp.lstat(full)
911
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
912
+ } catch { /* degrade to file */ }
913
+ }))
914
+ }
915
+ return { path: target, items }
916
+ }
917
+
918
+ const isRemoteDir = async (p) => {
919
+ const target = normalizeRemotePath(p)
920
+ try {
921
+ const sftp = await pool.sftp()
922
+ const st = await sftp.stat(target)
923
+ return !!(st && st.isDirectory && st.isDirectory())
924
+ } catch {
925
+ return false
926
+ }
927
+ }
928
+
929
+ /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
930
+ const removeRemoteTree = async (sftp, p, maxFiles = 2000) => {
931
+ let removed = 0
932
+ const walk = async (dir) => {
933
+ if (removed >= maxFiles) return
934
+ let entries = []
935
+ try { entries = (await sftp.readdir(dir)) || [] } catch { return }
936
+ for (const e of entries) {
937
+ if (removed >= maxFiles) return
938
+ const name = String(e.filename)
939
+ if (name === '.' || name === '..') continue
940
+ const fp = joinRemotePath(dir, name)
941
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
942
+ if (isDir) await walk(fp)
943
+ else {
944
+ try { await sftp.unlink(fp); removed++ } catch {}
945
+ }
946
+ }
947
+ try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
948
+ }
949
+ await walk(p)
950
+ return removed
951
+ }
952
+
953
+ // ── remote workspace state ────────────────────────────────────────────────
954
+ const wsPath = () => (config.workspace || '').trim()
955
+ if (config.autoPush && wsPath()) {
956
+ startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
957
+ }
958
+ const status = () => ({
959
+ host: config.host,
960
+ port: config.port,
961
+ username: config.username,
962
+ connected: !!pool.client,
963
+ workspace: wsPath(),
964
+ localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
965
+ currentId: store.currentId || null,
966
+ activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
967
+ machines: machines.map(sanitizeMachine),
968
+ hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
969
+ hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
970
+ forwards: forwards.list(),
971
+ auditEnabled: !!config.auditLog,
972
+ backend: platformBackend(),
973
+ })
974
+
975
+ // ── tools ─────────────────────────────────────────────────────────────────
976
+
977
+ const textOut = {
978
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
979
+ render: (_a, v) => [{ type: 'text', text: v.text }],
980
+ }
981
+ const okOut = {
982
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
983
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'ok' : 'failed') }],
984
+ }
985
+
986
+ const tools = [
987
+ defineTool({
988
+ name: 'rw_info',
989
+ description:
990
+ 'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
991
+ parameters: {},
992
+ output: textOut,
993
+ async execute() {
994
+ const s = status()
995
+ const lines = [
996
+ `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
997
+ `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
998
+ `Local mirror: ${s.localMirror || '(none)'}`,
999
+ `Connected: ${s.connected ? 'yes' : 'no'}`,
1000
+ `Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
1001
+ `Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
1002
+ '',
1003
+ ]
1004
+ if (s.host && s.workspace) {
1005
+ try {
1006
+ const res = await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1007
+ if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
1008
+ else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
1009
+ else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
1010
+ } catch (err) {
1011
+ lines.push('Ping: FAILED — ' + friendlyMessage(err, s))
1012
+ }
1013
+ } else {
1014
+ lines.push('No host + workspace configured — call rw_connect with a host to get started.')
1015
+ }
1016
+ return { text: lines.join('\n') }
1017
+ },
1018
+ }),
1019
+
1020
+ defineTool({
1021
+ name: 'rw_connect',
1022
+ description:
1023
+ 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
1024
+ parameters: {
1025
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
1026
+ username: { type: 'string', description: 'SSH user (default from config or root)' },
1027
+ port: { type: 'integer', description: 'SSH port (default 22)' },
1028
+ password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
1029
+ privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
1030
+ save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
1031
+ },
1032
+ output: textOut,
1033
+ async execute(args) {
1034
+ const host = String(args.host || '').trim()
1035
+ if (!host) throw new Error('rw_connect: host is required')
1036
+ const user = args.username || config.username || 'root'
1037
+ const port = Number(args.port) || undefined
1038
+ const rec = {
1039
+ host,
1040
+ port: port || 22,
1041
+ username: user,
1042
+ password: args.password !== undefined ? String(args.password) : '',
1043
+ privateKeyPath: args.privateKeyPath || '',
1044
+ }
1045
+ if (args.save !== false) {
1046
+ // Upsert into the registry and make it the current machine so the
1047
+ // settings UI and the tools always agree on who is active.
1048
+ const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
1049
+ if (i >= 0) {
1050
+ machines[i] = { ...machines[i], ...rec, id: machines[i].id, password: rec.password || machines[i].password || '' }
1051
+ store.currentId = machines[i].id
1052
+ saveMachines(machines, store.currentId)
1053
+ } else {
1054
+ const id = machineId()
1055
+ machines.push({ id, name: host, ...rec })
1056
+ store.currentId = id
1057
+ saveMachines(machines, store.currentId)
1058
+ }
1059
+ ephemeral = null
1060
+ await applyActiveMachine()
1061
+ } else {
1062
+ ephemeral = { id: machineId(), name: host, ...rec }
1063
+ pool.setTarget({
1064
+ host, port: port || 22, username: user,
1065
+ password: rec.password, privateKeyPath: rec.privateKeyPath,
1066
+ workspace: config.workspace,
1067
+ })
1068
+ }
1069
+ try {
1070
+ const res = await pool.exec('echo ok', { timeoutMs: 8000 })
1071
+ if (res.code !== 0 && !res.stdout) {
1072
+ audit('connect', `connect ${user}@${host}:${port || 22}`, res.code)
1073
+ return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
1074
+ }
1075
+ audit('connect', `connect ${user}@${host}:${port || 22}`, 0)
1076
+ return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1077
+ } catch (err) {
1078
+ throw new Error(friendlyMessage(err, { host, port }))
1079
+ }
1080
+ },
1081
+ }),
1082
+
1083
+ defineTool({
1084
+ name: 'rw_pick_workspace',
1085
+ description:
1086
+ '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.',
1087
+ parameters: {
1088
+ path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
1089
+ },
1090
+ output: textOut,
1091
+ async execute(args) {
1092
+ const p = normalizeRemotePath(String(args.path || ''))
1093
+ if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1094
+ const ok = await isRemoteDir(p)
1095
+ if (!ok) return { text: `not a directory (or missing) on ${p}` }
1096
+ persistWorkspace(p)
1097
+ const local = ensureMirror(p, config.host, config.username, config.port)
1098
+ startAutoPush(local)
1099
+ return {
1100
+ text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1101
+ }
1102
+ },
1103
+ }),
1104
+
1105
+ defineTool({
1106
+ name: 'rw_sync',
1107
+ description:
1108
+ 'Download the current remote workspace into its local mirror directory over SFTP (bounded, three-way conflict-aware). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1109
+ parameters: {
1110
+ depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
1111
+ maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
1112
+ dryRun: { type: 'boolean', description: 'Compute the plan without downloading (default false)' },
1113
+ force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1114
+ async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1115
+ },
1116
+ output: textOut,
1117
+ async execute(args) {
1118
+ const p = wsPath()
1119
+ if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
1120
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
1121
+ mkdirSync(local, { recursive: true })
1122
+ const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1123
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1124
+ const isIgnored = ignoreMatcher()
1125
+ const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1126
+ const runSync = async () => {
1127
+ let sftp
1128
+ try {
1129
+ sftp = await pool.sftp()
1130
+ } catch (err) {
1131
+ throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1132
+ }
1133
+ const state = loadSyncState(local)
1134
+ const { stats, nextState } = await syncTree(sftp, p, local, { ...body, state, maxFileBytes: config.maxFileBytes })
1135
+ if (!args.dryRun) saveSyncState(local, nextState)
1136
+ let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}${stats.files >= maxFiles ? ' (hit download cap)' : ''}.`
1137
+ if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1138
+ if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1139
+ if (stats.staleRemote) text += ` ${stats.staleRemote} remote entries gone (kept locally; use rw_push to mirror deletions).`
1140
+ if (stats.conflicts.length) {
1141
+ text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
1142
+ for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
1143
+ if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
1144
+ text += '\n(use force=true to override)'
1145
+ }
1146
+ return { text }
1147
+ }
1148
+ if (args.async) {
1149
+ const t = tasks.start('sync', `sync ${p}`, runSync)
1150
+ return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1151
+ }
1152
+ return runSync()
1153
+ },
1154
+ }),
1155
+
1156
+ defineTool({
1157
+ name: 'rw_push',
1158
+ description:
1159
+ 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded, three-way conflict-aware). Use after editing files in the local mirror so the remote reflects your changes. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
1160
+ parameters: {
1161
+ maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
1162
+ dryRun: { type: 'boolean', description: 'Compute the plan without uploading (default false)' },
1163
+ force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
1164
+ async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1165
+ },
1166
+ output: textOut,
1167
+ async execute(args) {
1168
+ const p = wsPath()
1169
+ if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
1170
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
1171
+ if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1172
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1173
+ const isIgnored = ignoreMatcher()
1174
+ const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1175
+ const runPush = async () => {
1176
+ let sftp
1177
+ try {
1178
+ sftp = await pool.sftp()
1179
+ } catch (err) {
1180
+ throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1181
+ }
1182
+ const state = loadSyncState(local)
1183
+ const { stats, nextState } = await pushTree(sftp, local, p, { ...body, state, maxFileBytes: config.maxFileBytes })
1184
+ if (!args.dryRun) saveSyncState(local, nextState)
1185
+ let text = `${args.dryRun ? 'WOULD upload' : 'Uploaded'} ${stats.files} file(s) from ${local} → ${p}.`
1186
+ if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
1187
+ if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
1188
+ if (stats.staleLocal) text += ` ${stats.staleLocal} local entries gone remotely (kept remotely; use rw_remove to mirror deletions).`
1189
+ if (stats.conflicts.length) {
1190
+ text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
1191
+ for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
1192
+ if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
1193
+ text += '\n(use force=true to override)'
1194
+ }
1195
+ return { text }
1196
+ }
1197
+ if (args.async) {
1198
+ const t = tasks.start('push', `push ${p}`, runPush)
1199
+ return { text: `push started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1200
+ }
1201
+ return runPush()
1202
+ },
1203
+ }),
1204
+
1205
+ defineTool({
1206
+ name: 'rw_list_dir',
1207
+ description:
1208
+ 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace. Shows type, size, mtime.',
1209
+ parameters: {
1210
+ path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
1211
+ },
1212
+ output: textOut,
1213
+ async execute(args) {
1214
+ const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
1215
+ if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1216
+ let list
1217
+ try {
1218
+ const sftp = await pool.sftp()
1219
+ list = await sftp.readdir(p)
1220
+ } catch (err) {
1221
+ throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1222
+ }
1223
+ const fmtMtime = (t) => {
1224
+ if (!t) return '?'
1225
+ const d = new Date(t * 1000)
1226
+ const pad = (n) => String(n).padStart(2, '0')
1227
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
1228
+ }
1229
+ const lines = list
1230
+ .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1231
+ .map((e) => {
1232
+ const a = e.attrs || {}
1233
+ const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1234
+ const size = typeof a.size === 'number' ? String(a.size) : '?'
1235
+ return `${type} ${size.padStart(10)} ${fmtMtime(a.mtime).padEnd(17)} ${String(e.filename)}`
1236
+ })
1237
+ return { text: lines.length ? lines.join('\n') : '(empty directory)' }
1238
+ },
1239
+ }),
1240
+
1241
+ defineTool({
1242
+ name: 'rw_stat',
1243
+ description:
1244
+ 'Show detailed stat of a remote file or directory: type, size, mtime, mode (SFTP attrs). Use to verify a remote path exists or to compare files.',
1245
+ parameters: {
1246
+ path: { type: 'string', required: true, description: 'Absolute remote path' },
1247
+ },
1248
+ output: textOut,
1249
+ async execute(args) {
1250
+ const p = normalizeRemotePath(String(args.path || ''))
1251
+ if (!p) throw new Error('rw_stat: path is required')
1252
+ const sftp = await pool.sftp()
1253
+ let st
1254
+ try {
1255
+ st = await sftp.stat(p)
1256
+ } catch (err) {
1257
+ throw new Error('rw_stat: not found or unreadable: ' + ((err && err.message) || err))
1258
+ }
1259
+ const type = st.isDirectory && st.isDirectory() ? 'directory' : (st.isSymbolicLink && st.isSymbolicLink() ? 'symlink' : 'file')
1260
+ const lines = [
1261
+ `path: ${p}`,
1262
+ `type: ${type}`,
1263
+ `size: ${st.size} bytes`,
1264
+ `mtime: ${new Date(st.mtime * 1000).toISOString()}`,
1265
+ `mode: ${typeof st.mode === 'number' ? st.mode.toString(8) : '?'}`,
1266
+ ]
1267
+ return { text: lines.join('\n') }
1268
+ },
1269
+ }),
1270
+
1271
+ defineTool({
1272
+ name: 'rw_read_file',
1273
+ description:
1274
+ 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine and an encoding param (utf-8 default, gbk etc). Path is absolute.',
1275
+ parameters: {
1276
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1277
+ startLine: { type: 'integer', description: '1-based first line (default 1)' },
1278
+ endLine: { type: 'integer', description: '1-based last line (inclusive)' },
1279
+ maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
1280
+ encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1281
+ },
1282
+ output: textOut,
1283
+ async execute(args) {
1284
+ const p = normalizeRemotePath(String(args.path || ''))
1285
+ if (!p) throw new Error('rw_read_file: path is required')
1286
+ const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1287
+ let from = Math.max(Number(args.startLine) || 1, 1)
1288
+ let to = Number(args.endLine) || 0
1289
+ if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
1290
+ const sftp = await pool.sftp()
1291
+ let st
1292
+ try { st = await sftp.stat(p) } catch (err) { throw new Error('rw_read_file: ' + ((err && err.message) || err)) }
1293
+ if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
1294
+ throw new Error(`rw_read_file: file is ${st.size} bytes (over ${config.maxFileBytes} cap); use rw_download or rw_exec to read it`)
1295
+ }
1296
+ let buf
1297
+ try {
1298
+ buf = await sftp.readFile(p)
1299
+ } catch (err) {
1300
+ throw new Error('rw_read_file: ' + ((err && err.message) || err))
1301
+ }
1302
+ const content = decodeBuf(buf, args.encoding || config.encoding).replace(/\r\n/g, '\n')
1303
+ const allLines = content.split('\n')
1304
+ const page = allLines.slice(from - 1, to)
1305
+ const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1306
+ let text = numbered === '' ? '(empty or out of range)' : numbered
1307
+ if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
1308
+ return { text }
1309
+ },
1310
+ }),
1311
+
1312
+ defineTool({
1313
+ name: 'rw_write_file',
1314
+ description:
1315
+ '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.',
1316
+ parameters: {
1317
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1318
+ content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
1319
+ mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
1320
+ encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1321
+ },
1322
+ output: okOut,
1323
+ async execute(args) {
1324
+ const p = normalizeRemotePath(String(args.path || ''))
1325
+ if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1326
+ const content = String(args.content == null ? '' : args.content)
1327
+ const sftp = await pool.sftp()
1328
+ if (args.mkdir !== false) await mkdirRemoteDirs(sftp, remoteDirname(p))
1329
+ const buf = encodeText(content, args.encoding || config.encoding)
1330
+ await sftp.writeFile(p, buf)
1331
+ const bytes = buf.byteLength
1332
+ audit('write_file', `write ${p} (${bytes}B)`, 0)
1333
+ return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
1334
+ },
1335
+ }),
1336
+
1337
+ defineTool({
1338
+ name: 'rw_edit',
1339
+ description:
1340
+ 'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
1341
+ parameters: {
1342
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1343
+ old: { type: 'string', required: true, description: 'Literal text to replace (must appear exactly once unless count is given)' },
1344
+ new: { type: 'string', required: true, description: 'Replacement text' },
1345
+ count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
1346
+ encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1347
+ },
1348
+ output: okOut,
1349
+ async execute(args) {
1350
+ const p = normalizeRemotePath(String(args.path || ''))
1351
+ if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1352
+ const oldS = String(args.old ?? '')
1353
+ const newS = String(args.new ?? '')
1354
+ if (oldS === '') throw new Error('rw_edit: old text must not be empty')
1355
+ const sftp = await pool.sftp()
1356
+ const st0 = await sftp.stat(p)
1357
+ const buf = await sftp.readFile(p)
1358
+ const content = decodeBuf(buf, args.encoding || config.encoding)
1359
+ const count = args.count == null ? 0 : Math.max(Number(args.count) || 1, 1)
1360
+ const idxs = []
1361
+ let from = 0
1362
+ let hit
1363
+ while ((hit = content.indexOf(oldS, from)) !== -1) { idxs.push(hit); from = hit + oldS.length }
1364
+ if (!idxs.length) throw new Error(`rw_edit: old text not found in ${p}`)
1365
+ if (count === 0 && idxs.length > 1) {
1366
+ throw new Error(`rw_edit: "old" appears ${idxs.length} times in ${p} — pass count=<n> to pick how many to replace`)
1367
+ }
1368
+ const n = count === 0 ? 1 : Math.min(count, idxs.length)
1369
+ let out = content
1370
+ for (let i = n - 1; i >= 0; i--) {
1371
+ out = out.slice(0, idxs[i]) + newS + out.slice(idxs[i] + oldS.length)
1372
+ }
1373
+ // Optimistic lock: the remote must not have changed since we read it.
1374
+ const st1 = await sftp.stat(p)
1375
+ if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
1376
+ throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
1377
+ }
1378
+ await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
1379
+ audit('edit', `edit ${p} (${n} occurrence(s))`, 0)
1380
+ return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
1381
+ },
1382
+ }),
1383
+
1384
+ defineTool({
1385
+ name: 'rw_append',
1386
+ description:
1387
+ 'Append text to a remote file (creates it when missing). Path is absolute.',
1388
+ parameters: {
1389
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1390
+ content: { type: 'string', required: true, description: 'Text to append' },
1391
+ encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1392
+ },
1393
+ output: okOut,
1394
+ async execute(args) {
1395
+ const p = normalizeRemotePath(String(args.path || ''))
1396
+ if (!p || p === '/') throw new Error('rw_append: a file path is required')
1397
+ const sftp = await pool.sftp()
1398
+ let existing = ''
1399
+ try { existing = decodeBuf(await sftp.readFile(p), args.encoding || config.encoding) } catch { /* new file */ }
1400
+ const content = existing + String(args.content ?? '')
1401
+ await sftp.writeFile(p, encodeText(content, args.encoding || config.encoding))
1402
+ const bytes = Buffer.byteLength(content)
1403
+ audit('append', `append ${p}`, 0)
1404
+ return { ok: true, bytes, text: `appended to ${p} (now ${bytes} bytes)` }
1405
+ },
1406
+ }),
1407
+
1408
+ defineTool({
1409
+ name: 'rw_mkdir',
1410
+ description:
1411
+ 'Create a remote directory (mkdir -p semantics, all levels). Path is absolute.',
1412
+ parameters: {
1413
+ path: { type: 'string', required: true, description: 'Absolute remote directory path' },
1414
+ },
1415
+ output: textOut,
1416
+ async execute(args) {
1417
+ const p = normalizeRemotePath(String(args.path || ''))
1418
+ if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
1419
+ const sftp = await pool.sftp()
1420
+ await mkdirRemoteDirs(sftp, p)
1421
+ audit('mkdir', `mkdir ${p}`, 0)
1422
+ return { text: `created ${p}` }
1423
+ },
1424
+ }),
1425
+
1426
+ defineTool({
1427
+ name: 'rw_remove',
1428
+ description:
1429
+ 'Delete a remote file (or an empty directory). recursive=true removes a directory tree (bounded). Path is absolute. This is destructive — the agent should confirm intent before calling.',
1430
+ parameters: {
1431
+ path: { type: 'string', required: true, description: 'Absolute remote path to delete' },
1432
+ recursive: { type: 'boolean', description: 'Recursively delete a directory (default false)' },
1433
+ },
1434
+ output: okOut,
1435
+ async execute(args) {
1436
+ const p = normalizeRemotePath(String(args.path || ''))
1437
+ if (!p || p === '/') throw new Error('rw_remove: a path is required')
1438
+ const sftp = await pool.sftp()
1439
+ const st = await sftp.stat(p).catch(() => null)
1440
+ if (!st) return { ok: false, text: `not found: ${p}` }
1441
+ if (st.isDirectory && st.isDirectory()) {
1442
+ if (!args.recursive) throw new Error(`rw_remove: ${p} is a directory — pass recursive=true to delete its tree`)
1443
+ const removed = await removeRemoteTree(sftp, p)
1444
+ audit('remove', `remove -r ${p}`, 0)
1445
+ return { ok: true, text: `removed ${removed} entries under ${p}` }
1446
+ }
1447
+ await sftp.unlink(p)
1448
+ audit('remove', `remove ${p}`, 0)
1449
+ return { ok: true, text: `removed ${p}` }
1450
+ },
1451
+ }),
1452
+
1453
+ defineTool({
1454
+ name: 'rw_move',
1455
+ description:
1456
+ 'Rename or move a remote file/directory (SFTP rename, same filesystem). Paths are absolute.',
1457
+ parameters: {
1458
+ path: { type: 'string', required: true, description: 'Current absolute remote path' },
1459
+ dest: { type: 'string', required: true, description: 'Destination absolute remote path' },
1460
+ },
1461
+ output: textOut,
1462
+ async execute(args) {
1463
+ const p = normalizeRemotePath(String(args.path || ''))
1464
+ const d = normalizeRemotePath(String(args.dest || ''))
1465
+ if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
1466
+ const sftp = await pool.sftp()
1467
+ await mkdirRemoteDirs(sftp, remoteDirname(d))
1468
+ await sftp.rename(p, d)
1469
+ audit('move', `move ${p} → ${d}`, 0)
1470
+ return { text: `moved ${p} → ${d}` }
1471
+ },
1472
+ }),
1473
+
1474
+ defineTool({
1475
+ name: 'rw_exec',
1476
+ description:
1477
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables.',
1478
+ parameters: {
1479
+ command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1480
+ cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
1481
+ pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
1482
+ env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
1483
+ },
1484
+ output: textOut,
1485
+ async execute(args) {
1486
+ const cmd = String(args.command || '')
1487
+ if (!cmd) throw new Error('rw_exec: command is required')
1488
+ const ws = wsPath()
1489
+ const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1490
+ const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1491
+ try {
1492
+ const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
1493
+ audit('exec', cmd, res.code)
1494
+ const parts = []
1495
+ if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
1496
+ if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
1497
+ if (!parts.length) parts.push('(no output)')
1498
+ let text = parts.join('\n')
1499
+ if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
1500
+ else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
1501
+ return { text }
1502
+ } catch (err) {
1503
+ throw new Error(friendlyMessage(err, { host: config.host, port: config.port }))
1504
+ }
1505
+ },
1506
+ }),
1507
+
1508
+ defineTool({
1509
+ name: 'rw_search',
1510
+ description:
1511
+ 'Search remote files for a pattern (recursive SFTP walk — works on ANY remote including Windows, honors ignore rules). Returns matching file:line rows; output is capped.',
1512
+ parameters: {
1513
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1514
+ path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1515
+ glob: { type: 'string', description: 'Only files whose NAME matches this glob, e.g. *.ts (optional)' },
1516
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1517
+ contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
1518
+ maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
1519
+ },
1520
+ output: textOut,
1521
+ async execute(args) {
1522
+ const pattern = String(args.pattern || '')
1523
+ if (!pattern) throw new Error('rw_search: pattern is required')
1524
+ const ws = wsPath()
1525
+ const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1526
+ if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1527
+ let regex
1528
+ try {
1529
+ regex = new RegExp(pattern, args.ignoreCase === false ? '' : 'i')
1530
+ } catch (err) {
1531
+ throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
1532
+ }
1533
+ const sftp = await pool.sftp()
1534
+ const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
1535
+ const matcher = ignoreMatcher()
1536
+ const { matches, scanned, truncated } = await searchTree(sftp, dir, {
1537
+ regex,
1538
+ glob: args.glob,
1539
+ contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
1540
+ maxMatches,
1541
+ maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
1542
+ isIgnored: (name, isDir) => matcher(name, isDir),
1543
+ })
1544
+ if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` }
1545
+ let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
1546
+ text += `\n(${matches.length} match(es), ${scanned} files scanned${truncated ? ', TRUNCATED' : ''})`
1547
+ return { text: truncate(text, config.maxOutputChars) }
1548
+ },
1549
+ }),
1550
+
1551
+ defineTool({
1552
+ name: 'rw_download',
1553
+ description:
1554
+ 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text. Streams to disk (fastGet).',
1555
+ parameters: {
1556
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1557
+ localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1558
+ },
1559
+ output: okOut,
1560
+ async execute(args) {
1561
+ const p = normalizeRemotePath(String(args.path || ''))
1562
+ if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1563
+ const sftp = await pool.sftp()
1564
+ let local
1565
+ if (args.localPath) {
1566
+ local = path.resolve(String(args.localPath))
1567
+ } else {
1568
+ const ws = wsPath()
1569
+ if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1570
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
1571
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1572
+ local = path.join(base, rel)
1573
+ }
1574
+ mkdirSync(path.dirname(local), { recursive: true })
1575
+ const st = await sftp.stat(p).catch(() => null)
1576
+ if (config.maxFileBytes > 0 && st && st.size > config.maxFileBytes) {
1577
+ throw new Error(`rw_download: file is ${st.size} bytes (over ${config.maxFileBytes} cap)`)
1578
+ }
1579
+ await sftp.fastGet(p, local)
1580
+ const bytes = existsSync(local) ? statSync(local).size : 0
1581
+ return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1582
+ },
1583
+ }),
1584
+
1585
+ defineTool({
1586
+ name: 'rw_upload',
1587
+ description:
1588
+ 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1589
+ parameters: {
1590
+ localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1591
+ path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1592
+ },
1593
+ output: okOut,
1594
+ async execute(args) {
1595
+ const rp = normalizeRemotePath(String(args.path || ''))
1596
+ const lp = String(args.localPath || '')
1597
+ if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1598
+ if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1599
+ const sftp = await pool.sftp()
1600
+ await mkdirRemoteDirs(sftp, remoteDirname(rp))
1601
+ const st = statSync(lp)
1602
+ await sftp.fastPut(rp, lp)
1603
+ audit('upload', `upload ${lp} → ${rp}`, 0)
1604
+ return { ok: true, bytes: st.size, text: `uploaded ${st.size} bytes from ${lp} → ${rp}` }
1605
+ },
1606
+ }),
1607
+
1608
+ defineTool({
1609
+ name: 'rw_forward',
1610
+ description:
1611
+ 'Manage SSH port forwards. Direction "local" listens on 127.0.0.1:<listenPort> on THIS machine and forwards connections through SSH to <targetHost>:<targetPort> on the remote. Direction "reverse" asks the REMOTE to listen on 127.0.0.1:<listenPort> and pipes connections back to <targetHost>:<targetPort> on this machine. Call with a listenPort to create+start; with remove=true to delete.',
1612
+ parameters: {
1613
+ listenPort: { type: 'integer', required: true, description: 'Port to listen on (local for direction=local, remote for direction=reverse)' },
1614
+ targetHost: { type: 'string', description: 'Forward target host (default 127.0.0.1)' },
1615
+ targetPort: { type: 'integer', description: 'Forward target port (default: same as listenPort)' },
1616
+ direction: { type: 'string', description: 'local (default) or reverse' },
1617
+ autoStart: { type: 'boolean', description: 'Restart this forward automatically on future connects (default false)' },
1618
+ remove: { type: 'boolean', description: 'Remove an existing forward by listenPort (default false)' },
1619
+ },
1620
+ output: textOut,
1621
+ async execute(args) {
1622
+ const port = Number(args.listenPort)
1623
+ if (!port || port < 1 || port > 65535) throw new Error('rw_forward: a valid listenPort is required')
1624
+ const dir = args.direction === 'reverse' ? 'reverse' : 'local'
1625
+ const existing = forwards.list().find((f) => Number(f.listenPort) === port && f.direction === dir)
1626
+ if (args.remove) {
1627
+ if (!existing) return { text: `no forward on port ${port} to remove` }
1628
+ forwards.remove(existing.id)
1629
+ return { text: `removed ${existing.direction} forward on port ${port}` }
1630
+ }
1631
+ if (existing && existing.active) return { text: `already active: ${existing.direction} forward 127.0.0.1:${port} → ${existing.targetHost}:${existing.targetPort}` }
1632
+ const d = existing || forwards.define({
1633
+ direction: dir,
1634
+ listenPort: port,
1635
+ targetHost: args.targetHost || '127.0.0.1',
1636
+ targetPort: Number(args.targetPort) || port,
1637
+ autoStart: !!args.autoStart,
1638
+ machineId: store.currentId,
1639
+ })
1640
+ const r = await forwards.start(d)
1641
+ audit('forward', `${d.direction} forward ${port} → ${d.targetHost}:${d.targetPort}`, r.ok ? 0 : 1)
1642
+ if (!r.ok) throw new Error(r.error)
1643
+ return { text: `${d.direction} forward active: 127.0.0.1:${port} → ${d.targetHost}:${d.targetPort} (id=${d.id})` }
1644
+ },
1645
+ }),
1646
+
1647
+ defineTool({
1648
+ name: 'rw_disconnect',
1649
+ description:
1650
+ 'Close the current SSH connection to the remote host, releasing the persistent pool (and stopping all active port forwards). Useful to rotate connections or after a long idle.',
1651
+ parameters: {},
1652
+ output: okOut,
1653
+ async execute() {
1654
+ pool.close()
1655
+ return { ok: true, text: 'disconnected (forwards stopped)' }
1656
+ },
1657
+ }),
1658
+ ]
1659
+
1660
+ for (const t of tools) {
1661
+ ctx.tools.register(t)
1662
+ }
1663
+
1664
+ // ── system-prompt injection: the current remote workspace ─────────────────
1665
+ ctx.systemPrompt.section({
1666
+ name: 'dsh-remote',
1667
+ order: 88,
1668
+ text: () => {
1669
+ const w = wsPath()
1670
+ if (!w || !config.host) return ''
1671
+ const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1672
+ let extra = ''
1673
+ if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1674
+ return (
1675
+ '## Remote workspace\n' +
1676
+ `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
1677
+ 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
1678
+ extra
1679
+ )
1680
+ },
1681
+ })
1682
+
1683
+ // ── slash commands ─────────────────────────────────────────────────────────
1684
+ const commands = ctx.get('commands')
1685
+ if (commands !== undefined) {
1686
+ commands.register({
1687
+ name: 'remote',
1688
+ description: 'Show the current remote workspace / connection status, active forwards, and how to use remote tools.',
1689
+ handler: (invocation) => {
1690
+ const s = status()
1691
+ const fwd = s.forwards.filter((f) => f.active)
1692
+ return {
1693
+ kind: 'success',
1694
+ text:
1695
+ `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected}, source: ${s.activeSource})\n` +
1696
+ `Remote workspace: ${s.workspace || '(none)'}\n` +
1697
+ `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1698
+ (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1699
+ (fwd.length ? `Active forwards:\n${fwd.map((f) => ` ${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}`).join('\n')}\n` : '') +
1700
+ `\nUse tools: rw_list_dir / rw_read_file / rw_edit / rw_exec / rw_search / rw_forward.` +
1701
+ (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
1702
+ }
1703
+ },
1704
+ })
1705
+ commands.register({
1706
+ name: 'remote-forget-key',
1707
+ description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1708
+ handler: () => {
1709
+ createHostKeyGuard(config).forgetHost()
1710
+ return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1711
+ },
1712
+ })
1713
+ commands.register({
1714
+ name: 'remote-ignore',
1715
+ description: 'Show the mirror ignore rules file location and the current default patterns (gitignore syntax).',
1716
+ handler: () => {
1717
+ return {
1718
+ kind: 'success',
1719
+ text:
1720
+ `Ignore file: ${ignoreFile()}\n(defaults merged with the file; gitignore syntax, '#' comments)\n\nDefault patterns:\n${DEFAULT_IGNORE.map((p) => ' ' + p).join('\n')}`,
1721
+ }
1722
+ },
1723
+ })
1724
+ }
1725
+
1726
+ // ── JSON endpoints for settings UI ─────────────────────────────────────────
1727
+ const webServer = ctx.get('webServer')
1728
+ if (webServer === undefined) return
1729
+
1730
+ const sendJson = (res, status, body) => {
1731
+ res.statusCode = status
1732
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
1733
+ res.end(JSON.stringify(body))
1734
+ }
1735
+ const MAX_BODY_BYTES = 1024 * 1024
1736
+ const readBody = (req) =>
1737
+ new Promise((resolve) => {
1738
+ const chunks = []
1739
+ let total = 0
1740
+ req.on('data', (c) => {
1741
+ total += c.length
1742
+ if (total > MAX_BODY_BYTES) {
1743
+ req.removeAllListeners('data')
1744
+ resolve('{}')
1745
+ return
1746
+ }
1747
+ chunks.push(c)
1748
+ })
1749
+ req.on('end', () => resolve(chunks.join('')))
1750
+ })
1751
+
1752
+ // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1753
+ const PICK_TIMEOUT_MS = 120000
1754
+ const runPick = (bin, args) =>
1755
+ new Promise((resolve, reject) => {
1756
+ execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1757
+ if (err) {
1758
+ const code = err.code
1759
+ const msg = String(stderr || '')
1760
+ if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1761
+ if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1762
+ if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1763
+ return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1764
+ }
1765
+ const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1766
+ resolve(p === 'CANCELED' ? { cancelled: true } : (p ? { path: p } : { cancelled: true }))
1767
+ })
1768
+ })
1769
+ const pickLocalNative = async () => {
1770
+ const platform = process.platform
1771
+ if (platform === 'darwin') {
1772
+ return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1773
+ }
1774
+ if (platform === 'linux') {
1775
+ try {
1776
+ return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1777
+ } catch (err) {
1778
+ if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1779
+ throw err
1780
+ }
1781
+ }
1782
+ if (platform === 'win32') {
1783
+ const script =
1784
+ `Add-Type -AssemblyName System.Windows.Forms;` +
1785
+ `$f = New-Object System.Windows.Forms.FolderBrowserDialog;` +
1786
+ `$f.Description = 'Select Workspace Directory';` +
1787
+ `if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { 'CANCELED' }`
1788
+ return runPick('powershell', ['-NoProfile', '-STA', '-Command', script])
1789
+ }
1790
+ throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1791
+ }
1792
+
1793
+ // ── route helpers ─────────────────────────────────────────────────────────
1794
+ const routes = [
1795
+ {
1796
+ kind: 'exact',
1797
+ path: '/dsh-remote/status',
1798
+ handler: async (req, res) => {
1799
+ if (req.method === 'GET') return sendJson(res, 200, status())
1800
+ sendJson(res, 405, { error: 'method not allowed' })
1801
+ },
1802
+ },
1803
+ {
1804
+ kind: 'exact',
1805
+ path: '/dsh-remote/connect',
1806
+ handler: async (req, res) => {
1807
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
1808
+ try {
1809
+ const payload = JSON.parse((await readBody(req)) || '{}')
1810
+ pool.setTarget({
1811
+ host: payload.host,
1812
+ port: payload.port,
1813
+ username: payload.username,
1814
+ password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
1815
+ privateKeyPath: payload.privateKeyPath,
1816
+ workspace: payload.workspace,
1817
+ })
1818
+ await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1819
+ return sendJson(res, 200, { ok: true, ...status() })
1820
+ } catch (err) {
1821
+ return sendJson(res, 500, { ok: false, error: friendlyMessage(err, { host: payload && payload.host, port: payload && payload.port }) })
1822
+ }
1823
+ },
1824
+ },
1825
+ {
1826
+ kind: 'exact',
1827
+ path: '/dsh-remote/ls',
1828
+ handler: async (req, res) => {
1829
+ try {
1830
+ const q = new URL(req.url, 'http://localhost').searchParams
1831
+ const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
1832
+ const out = await listDirStructured(p || '/')
1833
+ return sendJson(res, 200, { path: out.path, items: out.items })
1834
+ } catch (err) {
1835
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
1836
+ }
1837
+ },
1838
+ },
1839
+ {
1840
+ // Read a remote file over SFTP (live). Text returns as content (CRLF→LF,
1841
+ // encoding-aware), binary is reported with size+head (base64). A stat
1842
+ // happens first; oversized files are previewed from a streamed head chunk
1843
+ // instead of being fully buffered.
1844
+ kind: 'exact',
1845
+ path: '/dsh-remote/read',
1846
+ handler: async (req, res) => {
1847
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1848
+ try {
1849
+ const q = new URL(req.url, 'http://localhost').searchParams
1850
+ let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
1851
+ let encoding = ''
1852
+ let maxBytes = 256 * 1024
1853
+ if (req.method === 'POST') {
1854
+ const body = JSON.parse((await readBody(req)) || '{}')
1855
+ if (!p) p = String(body.path || '')
1856
+ if (body.encoding) encoding = String(body.encoding)
1857
+ if (body.maxBytes) maxBytes = Number(body.maxBytes)
1858
+ }
1859
+ if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
1860
+ if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
1861
+ const target = normalizeRemotePath(p)
1862
+ maxBytes = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
1863
+ let sftp
1864
+ try {
1865
+ sftp = await pool.sftp()
1866
+ } catch (err) {
1867
+ return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
1868
+ }
1869
+ let st
1870
+ try { st = await sftp.stat(target) } catch (err) {
1871
+ return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1872
+ }
1873
+ if (st.size > maxBytes) {
1874
+ // Head preview for oversized files: fastGet to a temp file, read the
1875
+ // first maxBytes, delete the temp. Never buffers the whole file.
1876
+ const tmp = path.join(dshBase(), '.dsh-remote-preview-' + process.pid)
1877
+ try {
1878
+ await sftp.fastGet(target, tmp)
1879
+ const { open } = await import('node:fs/promises')
1880
+ const fd = await open(tmp, 'r')
1881
+ const head = Buffer.alloc(Math.min(maxBytes, st.size))
1882
+ await fd.read(head, 0, head.length, 0)
1883
+ await fd.close()
1884
+ try { unlinkSync(tmp) } catch {}
1885
+ const content = decodeBuf(head, encoding || config.encoding).replace(/\r\n/g, '\n')
1886
+ return sendJson(res, 200, { ok: true, binary: false, content, truncated: true, size: st.size })
1887
+ } catch (err) {
1888
+ return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1889
+ }
1890
+ }
1891
+ let buf
1892
+ try {
1893
+ buf = await sftp.readFile(target)
1894
+ } catch (err) {
1895
+ return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
1896
+ }
1897
+ const headN = Math.min(buf.length, 8192)
1898
+ let binary = false
1899
+ for (let i = 0; i < headN; i++) {
1900
+ if (buf[i] === 0) { binary = true; break }
1901
+ }
1902
+ if (binary) {
1903
+ return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
1904
+ }
1905
+ let content = decodeBuf(buf, encoding || config.encoding).replace(/\r\n/g, '\n')
1906
+ const truncated = content.length > maxBytes
1907
+ if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
1908
+ return sendJson(res, 200, { ok: true, binary: false, content, truncated })
1909
+ } catch (err) {
1910
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1911
+ }
1912
+ },
1913
+ },
1914
+ {
1915
+ // Write a remote file (sidebar editor save). `expectedMtime` is an
1916
+ // optimistic lock: a 409 is returned when the remote changed meanwhile.
1917
+ kind: 'exact',
1918
+ path: '/dsh-remote/write',
1919
+ handler: async (req, res) => {
1920
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1921
+ try {
1922
+ const body = JSON.parse((await readBody(req)) || '{}')
1923
+ const p = normalizeRemotePath(String(body.path || ''))
1924
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
1925
+ const sftp = await pool.sftp()
1926
+ let st = null
1927
+ try { st = await sftp.stat(p) } catch { /* new file */ }
1928
+ if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
1929
+ return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
1930
+ }
1931
+ const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
1932
+ if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
1933
+ await sftp.writeFile(p, buf)
1934
+ audit('write', `write ${p}`, 0)
1935
+ const st2 = await sftp.stat(p).catch(() => null)
1936
+ return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
1937
+ } catch (err) {
1938
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1939
+ }
1940
+ },
1941
+ },
1942
+ {
1943
+ // Generic remote fs ops used by the sidebar context menu + picker:
1944
+ // mkdir / rename / remove (recursive) / write / append.
1945
+ kind: 'exact',
1946
+ path: '/dsh-remote/fs',
1947
+ handler: async (req, res) => {
1948
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1949
+ try {
1950
+ const body = JSON.parse((await readBody(req)) || '{}')
1951
+ const op = String(body.op || '')
1952
+ const sftp = await pool.sftp()
1953
+ if (op === 'mkdir') {
1954
+ const p = normalizeRemotePath(String(body.path || ''))
1955
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1956
+ await mkdirRemoteDirs(sftp, p)
1957
+ audit('mkdir', `mkdir ${p}`, 0)
1958
+ return sendJson(res, 200, { ok: true })
1959
+ }
1960
+ if (op === 'rename') {
1961
+ const p = normalizeRemotePath(String(body.path || ''))
1962
+ const d = normalizeRemotePath(String(body.dest || ''))
1963
+ if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
1964
+ await sftp.rename(p, d)
1965
+ audit('move', `move ${p} → ${d}`, 0)
1966
+ return sendJson(res, 200, { ok: true })
1967
+ }
1968
+ if (op === 'remove') {
1969
+ const p = normalizeRemotePath(String(body.path || ''))
1970
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1971
+ const st = await sftp.stat(p).catch(() => null)
1972
+ if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
1973
+ if (st.isDirectory && st.isDirectory()) {
1974
+ await removeRemoteTree(sftp, p)
1975
+ } else {
1976
+ await sftp.unlink(p)
1977
+ }
1978
+ audit('remove', `remove ${p}`, 0)
1979
+ return sendJson(res, 200, { ok: true })
1980
+ }
1981
+ if (op === 'write' || op === 'append') {
1982
+ const p = normalizeRemotePath(String(body.path || ''))
1983
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1984
+ let content = ''
1985
+ if (op === 'append') {
1986
+ try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
1987
+ }
1988
+ const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
1989
+ if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
1990
+ await sftp.writeFile(p, buf)
1991
+ audit(op, `${op} ${p}`, 0)
1992
+ return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
1993
+ }
1994
+ if (op === 'download') {
1995
+ // Download a remote file into the current workspace mirror.
1996
+ const p = normalizeRemotePath(String(body.path || ''))
1997
+ const ws = wsPath()
1998
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
1999
+ if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
2000
+ const st = await sftp.stat(p).catch(() => null)
2001
+ if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
2002
+ if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
2003
+ return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
2004
+ }
2005
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
2006
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
2007
+ const local = path.join(base, rel)
2008
+ mkdirSync(path.dirname(local), { recursive: true })
2009
+ await sftp.fastGet(p, local)
2010
+ audit('download', `download ${p}`, 0)
2011
+ return sendJson(res, 200, { ok: true, bytes: st.size, local })
2012
+ }
2013
+ return sendJson(res, 400, { ok: false, error: 'unknown op' })
2014
+ } catch (err) {
2015
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2016
+ }
2017
+ },
2018
+ },
2019
+ {
2020
+ kind: 'exact',
2021
+ path: '/dsh-remote/workspace',
2022
+ handler: async (req, res) => {
2023
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2024
+ try {
2025
+ const payload = JSON.parse((await readBody(req)) || '{}')
2026
+ const p = normalizeRemotePath(String(payload.path || ''))
2027
+ if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
2028
+ const okDir = await isRemoteDir(p)
2029
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
2030
+ persistWorkspace(p)
2031
+ const local = ensureMirror(p, config.host, config.username, config.port)
2032
+ startAutoPush(local)
2033
+ return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
2034
+ } catch (err) {
2035
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2036
+ }
2037
+ },
2038
+ },
2039
+ {
2040
+ kind: 'exact',
2041
+ path: '/dsh-remote/mirror',
2042
+ handler: async (req, res) => {
2043
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2044
+ try {
2045
+ const payload = JSON.parse((await readBody(req)) || '{}')
2046
+ const p = normalizeRemotePath(String(payload.path || ''))
2047
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2048
+ if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2049
+ const okDir = await isRemoteDir(p)
2050
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
2051
+ const local = ensureMirror(p, config.host, config.username, config.port)
2052
+ persistWorkspace(p)
2053
+ startAutoPush(local)
2054
+ return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
2055
+ } catch (err) {
2056
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2057
+ }
2058
+ },
2059
+ },
2060
+ {
2061
+ kind: 'exact',
2062
+ path: '/dsh-remote/local-pick',
2063
+ handler: async (req, res) => {
2064
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2065
+ try {
2066
+ let outcome = null
2067
+ let via = 'service'
2068
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
2069
+ if (dp && typeof dp.capability === 'function') {
2070
+ try {
2071
+ const cap = await Promise.resolve(dp.capability())
2072
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2073
+ const pickAbort = new AbortController()
2074
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
2075
+ pickAbort.abort()
2076
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
2077
+ }
2078
+ } catch (err) {
2079
+ outcome = null
2080
+ }
2081
+ }
2082
+ if (!outcome) {
2083
+ via = 'own'
2084
+ try {
2085
+ outcome = await pickLocalNative()
2086
+ } catch (err) {
2087
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
2088
+ }
2089
+ }
2090
+ if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
2091
+ return sendJson(res, 200, { ok: true, path: outcome.path, via })
2092
+ } catch (err) {
2093
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2094
+ }
2095
+ },
2096
+ },
2097
+ {
2098
+ kind: 'exact',
2099
+ path: '/dsh-remote/machines',
2100
+ handler: async (req, res) => {
2101
+ if (req.method === 'GET') {
2102
+ return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
2103
+ }
2104
+ if (req.method === 'POST') {
2105
+ try {
2106
+ const body = JSON.parse((await readBody(req)) || '{}')
2107
+ const action = body.action || 'add'
2108
+ if (action === 'add' || action === 'update') {
2109
+ const host = String(body.host || '').trim()
2110
+ if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
2111
+ const id = body.id || machineId()
2112
+ const i = machineIndex(id)
2113
+ const prev = i >= 0 ? machines[i] : null
2114
+ const credBackend = body.credentialBackend === 'plain' ? 'plain'
2115
+ : (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
2116
+ const rec = {
2117
+ id,
2118
+ name: String(body.name || '').trim() || host,
2119
+ host,
2120
+ port: Number(body.port) || 22,
2121
+ username: String(body.username || '').trim() || 'root',
2122
+ password: '',
2123
+ privateKeyPath: String(body.privateKeyPath || '').trim(),
2124
+ passphrase: body.passphrase || '',
2125
+ workspace: String(body.workspace || '').trim(),
2126
+ hostKeyMode: body.hostKeyMode || '',
2127
+ useAgent: !!body.useAgent,
2128
+ keyboardInteractive: !!body.keyboardInteractive,
2129
+ proxy: body.proxy && body.proxy.host ? {
2130
+ host: String(body.proxy.host),
2131
+ port: Number(body.proxy.port) || 22,
2132
+ username: String(body.proxy.username || '').trim(),
2133
+ password: String(body.proxy.password || ''),
2134
+ privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
2135
+ } : undefined,
2136
+ credentialBackend: credBackend,
2137
+ recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
2138
+ lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
2139
+ latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
2140
+ }
2141
+ if (body.password) {
2142
+ if (credBackend !== 'plain') {
2143
+ await saveSecret(id, String(body.password), secretsDir())
2144
+ rec.password = ''
2145
+ } else {
2146
+ rec.password = String(body.password)
2147
+ }
2148
+ } else if (prev && prev.password) {
2149
+ rec.password = prev.password
2150
+ }
2151
+ if (i >= 0) machines[i] = rec; else machines.push(rec)
2152
+ if (!store.currentId) store.currentId = id
2153
+ saveMachines(machines, store.currentId)
2154
+ if (store.currentId === id) await applyActiveMachine()
2155
+ return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
2156
+ }
2157
+ if (action === 'delete') {
2158
+ const i = machineIndex(String(body.id || ''))
2159
+ if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2160
+ const m = machines[i]
2161
+ if (m.credentialBackend && m.credentialBackend !== 'plain') {
2162
+ await deleteSecret(m.id, secretsDir()).catch(() => {})
2163
+ }
2164
+ machines.splice(i, 1)
2165
+ if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
2166
+ saveMachines(machines, store.currentId)
2167
+ if (store.currentId) await applyActiveMachine()
2168
+ return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2169
+ }
2170
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
2171
+ } catch (err) {
2172
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2173
+ }
2174
+ }
2175
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2176
+ },
2177
+ },
2178
+ {
2179
+ kind: 'exact',
2180
+ path: '/dsh-remote/test-connect',
2181
+ handler: async (req, res) => {
2182
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2183
+ try {
2184
+ const body = JSON.parse((await readBody(req)) || '{}')
2185
+ const probe = new SshPool({
2186
+ ...config,
2187
+ host: String(body.host || config.host),
2188
+ port: Number(body.port) || config.port,
2189
+ username: String(body.username || config.username),
2190
+ password: String(body.password || ''),
2191
+ privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
2192
+ passphrase: String(body.passphrase || ''),
2193
+ proxy: body.proxy && body.proxy.host ? {
2194
+ host: String(body.proxy.host),
2195
+ port: Number(body.proxy.port) || 22,
2196
+ username: String(body.proxy.username || ''),
2197
+ password: String(body.proxy.password || ''),
2198
+ privateKeyPath: String(body.proxy.privateKeyPath || ''),
2199
+ } : undefined,
2200
+ connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
2201
+ commandTimeoutMs: 10000,
2202
+ })
2203
+ const started = Date.now()
2204
+ await probe.connect()
2205
+ await probe.exec('true', { timeoutMs: 10000 })
2206
+ probe.close()
2207
+ const latencyMs = Date.now() - started
2208
+ const mi = machines.findIndex((m) => m.host === probe.config.host && m.username === probe.config.username && Number(m.port) === probe.config.port)
2209
+ if (mi >= 0) {
2210
+ machines[mi].lastConnectedAt = new Date().toISOString()
2211
+ machines[mi].latencyMs = latencyMs
2212
+ saveMachines(machines, store.currentId)
2213
+ }
2214
+ return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString() })
2215
+ } catch (err) {
2216
+ return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body && body.host, port: body && body.port }) })
2217
+ }
2218
+ },
2219
+ },
2220
+ {
2221
+ kind: 'exact',
2222
+ path: '/dsh-remote/current',
2223
+ handler: async (req, res) => {
2224
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2225
+ try {
2226
+ const body = JSON.parse((await readBody(req)) || '{}')
2227
+ const okSet = await setCurrent(String(body.id || ''))
2228
+ if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2229
+ return sendJson(res, 200, { ok: true, ...status() })
2230
+ } catch (err) {
2231
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2232
+ }
2233
+ },
2234
+ },
2235
+ {
2236
+ kind: 'exact',
2237
+ path: '/dsh-remote/forget-key',
2238
+ handler: async (req, res) => {
2239
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2240
+ createHostKeyGuard(config).forgetHost()
2241
+ return sendJson(res, 200, { ok: true, ...status() })
2242
+ },
2243
+ },
2244
+ {
2245
+ kind: 'exact',
2246
+ path: '/dsh-remote/forwards',
2247
+ handler: async (req, res) => {
2248
+ if (req.method === 'GET') return sendJson(res, 200, { forwards: forwards.list() })
2249
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2250
+ try {
2251
+ const body = JSON.parse((await readBody(req)) || '{}')
2252
+ const action = String(body.action || '')
2253
+ if (action === 'define') {
2254
+ const d = forwards.define({
2255
+ direction: body.direction === 'reverse' ? 'reverse' : 'local',
2256
+ listenPort: Number(body.listenPort),
2257
+ targetHost: body.targetHost || '127.0.0.1',
2258
+ targetPort: Number(body.targetPort) || Number(body.listenPort),
2259
+ autoStart: !!body.autoStart,
2260
+ machineId: store.currentId,
2261
+ })
2262
+ return sendJson(res, 200, { ok: true, forward: d, forwards: forwards.list() })
2263
+ }
2264
+ if (action === 'start' || action === 'stop') {
2265
+ const d = forwards.list().find((f) => f.id === body.id)
2266
+ if (!d) return sendJson(res, 404, { ok: false, error: 'forward not found' })
2267
+ if (action === 'start') {
2268
+ const r = await forwards.start(d)
2269
+ if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error })
2270
+ } else {
2271
+ forwards.stop(d.id)
2272
+ }
2273
+ return sendJson(res, 200, { ok: true, forwards: forwards.list() })
2274
+ }
2275
+ if (action === 'remove') {
2276
+ forwards.remove(String(body.id || ''))
2277
+ return sendJson(res, 200, { ok: true, forwards: forwards.list() })
2278
+ }
2279
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
2280
+ } catch (err) {
2281
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2282
+ }
2283
+ },
2284
+ },
2285
+ {
2286
+ kind: 'exact',
2287
+ path: '/dsh-remote/task',
2288
+ handler: async (req, res) => {
2289
+ if (req.method === 'GET') {
2290
+ const q = new URL(req.url, 'http://localhost').searchParams
2291
+ const id = q.get('id') || ''
2292
+ const t = tasks.get(id)
2293
+ if (!t) return sendJson(res, 404, { ok: false, error: 'task not found' })
2294
+ return sendJson(res, 200, { ok: true, task: t })
2295
+ }
2296
+ if (req.method === 'POST') {
2297
+ try {
2298
+ const body = JSON.parse((await readBody(req)) || '{}')
2299
+ const id = String(body.id || '')
2300
+ if (body.action === 'cancel') {
2301
+ const ok = tasks.cancel(id)
2302
+ return sendJson(res, 200, { ok, task: tasks.get(id) })
2303
+ }
2304
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
2305
+ } catch (err) {
2306
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2307
+ }
2308
+ }
2309
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2310
+ },
2311
+ },
2312
+ {
2313
+ kind: 'exact',
2314
+ path: '/dsh-remote/tasks',
2315
+ handler: async (req, res) => {
2316
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2317
+ return sendJson(res, 200, { ok: true, tasks: tasks.list() })
2318
+ },
2319
+ },
2320
+ {
2321
+ kind: 'exact',
2322
+ path: '/dsh-remote/audit',
2323
+ handler: async (req, res) => {
2324
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2325
+ const q = new URL(req.url, 'http://localhost').searchParams
2326
+ return sendJson(res, 200, { ok: true, auditEnabled: !!config.auditLog, file: auditFile(), lines: readAudit(q.get('limit')) })
2327
+ },
2328
+ },
2329
+ {
2330
+ kind: 'exact',
2331
+ path: '/dsh-remote/ssh-config',
2332
+ handler: async (req, res) => {
2333
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2334
+ try {
2335
+ const text = readSshConfigText()
2336
+ return sendJson(res, 200, { ok: true, file: sshConfigPath(), present: !!text, entries: importableEntries(text) })
2337
+ } catch (err) {
2338
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2339
+ }
2340
+ },
2341
+ },
2342
+ {
2343
+ kind: 'exact',
2344
+ path: '/dsh-remote/home',
2345
+ handler: async (req, res) => {
2346
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2347
+ try {
2348
+ const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
2349
+ const home = String(out.stdout || '').replace(/\s+/g, '').trim()
2350
+ if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
2351
+ return sendJson(res, 200, { ok: true, home })
2352
+ } catch (err) {
2353
+ return sendJson(res, 200, { ok: true, home: null, error: String((err && err.message) || err) })
2354
+ }
2355
+ },
2356
+ },
2357
+ {
2358
+ kind: 'exact',
2359
+ path: '/dsh-remote/update-check',
2360
+ handler: async (req, res) => {
2361
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2362
+ const current = readVersion()
2363
+ const latest = await fetchLatestVersion()
2364
+ if (latest === null) return sendJson(res, 200, { ok: false, current, error: '无法连接 npm registry' })
2365
+ const rawMode = readUpdateMode() || config.updateMode || 'manual'
2366
+ return sendJson(res, 200, {
2367
+ ok: true,
2368
+ current,
2369
+ latest,
2370
+ updateAvailable: gtVersion(latest, current),
2371
+ updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2372
+ updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2373
+ })
2374
+ },
2375
+ },
2376
+ {
2377
+ kind: 'exact',
2378
+ path: '/dsh-remote/update-apply',
2379
+ handler: async (req, res) => {
2380
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2381
+ try {
2382
+ const body = JSON.parse((await readBody(req)) || '{}')
2383
+ const target = String(body.version || '')
2384
+ if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2385
+ const result = await applyUpdate(target)
2386
+ return sendJson(res, 200, { ok: true, from: readVersion(), ...result })
2387
+ } catch (err) {
2388
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2389
+ }
2390
+ },
2391
+ },
2392
+ {
2393
+ kind: 'exact',
2394
+ path: '/dsh-remote/update-mode',
2395
+ handler: async (req, res) => {
2396
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2397
+ try {
2398
+ const body = JSON.parse((await readBody(req)) || '{}')
2399
+ const mode = String(body.mode || '')
2400
+ if (!['manual', 'auto', 'off'].includes(mode)) return sendJson(res, 400, { ok: false, error: 'mode must be manual | auto | off' })
2401
+ if (!persistUpdateMode(mode)) return sendJson(res, 500, { ok: false, error: 'cannot persist mode' })
2402
+ return sendJson(res, 200, { ok: true, mode })
2403
+ } catch (err) {
2404
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2405
+ }
2406
+ },
2407
+ },
2408
+ ]
2409
+
2410
+ const disposers = routes.map((r) => webServer.register(r))
2411
+ ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
2412
+
2413
+ // ── auto-update: check on load + on an interval; apply silently ───────────
2414
+ // Failures are swallowed (never break the plugin). The persisted override
2415
+ // (settings UI) wins over the profile-config default.
2416
+ const rawMode = readUpdateMode() || config.updateMode || 'manual'
2417
+ const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2418
+ if (effectiveUpdateMode === 'auto') {
2419
+ const currentVersion = readVersion()
2420
+ const checkAndApply = async () => {
2421
+ const latest = await fetchLatestVersion()
2422
+ if (latest && gtVersion(latest, currentVersion)) {
2423
+ try { await applyUpdate(latest) } catch {}
2424
+ }
2425
+ }
2426
+ void checkAndApply()
2427
+ const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
2428
+ if (typeof updateTimer.unref === 'function') updateTimer.unref()
2429
+ ctx.effect(() => () => clearInterval(updateTimer), 'dsh-remote.update-timer')
2430
+ }
2431
+ }