dsh-remote 0.7.5 → 0.8.1

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