dsh-remote 0.5.9 → 0.6.7
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/LICENSE +21 -21
- package/README.md +49 -7
- package/README.zh.md +39 -7
- package/cordis.patch.yml +6 -6
- package/lib/client.js +13 -5
- package/lib/index.js +616 -102
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
import z from '@deepseek-ai/schemastery'
|
|
19
19
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
20
|
import ssh2 from 'ssh2'
|
|
21
|
-
import {
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { execFile } from 'node:child_process'
|
|
23
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
|
|
22
24
|
import { homedir } from 'node:os'
|
|
23
25
|
import path from 'node:path'
|
|
24
26
|
|
|
@@ -53,6 +55,13 @@ export const Config = z.object({
|
|
|
53
55
|
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
54
56
|
/** Hard ceiling on collected remote output per call. */
|
|
55
57
|
maxOutputChars: z.number().step(1).min(1024).default(200000),
|
|
58
|
+
/** Skip mirroring files larger than this many bytes (0 = no cap). */
|
|
59
|
+
maxFileBytes: z.number().step(1).min(0).default(52428800),
|
|
60
|
+
/** Host-key policy: `accept-new` (default) records a host's key on first
|
|
61
|
+
* connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
|
|
62
|
+
* accept-new); `verify` also rejects hosts never seen before; `off` skips
|
|
63
|
+
* verification entirely (MITM-unsafe, not recommended). */
|
|
64
|
+
hostKeyMode: z.string().default('accept-new'),
|
|
56
65
|
})
|
|
57
66
|
|
|
58
67
|
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
@@ -100,18 +109,89 @@ const remotePathBase = (p) => {
|
|
|
100
109
|
return base || 'workspace'
|
|
101
110
|
}
|
|
102
111
|
|
|
112
|
+
/** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
|
|
113
|
+
* own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
|
|
114
|
+
* data under the same root the harness uses means uninstalling / upgrading the
|
|
115
|
+
* app no longer leaves state behind in the home directory. */
|
|
116
|
+
function dshBase() {
|
|
117
|
+
const env = process.env.DSH_HOME
|
|
118
|
+
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
119
|
+
return path.join(homedir(), '.dsh')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Root holding every remote host's mirrors + the machine registry. */
|
|
123
|
+
function remoteWorkspacesRoot() {
|
|
124
|
+
return path.join(dshBase(), 'remote-workspaces')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
|
|
128
|
+
function copyDirSync(from, to) {
|
|
129
|
+
mkdirSync(to, { recursive: true })
|
|
130
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
131
|
+
const s = path.join(from, e.name)
|
|
132
|
+
const d = path.join(to, e.name)
|
|
133
|
+
if (e.isDirectory()) copyDirSync(s, d)
|
|
134
|
+
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
|
|
139
|
+
* regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
|
|
140
|
+
* desktop install keeps its existing machines and mirrors. Idempotent; a
|
|
141
|
+
* rename across devices falls back to a copy. */
|
|
142
|
+
function migrateLegacyData() {
|
|
143
|
+
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
144
|
+
const target = remoteWorkspacesRoot()
|
|
145
|
+
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
146
|
+
try {
|
|
147
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
148
|
+
try {
|
|
149
|
+
renameSync(legacy, target)
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err.code !== 'EXDEV') throw err
|
|
152
|
+
copyDirSync(legacy, target)
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Migration is best-effort: a fresh registry is created on next save.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
160
|
+
function shortHash(s) {
|
|
161
|
+
let h = 0
|
|
162
|
+
for (let i = 0; i < s.length; i++) {
|
|
163
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
164
|
+
}
|
|
165
|
+
return (h >>> 0).toString(36)
|
|
166
|
+
}
|
|
167
|
+
|
|
103
168
|
/** Stable local root for one remote host's mirrors */
|
|
104
169
|
function mirrorRootFor(host, user, port) {
|
|
105
170
|
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
106
|
-
return path.join(
|
|
171
|
+
return path.join(remoteWorkspacesRoot(), tag)
|
|
107
172
|
}
|
|
108
173
|
|
|
109
174
|
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
110
175
|
* Named after the remote directory's basename so the harness workspace label
|
|
111
|
-
* reads cleanly (e.g. .../project)
|
|
176
|
+
* reads cleanly (e.g. .../project). When the plain basename dir is already
|
|
177
|
+
* taken by a DIFFERENT remote path on the same host, a short path-hash suffix
|
|
178
|
+
* is appended so mirrors never collide. */
|
|
112
179
|
function mirrorDirFor(remotePath, host, user, port) {
|
|
113
180
|
const base = remotePathBase(remotePath)
|
|
114
|
-
|
|
181
|
+
const root = mirrorRootFor(host, user, port)
|
|
182
|
+
const plain = path.join(root, base)
|
|
183
|
+
const norm = normalizeRemotePath(remotePath)
|
|
184
|
+
// A pre-existing mirror for this exact remote origin → reuse it (idempotent).
|
|
185
|
+
try {
|
|
186
|
+
const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
|
|
187
|
+
if (meta.remotePath === norm) return plain
|
|
188
|
+
} catch {
|
|
189
|
+
/* no mirror yet → fall through */
|
|
190
|
+
}
|
|
191
|
+
// Plain dir is free → first mirror for this basename keeps the clean name.
|
|
192
|
+
// It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
|
|
193
|
+
if (!existsSync(plain)) return plain
|
|
194
|
+
return path.join(root, base + '-' + shortHash(norm))
|
|
115
195
|
}
|
|
116
196
|
|
|
117
197
|
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
@@ -120,82 +200,130 @@ function ensureMirror(remotePath, host, user, port) {
|
|
|
120
200
|
mkdirSync(dir, { recursive: true })
|
|
121
201
|
writeFileSync(
|
|
122
202
|
path.join(dir, '.dsh-remote-meta.json'),
|
|
123
|
-
JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
|
|
203
|
+
JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
|
|
124
204
|
)
|
|
125
205
|
return dir
|
|
126
206
|
}
|
|
127
207
|
|
|
128
|
-
/**
|
|
129
|
-
async function
|
|
208
|
+
/** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
|
|
209
|
+
async function mapLimit(items, limit, fn) {
|
|
210
|
+
const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
|
|
211
|
+
let next = 0
|
|
212
|
+
const worker = async () => {
|
|
213
|
+
while (next < items.length) {
|
|
214
|
+
const i = next++
|
|
215
|
+
try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const workers = []
|
|
219
|
+
for (let w = 0; w < limitN; w++) workers.push(worker())
|
|
220
|
+
await Promise.all(workers)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Recursively sync remote → local mirror. Bounded by depth/files/size;
|
|
224
|
+
* skips files whose size+mtime already match locally. Returns counters. */
|
|
225
|
+
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
|
|
130
226
|
const entries = await sftp.readdir(remoteDir).then(
|
|
131
227
|
(list) => list,
|
|
132
228
|
() => [],
|
|
133
229
|
)
|
|
134
|
-
|
|
135
|
-
|
|
230
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
|
|
231
|
+
|
|
232
|
+
// Directories first (sequential, depth-bounded) so every file has a home.
|
|
136
233
|
for (const e of entries) {
|
|
137
234
|
const name = String(e.filename)
|
|
138
235
|
if (name === '.' || name === '..') continue
|
|
236
|
+
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
237
|
+
if (!isDir) continue
|
|
238
|
+
if (maxDepth <= 0 || stats.files >= maxFiles) continue
|
|
239
|
+
const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
|
|
240
|
+
const lp = path.join(localDir, name)
|
|
241
|
+
mkdirSync(lp, { recursive: true })
|
|
242
|
+
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
|
|
243
|
+
stats.dirs += sub.dirs + 1
|
|
244
|
+
stats.files += sub.files
|
|
245
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
246
|
+
stats.skippedLarge += sub.skippedLarge
|
|
247
|
+
stats.touched.push(...sub.touched)
|
|
248
|
+
if (stats.files >= maxFiles) break
|
|
249
|
+
}
|
|
250
|
+
if (stats.files >= maxFiles) return stats
|
|
251
|
+
|
|
252
|
+
// Files in one bounded-parallel sweep per directory level.
|
|
253
|
+
const fileEntries = entries.filter(
|
|
254
|
+
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
255
|
+
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
256
|
+
)
|
|
257
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
258
|
+
if (stats.files >= maxFiles) return
|
|
259
|
+
const name = String(e.filename)
|
|
139
260
|
const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
|
|
140
261
|
const lp = path.join(localDir, name)
|
|
141
|
-
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
142
|
-
if (isDir) {
|
|
143
|
-
if (maxDepth <= 0) continue
|
|
144
|
-
mkdirSync(lp, { recursive: true })
|
|
145
|
-
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
|
|
146
|
-
files += sub.files
|
|
147
|
-
touched.push(...sub.touched)
|
|
148
|
-
continue
|
|
149
|
-
}
|
|
150
|
-
if (files >= maxFiles) break
|
|
151
262
|
try {
|
|
263
|
+
const st = await sftp.stat(rp)
|
|
264
|
+
if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
265
|
+
const lpStat = existsSync(lp) ? statSync(lp) : null
|
|
266
|
+
const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
|
|
267
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
152
268
|
const buf = await sftp.readFile(rp)
|
|
153
269
|
writeFileSync(lp, buf)
|
|
154
|
-
|
|
155
|
-
files++
|
|
270
|
+
try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
|
|
271
|
+
stats.files++
|
|
272
|
+
stats.touched.push(rp)
|
|
156
273
|
} catch {
|
|
157
274
|
/* skip unreadable */
|
|
158
275
|
}
|
|
159
|
-
}
|
|
160
|
-
return
|
|
276
|
+
})
|
|
277
|
+
return stats
|
|
161
278
|
}
|
|
162
279
|
|
|
163
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files
|
|
164
|
-
|
|
280
|
+
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
|
|
281
|
+
* skips files whose remote copy already matches (same size, remote no older). */
|
|
282
|
+
async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
|
|
165
283
|
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const pushed = []
|
|
284
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
|
|
285
|
+
|
|
169
286
|
for (const e of entries) {
|
|
170
|
-
if (e.isDirectory())
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
287
|
+
if (!e.isDirectory()) continue
|
|
288
|
+
if (stats.files >= maxFiles) break
|
|
289
|
+
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
290
|
+
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
291
|
+
stats.dirs++
|
|
292
|
+
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
|
|
293
|
+
stats.files += sub.files
|
|
294
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
295
|
+
stats.skippedLarge += sub.skippedLarge
|
|
296
|
+
stats.pushed.push(...sub.pushed)
|
|
297
|
+
if (stats.files >= maxFiles) break
|
|
298
|
+
}
|
|
299
|
+
if (stats.files >= maxFiles) return stats
|
|
300
|
+
|
|
301
|
+
const fileEntries = entries.filter((e) => !e.isDirectory())
|
|
302
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
303
|
+
if (stats.files >= maxFiles) return
|
|
182
304
|
const lp = path.join(localDir, e.name)
|
|
183
305
|
const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
|
|
184
306
|
try {
|
|
307
|
+
const lstat = statSync(lp)
|
|
308
|
+
if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
309
|
+
let rstat = null
|
|
310
|
+
try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
|
|
311
|
+
const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
|
|
312
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
185
313
|
const buf = readFileSync(lp)
|
|
186
314
|
await sftp.writeFile(rp, buf)
|
|
187
|
-
|
|
188
|
-
|
|
315
|
+
stats.files++
|
|
316
|
+
stats.pushed.push(rp)
|
|
189
317
|
} catch {
|
|
190
318
|
/* skip unreadable / unwritable */
|
|
191
319
|
}
|
|
192
|
-
}
|
|
193
|
-
return
|
|
320
|
+
})
|
|
321
|
+
return stats
|
|
194
322
|
}
|
|
195
323
|
|
|
196
324
|
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
197
325
|
const MACHINES_FILE = 'machines.json'
|
|
198
|
-
const machinesFile = () => path.join(
|
|
326
|
+
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
199
327
|
function loadMachines() {
|
|
200
328
|
try {
|
|
201
329
|
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
@@ -225,6 +353,98 @@ function applyMachine(config, m) {
|
|
|
225
353
|
}
|
|
226
354
|
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
227
355
|
|
|
356
|
+
// ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
|
|
357
|
+
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
358
|
+
const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
|
|
359
|
+
|
|
360
|
+
/** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
|
|
361
|
+
function loadKnownHosts() {
|
|
362
|
+
try {
|
|
363
|
+
const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
|
|
364
|
+
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
365
|
+
} catch {}
|
|
366
|
+
return {}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Extract the host-key algorithm name from a raw SSH host-key blob
|
|
370
|
+
* (SSH wire format: `uint32 len` + algorithm string + key data). */
|
|
371
|
+
function blobAlgorithm(blob) {
|
|
372
|
+
if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
|
|
373
|
+
try {
|
|
374
|
+
const len = blob.readUInt32BE(0)
|
|
375
|
+
return blob.toString('utf8', 4, 4 + len)
|
|
376
|
+
} catch {
|
|
377
|
+
return ''
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
|
|
382
|
+
* in a known_hosts file (without the `SHA256:` prefix).
|
|
383
|
+
*
|
|
384
|
+
* ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
|
|
385
|
+
* wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
|
|
386
|
+
* object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
|
|
387
|
+
* argument must be of type string or an instance of Buffer, TypedArray, or
|
|
388
|
+
* DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
|
|
389
|
+
* v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
|
|
390
|
+
* shapes defensively. */
|
|
391
|
+
function keyFingerprint(key) {
|
|
392
|
+
const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
|
|
393
|
+
if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
|
|
394
|
+
return createHash('sha256').update(blob).digest('base64')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Build an ssh2 `hostVerifier` bound to the current config. Returns
|
|
398
|
+
* `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
|
|
399
|
+
* - `off` → always accepts;
|
|
400
|
+
* - `accept-new` → records a host's key on first connect, rejects any CHANGE
|
|
401
|
+
* afterwards (classic TOFU / MITM detection);
|
|
402
|
+
* - `verify` → same, plus rejects hosts never recorded before. */
|
|
403
|
+
function createHostKeyGuard(config) {
|
|
404
|
+
const id = () => `${config.host}:${config.port}`
|
|
405
|
+
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
|
|
406
|
+
? config.hostKeyMode
|
|
407
|
+
: 'accept-new'
|
|
408
|
+
const guard = {
|
|
409
|
+
mode,
|
|
410
|
+
lastError: null,
|
|
411
|
+
knownHosts: loadKnownHosts,
|
|
412
|
+
forgetHost() {
|
|
413
|
+
const kh = loadKnownHosts()
|
|
414
|
+
delete kh[id()]
|
|
415
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
416
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
417
|
+
},
|
|
418
|
+
verifier(key) {
|
|
419
|
+
if (mode === 'off') return true
|
|
420
|
+
const fp = keyFingerprint(key)
|
|
421
|
+
const kh = loadKnownHosts()
|
|
422
|
+
const stored = kh[id()]
|
|
423
|
+
if (stored) {
|
|
424
|
+
if (stored.fingerprint === fp) return true
|
|
425
|
+
guard.lastError =
|
|
426
|
+
`host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
|
|
427
|
+
'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
430
|
+
if (mode === 'verify') {
|
|
431
|
+
guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
|
|
432
|
+
return false
|
|
433
|
+
}
|
|
434
|
+
kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
|
|
435
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
436
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
437
|
+
return true
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
return guard
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Whether the current target's key has been recorded/trusted before. */
|
|
444
|
+
function isHostKeyKnown(host, port) {
|
|
445
|
+
return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
|
|
446
|
+
}
|
|
447
|
+
|
|
228
448
|
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
229
449
|
|
|
230
450
|
class SshPool {
|
|
@@ -232,6 +452,9 @@ class SshPool {
|
|
|
232
452
|
this.config = config
|
|
233
453
|
this.client = null
|
|
234
454
|
this.connecting = null
|
|
455
|
+
// Generational token: bumped on every target change / close so a stale
|
|
456
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
457
|
+
this.epoch = 0
|
|
235
458
|
}
|
|
236
459
|
|
|
237
460
|
resolveKeyPath() {
|
|
@@ -257,31 +480,47 @@ class SshPool {
|
|
|
257
480
|
connect() {
|
|
258
481
|
if (this.client) return Promise.resolve(this.client)
|
|
259
482
|
if (this.connecting) return this.connecting
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
483
|
+
const epoch = this.epoch
|
|
484
|
+
const pending = this._doConnect(epoch)
|
|
485
|
+
this.connecting = pending
|
|
486
|
+
const clear = () => {
|
|
487
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
488
|
+
}
|
|
489
|
+
// Keep the chain clean in both directions: the raw pending is what callers
|
|
490
|
+
// await (they still see rejections); this side just clears the slot.
|
|
491
|
+
pending.then(clear, clear)
|
|
492
|
+
return pending
|
|
264
493
|
}
|
|
265
494
|
|
|
266
|
-
_doConnect() {
|
|
495
|
+
_doConnect(epoch) {
|
|
267
496
|
return new Promise((resolve, reject) => {
|
|
268
497
|
const client = new Client()
|
|
269
498
|
let settled = false
|
|
499
|
+
// Fresh host-key guard per connect attempt so lastError is never stale.
|
|
500
|
+
const guard = createHostKeyGuard(this.config)
|
|
501
|
+
const isCurrent = () => this.epoch === epoch
|
|
270
502
|
const fail = (err) => {
|
|
271
503
|
if (settled) return
|
|
272
504
|
settled = true
|
|
273
|
-
if (this.client === client) this.client = null
|
|
274
|
-
reject(err)
|
|
505
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
506
|
+
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
275
507
|
}
|
|
276
508
|
client.on('ready', () => {
|
|
277
509
|
if (settled) return
|
|
278
510
|
settled = true
|
|
511
|
+
if (!isCurrent()) {
|
|
512
|
+
// The target changed while we were connecting — never adopt this
|
|
513
|
+
// client, and make sure the caller learns the connect is void.
|
|
514
|
+
try { client.end() } catch {}
|
|
515
|
+
reject(new Error('ssh target changed during connect'))
|
|
516
|
+
return
|
|
517
|
+
}
|
|
279
518
|
this.client = client
|
|
280
519
|
resolve(client)
|
|
281
520
|
})
|
|
282
521
|
client.on('error', fail)
|
|
283
522
|
client.on('close', () => {
|
|
284
|
-
if (this.client === client) this.client = null
|
|
523
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
285
524
|
fail(new Error('ssh connection closed'))
|
|
286
525
|
})
|
|
287
526
|
|
|
@@ -292,6 +531,7 @@ class SshPool {
|
|
|
292
531
|
readyTimeout: this.config.connectTimeoutMs,
|
|
293
532
|
keepaliveInterval: 15000,
|
|
294
533
|
keepaliveCountMax: 3,
|
|
534
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
295
535
|
}
|
|
296
536
|
if (this.config.password) {
|
|
297
537
|
opts.password = this.config.password
|
|
@@ -341,9 +581,17 @@ class SshPool {
|
|
|
341
581
|
if (settled) return
|
|
342
582
|
exitCode = -1
|
|
343
583
|
exitSignal = 'TIMEOUT'
|
|
584
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
585
|
+
// channel, so a runaway process cannot keep running and holding
|
|
586
|
+
// the SSH connection after we've given up on its output. The hard
|
|
587
|
+
// close below frees the channel even if the process ignores it.
|
|
344
588
|
try {
|
|
345
|
-
stream.
|
|
589
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
346
590
|
} catch {}
|
|
591
|
+
const hardClose = setTimeout(() => {
|
|
592
|
+
try { stream.close() } catch {}
|
|
593
|
+
}, 800)
|
|
594
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
347
595
|
settle()
|
|
348
596
|
}, timeoutMs || this.config.commandTimeoutMs)
|
|
349
597
|
stream.on('close', (code, signal) => {
|
|
@@ -389,8 +637,17 @@ class SshPool {
|
|
|
389
637
|
}
|
|
390
638
|
|
|
391
639
|
close() {
|
|
640
|
+
// Bump the epoch so any in-flight connect is orphaned, drop the pending
|
|
641
|
+
// promise (its rejection becomes nobody's problem — swallow it), and end
|
|
642
|
+
// the live client if there is one.
|
|
643
|
+
this.epoch++
|
|
392
644
|
const client = this.client
|
|
393
645
|
this.client = null
|
|
646
|
+
const pending = this.connecting
|
|
647
|
+
this.connecting = null
|
|
648
|
+
if (pending && typeof pending.catch === 'function') {
|
|
649
|
+
try { pending.catch(() => {}) } catch {}
|
|
650
|
+
}
|
|
394
651
|
if (client) {
|
|
395
652
|
try {
|
|
396
653
|
client.end()
|
|
@@ -405,6 +662,10 @@ export async function apply(ctx, config) {
|
|
|
405
662
|
const pool = new SshPool(config)
|
|
406
663
|
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
407
664
|
|
|
665
|
+
// Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
|
|
666
|
+
// location so existing machines + mirrors keep working after the move.
|
|
667
|
+
migrateLegacyData()
|
|
668
|
+
|
|
408
669
|
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
409
670
|
const store = loadMachines()
|
|
410
671
|
const machines = store.list
|
|
@@ -433,6 +694,19 @@ export async function apply(ctx, config) {
|
|
|
433
694
|
if (cur && cur.host) applyMachine(config, cur)
|
|
434
695
|
}
|
|
435
696
|
|
|
697
|
+
/** Set the active remote workspace AND persist it on the current machine so
|
|
698
|
+
* it survives restarts (previously a tool-set workspace reset on reload). */
|
|
699
|
+
const persistWorkspace = (p) => {
|
|
700
|
+
config.workspace = p
|
|
701
|
+
if (store.currentId) {
|
|
702
|
+
const i = machineIndex(store.currentId)
|
|
703
|
+
if (i >= 0) {
|
|
704
|
+
machines[i].workspace = p
|
|
705
|
+
saveMachines(machines, store.currentId)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
436
710
|
const run = async (cmd, opts = {}) => {
|
|
437
711
|
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
438
712
|
const parts = []
|
|
@@ -445,27 +719,51 @@ export async function apply(ctx, config) {
|
|
|
445
719
|
return text
|
|
446
720
|
}
|
|
447
721
|
|
|
448
|
-
/** Structured-listing of a remote dir: name + usable type.
|
|
449
|
-
*
|
|
450
|
-
*
|
|
722
|
+
/** Structured-listing of a remote dir: name + usable type.
|
|
723
|
+
*
|
|
724
|
+
* Implementation note — this deliberately uses `ls -1A -F` instead of a
|
|
725
|
+
* `for f in .[!.]* *` loop or `find`:
|
|
726
|
+
* • `-F` marks real directories with a trailing `/` and symlinks with `@`;
|
|
727
|
+
* on Linux GNU ls classifies from readdir `d_type` WITHOUT stat, so the
|
|
728
|
+
* listing can never hang on a stuck FUSE/network mount the way
|
|
729
|
+
* `[ -d "$f" ]` / `find -type` do (those stat every entry and block in
|
|
730
|
+
* an uninterruptible D-state that even SIGTERM can't clear).
|
|
731
|
+
* • there is no shell glob, so zsh's `nomatch` can't abort the listing on
|
|
732
|
+
* directories that have no dotfiles (the old `.[!.]*` glob did).
|
|
733
|
+
* Symlinked entries (e.g. `/bin@`) get ONE bounded follow-up stat — only
|
|
734
|
+
* symlinks are touched, never the stuck mount — to decide dir vs file. */
|
|
451
735
|
const listDirStructured = async (p, timeoutMs) => {
|
|
452
736
|
const target = p || '/'
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
`[ -e "$f" ] || [ -L "$f" ] || continue; ` +
|
|
456
|
-
`if [ -d "$f" ]; then printf 'd\\t%s\\n' "$f"; else printf 'f\\t%s\\n' "$f"; fi; done`
|
|
457
|
-
const res = await pool.exec(cmd, timeoutMs || config.commandTimeoutMs)
|
|
458
|
-
const items = []
|
|
737
|
+
const to = timeoutMs || config.commandTimeoutMs
|
|
738
|
+
const res = await pool.exec(`ls -1A -F ${shq(target)}`, to)
|
|
459
739
|
if (res.code !== 0 && res.stderr) {
|
|
460
740
|
throw new Error('ls failed: ' + (res.stderr || '').trim())
|
|
461
741
|
}
|
|
742
|
+
const items = []
|
|
743
|
+
const symIdx = []
|
|
462
744
|
for (const line of String(res.stdout || '').split('\n')) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
745
|
+
if (!line) continue
|
|
746
|
+
const c = line[line.length - 1]
|
|
747
|
+
if (c === '/') items.push({ type: 'dir', name: line.slice(0, -1) })
|
|
748
|
+
else if (c === '@') {
|
|
749
|
+
items.push({ type: 'symlink', name: line.slice(0, -1) })
|
|
750
|
+
symIdx.push(items.length - 1)
|
|
751
|
+
} else {
|
|
752
|
+
// regular file, executable (`*`), socket (`=`), fifo (`|`) …
|
|
753
|
+
items.push({ type: 'file', name: c === '*' || c === '=' || c === '|' ? line.slice(0, -1) : line })
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Resolve symlink-to-dir vs symlink-to-file. Bounded and failure-tolerant:
|
|
757
|
+
// if the stat is slow it times out and symlinks just degrade to files
|
|
758
|
+
// (non-enterable) instead of failing the whole browse.
|
|
759
|
+
if (symIdx.length) {
|
|
760
|
+
const cmd = 'for f in ' + symIdx.map((i) => shq(items[i].name)).join(' ') +
|
|
761
|
+
'; do if [ -d "$f" ]; then echo d; else echo f; fi; done'
|
|
762
|
+
const r2 = await pool.exec(cmd, Math.min(to, 5000))
|
|
763
|
+
const kinds = String(r2.stdout || '').split('\n')
|
|
764
|
+
for (let i = 0; i < symIdx.length; i++) {
|
|
765
|
+
items[symIdx[i]].type = (kinds[i] || '').trim() === 'd' ? 'dir' : 'file'
|
|
766
|
+
}
|
|
469
767
|
}
|
|
470
768
|
return { path: target, items }
|
|
471
769
|
}
|
|
@@ -482,6 +780,8 @@ export async function apply(ctx, config) {
|
|
|
482
780
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
483
781
|
currentId: store.currentId || null,
|
|
484
782
|
machines: machines.map(sanitizeMachine),
|
|
783
|
+
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
784
|
+
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
485
785
|
})
|
|
486
786
|
|
|
487
787
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
@@ -495,7 +795,7 @@ export async function apply(ctx, config) {
|
|
|
495
795
|
defineTool({
|
|
496
796
|
name: 'rw_info',
|
|
497
797
|
description:
|
|
498
|
-
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when
|
|
798
|
+
'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.',
|
|
499
799
|
parameters: {},
|
|
500
800
|
output: {
|
|
501
801
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
@@ -506,6 +806,7 @@ export async function apply(ctx, config) {
|
|
|
506
806
|
const lines = [
|
|
507
807
|
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
508
808
|
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
809
|
+
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
509
810
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
510
811
|
'',
|
|
511
812
|
]
|
|
@@ -577,10 +878,10 @@ export async function apply(ctx, config) {
|
|
|
577
878
|
const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
578
879
|
const ok = res.stdout.trim() === 'DIR'
|
|
579
880
|
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
580
|
-
|
|
881
|
+
persistWorkspace(p)
|
|
581
882
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
582
883
|
return {
|
|
583
|
-
text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
884
|
+
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.`,
|
|
584
885
|
}
|
|
585
886
|
},
|
|
586
887
|
}),
|
|
@@ -610,8 +911,11 @@ export async function apply(ctx, config) {
|
|
|
610
911
|
} catch (err) {
|
|
611
912
|
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
612
913
|
}
|
|
613
|
-
const
|
|
614
|
-
|
|
914
|
+
const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
|
|
915
|
+
let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
|
|
916
|
+
if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
|
|
917
|
+
if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
918
|
+
return { text }
|
|
615
919
|
},
|
|
616
920
|
}),
|
|
617
921
|
|
|
@@ -638,8 +942,11 @@ export async function apply(ctx, config) {
|
|
|
638
942
|
} catch (err) {
|
|
639
943
|
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
640
944
|
}
|
|
641
|
-
const { files } = await pushTree(sftp, local, p,
|
|
642
|
-
|
|
945
|
+
const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
|
|
946
|
+
let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
|
|
947
|
+
if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
|
|
948
|
+
if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
949
|
+
return { text }
|
|
643
950
|
},
|
|
644
951
|
}),
|
|
645
952
|
|
|
@@ -657,7 +964,9 @@ export async function apply(ctx, config) {
|
|
|
657
964
|
async execute(args) {
|
|
658
965
|
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
659
966
|
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
660
|
-
|
|
967
|
+
// `ls -la` (no --color / -N): piped output never carries color on either
|
|
968
|
+
// GNU or BSD ls, so the GNU-only flags would break macOS/BSD remotes.
|
|
969
|
+
return { text: await run(`ls -la ${shq(p)}`) }
|
|
661
970
|
},
|
|
662
971
|
}),
|
|
663
972
|
|
|
@@ -682,7 +991,9 @@ export async function apply(ctx, config) {
|
|
|
682
991
|
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
683
992
|
let to = Number(args.endLine) || 0
|
|
684
993
|
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
685
|
-
|
|
994
|
+
// Absolute paths start with '/', so the GNU-only `--` terminator is
|
|
995
|
+
// dropped to keep BSD/macOS sed (which rejects `--`) working.
|
|
996
|
+
const raw = await run(`sed -n '${from},${to}p' ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
|
|
686
997
|
const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
687
998
|
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
688
999
|
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
@@ -693,9 +1004,10 @@ export async function apply(ctx, config) {
|
|
|
693
1004
|
defineTool({
|
|
694
1005
|
name: 'rw_exec',
|
|
695
1006
|
description:
|
|
696
|
-
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
|
|
1007
|
+
'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.',
|
|
697
1008
|
parameters: {
|
|
698
1009
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1010
|
+
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
699
1011
|
},
|
|
700
1012
|
output: {
|
|
701
1013
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
@@ -704,7 +1016,10 @@ export async function apply(ctx, config) {
|
|
|
704
1016
|
async execute(args) {
|
|
705
1017
|
const cmd = String(args.command || '')
|
|
706
1018
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
707
|
-
|
|
1019
|
+
const ws = wsPath()
|
|
1020
|
+
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1021
|
+
const full = cwd ? `cd ${shq(cwd)} && ${cmd}` : cmd
|
|
1022
|
+
return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
|
|
708
1023
|
},
|
|
709
1024
|
}),
|
|
710
1025
|
|
|
@@ -750,6 +1065,110 @@ export async function apply(ctx, config) {
|
|
|
750
1065
|
},
|
|
751
1066
|
}),
|
|
752
1067
|
|
|
1068
|
+
defineTool({
|
|
1069
|
+
name: 'rw_search',
|
|
1070
|
+
description:
|
|
1071
|
+
'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.',
|
|
1072
|
+
parameters: {
|
|
1073
|
+
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1074
|
+
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
1075
|
+
glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
|
|
1076
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1077
|
+
},
|
|
1078
|
+
output: {
|
|
1079
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1080
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1081
|
+
},
|
|
1082
|
+
async execute(args) {
|
|
1083
|
+
const pattern = String(args.pattern || '')
|
|
1084
|
+
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1085
|
+
const ws = wsPath()
|
|
1086
|
+
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
1087
|
+
if (!dir) throw new Error('rw_search: no path and no remote workspace set')
|
|
1088
|
+
const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
|
|
1089
|
+
const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
|
|
1090
|
+
// find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
|
|
1091
|
+
// differ on BSD, and GNU-only --include is avoided in favor of find -name.
|
|
1092
|
+
const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
|
|
1093
|
+
return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
|
|
1094
|
+
},
|
|
1095
|
+
}),
|
|
1096
|
+
|
|
1097
|
+
defineTool({
|
|
1098
|
+
name: 'rw_download',
|
|
1099
|
+
description:
|
|
1100
|
+
'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.',
|
|
1101
|
+
parameters: {
|
|
1102
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1103
|
+
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1104
|
+
},
|
|
1105
|
+
output: {
|
|
1106
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1107
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
|
|
1108
|
+
},
|
|
1109
|
+
async execute(args) {
|
|
1110
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1111
|
+
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1112
|
+
let sftp
|
|
1113
|
+
try {
|
|
1114
|
+
sftp = await pool.sftp()
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
|
|
1117
|
+
}
|
|
1118
|
+
let local
|
|
1119
|
+
if (args.localPath) {
|
|
1120
|
+
local = path.resolve(String(args.localPath))
|
|
1121
|
+
} else {
|
|
1122
|
+
const ws = wsPath()
|
|
1123
|
+
if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
|
|
1124
|
+
const base = mirrorDirFor(ws, config.host, config.username, config.port)
|
|
1125
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
1126
|
+
local = path.join(base, rel)
|
|
1127
|
+
}
|
|
1128
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
1129
|
+
const buf = await sftp.readFile(p)
|
|
1130
|
+
writeFileSync(local, buf)
|
|
1131
|
+
const bytes = buf.byteLength
|
|
1132
|
+
return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
|
|
1133
|
+
},
|
|
1134
|
+
}),
|
|
1135
|
+
|
|
1136
|
+
defineTool({
|
|
1137
|
+
name: 'rw_upload',
|
|
1138
|
+
description:
|
|
1139
|
+
'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
|
|
1140
|
+
parameters: {
|
|
1141
|
+
localPath: { type: 'string', required: true, description: 'Absolute local file path' },
|
|
1142
|
+
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1143
|
+
},
|
|
1144
|
+
output: {
|
|
1145
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1146
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
|
|
1147
|
+
},
|
|
1148
|
+
async execute(args) {
|
|
1149
|
+
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1150
|
+
const lp = String(args.localPath || '')
|
|
1151
|
+
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1152
|
+
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1153
|
+
let sftp
|
|
1154
|
+
try {
|
|
1155
|
+
sftp = await pool.sftp()
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
|
|
1158
|
+
}
|
|
1159
|
+
const segs = remoteDirname(rp).split('/').filter(Boolean)
|
|
1160
|
+
let cur = ''
|
|
1161
|
+
for (const s of segs) {
|
|
1162
|
+
cur += '/' + s
|
|
1163
|
+
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
1164
|
+
}
|
|
1165
|
+
const buf = readFileSync(lp)
|
|
1166
|
+
await sftp.writeFile(rp, buf)
|
|
1167
|
+
const bytes = buf.byteLength
|
|
1168
|
+
return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
|
|
1169
|
+
},
|
|
1170
|
+
}),
|
|
1171
|
+
|
|
753
1172
|
defineTool({
|
|
754
1173
|
name: 'rw_disconnect',
|
|
755
1174
|
description:
|
|
@@ -780,7 +1199,7 @@ export async function apply(ctx, config) {
|
|
|
780
1199
|
return (
|
|
781
1200
|
'## Remote workspace\n' +
|
|
782
1201
|
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
783
|
-
'Use the
|
|
1202
|
+
'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.'
|
|
784
1203
|
)
|
|
785
1204
|
},
|
|
786
1205
|
})
|
|
@@ -798,11 +1217,21 @@ export async function apply(ctx, config) {
|
|
|
798
1217
|
text:
|
|
799
1218
|
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
800
1219
|
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
801
|
-
|
|
1220
|
+
`Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
|
|
1221
|
+
(s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
|
|
1222
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
|
|
802
1223
|
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
803
1224
|
}
|
|
804
1225
|
},
|
|
805
1226
|
})
|
|
1227
|
+
commands.register({
|
|
1228
|
+
name: 'remote-forget-key',
|
|
1229
|
+
description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
|
|
1230
|
+
handler: () => {
|
|
1231
|
+
createHostKeyGuard(config).forgetHost()
|
|
1232
|
+
return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
|
|
1233
|
+
},
|
|
1234
|
+
})
|
|
806
1235
|
}
|
|
807
1236
|
|
|
808
1237
|
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
@@ -814,13 +1243,63 @@ export async function apply(ctx, config) {
|
|
|
814
1243
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
815
1244
|
res.end(JSON.stringify(body))
|
|
816
1245
|
}
|
|
1246
|
+
// Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
|
|
1247
|
+
// unbounded memory on a local-only route.
|
|
1248
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
817
1249
|
const readBody = (req) =>
|
|
818
1250
|
new Promise((resolve) => {
|
|
819
1251
|
const chunks = []
|
|
820
|
-
|
|
1252
|
+
let total = 0
|
|
1253
|
+
req.on('data', (c) => {
|
|
1254
|
+
total += c.length
|
|
1255
|
+
if (total > MAX_BODY_BYTES) {
|
|
1256
|
+
req.removeAllListeners('data')
|
|
1257
|
+
resolve('{}')
|
|
1258
|
+
return
|
|
1259
|
+
}
|
|
1260
|
+
chunks.push(c)
|
|
1261
|
+
})
|
|
821
1262
|
req.on('end', () => resolve(chunks.join('')))
|
|
822
1263
|
})
|
|
823
1264
|
|
|
1265
|
+
// ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
|
|
1266
|
+
// 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
|
|
1267
|
+
// loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
|
|
1268
|
+
// 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
|
|
1269
|
+
// native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
|
|
1270
|
+
const PICK_TIMEOUT_MS = 120000
|
|
1271
|
+
const runPick = (bin, args) =>
|
|
1272
|
+
new Promise((resolve, reject) => {
|
|
1273
|
+
execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
1274
|
+
if (err) {
|
|
1275
|
+
const code = err.code
|
|
1276
|
+
const msg = String(stderr || '')
|
|
1277
|
+
// 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
|
|
1278
|
+
if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
|
|
1279
|
+
if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
|
|
1280
|
+
if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
|
|
1281
|
+
return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
|
|
1282
|
+
}
|
|
1283
|
+
const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
|
|
1284
|
+
resolve(p ? { path: p } : { cancelled: true })
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
const pickLocalNative = async () => {
|
|
1288
|
+
const platform = process.platform
|
|
1289
|
+
if (platform === 'darwin') {
|
|
1290
|
+
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
1291
|
+
}
|
|
1292
|
+
if (platform === 'linux') {
|
|
1293
|
+
try {
|
|
1294
|
+
return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
|
|
1297
|
+
throw err
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1301
|
+
}
|
|
1302
|
+
|
|
824
1303
|
const routes = [
|
|
825
1304
|
{
|
|
826
1305
|
kind: 'exact',
|
|
@@ -857,8 +1336,10 @@ export async function apply(ctx, config) {
|
|
|
857
1336
|
path: '/dsh-remote/ls',
|
|
858
1337
|
handler: async (req, res) => {
|
|
859
1338
|
try {
|
|
860
|
-
|
|
861
|
-
|
|
1339
|
+
// Proper URL parsing so `+` in a path decodes to a literal space
|
|
1340
|
+
// (the old regex missed it).
|
|
1341
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1342
|
+
const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
862
1343
|
const out = await listDirStructured(p || '/')
|
|
863
1344
|
return sendJson(res, 200, { path: p, items: out.items })
|
|
864
1345
|
} catch (err) {
|
|
@@ -877,7 +1358,7 @@ export async function apply(ctx, config) {
|
|
|
877
1358
|
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
878
1359
|
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
879
1360
|
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
880
|
-
|
|
1361
|
+
persistWorkspace(p)
|
|
881
1362
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
882
1363
|
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
883
1364
|
} catch (err) {
|
|
@@ -895,13 +1376,12 @@ export async function apply(ctx, config) {
|
|
|
895
1376
|
const p = normalizeRemotePath(String(payload.path || ''))
|
|
896
1377
|
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
897
1378
|
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
}
|
|
1379
|
+
// Always verify over SSH (connecting if needed) so an unconnected or
|
|
1380
|
+
// wrong host can never silently mint a mirror for a bogus path.
|
|
1381
|
+
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
1382
|
+
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
903
1383
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
904
|
-
|
|
1384
|
+
persistWorkspace(p)
|
|
905
1385
|
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
906
1386
|
} catch (err) {
|
|
907
1387
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
@@ -914,16 +1394,38 @@ export async function apply(ctx, config) {
|
|
|
914
1394
|
handler: async (req, res) => {
|
|
915
1395
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
916
1396
|
try {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1397
|
+
// 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
|
|
1398
|
+
// ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
|
|
1399
|
+
// "cannot get property ... without inject"(该名字未在本插件
|
|
1400
|
+
// inject 里声明),ctx.get() 无此要求、缺失返回 null。
|
|
1401
|
+
// 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
|
|
1402
|
+
// 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
|
|
1403
|
+
let outcome = null
|
|
1404
|
+
let via = 'service'
|
|
1405
|
+
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1406
|
+
if (dp && typeof dp.capability === 'function') {
|
|
1407
|
+
try {
|
|
1408
|
+
const cap = await Promise.resolve(dp.capability())
|
|
1409
|
+
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
1410
|
+
const pickAbort = new AbortController()
|
|
1411
|
+
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
1412
|
+
pickAbort.abort()
|
|
1413
|
+
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
1414
|
+
}
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
outcome = null // DSH 服务出错 → 落到自持兜底
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (!outcome) {
|
|
1420
|
+
via = 'own'
|
|
1421
|
+
try {
|
|
1422
|
+
outcome = await pickLocalNative()
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
|
|
1428
|
+
return sendJson(res, 200, { ok: true, path: outcome.path, via })
|
|
927
1429
|
} catch (err) {
|
|
928
1430
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
929
1431
|
}
|
|
@@ -1020,6 +1522,18 @@ export async function apply(ctx, config) {
|
|
|
1020
1522
|
}
|
|
1021
1523
|
},
|
|
1022
1524
|
},
|
|
1525
|
+
{
|
|
1526
|
+
kind: 'exact',
|
|
1527
|
+
path: '/dsh-remote/forget-key',
|
|
1528
|
+
handler: async (req, res) => {
|
|
1529
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1530
|
+
// Drop the trusted host-key record for the CURRENT target so the next
|
|
1531
|
+
// connect re-records it (use after an intentional host reinstall or a
|
|
1532
|
+
// false MITM alarm).
|
|
1533
|
+
createHostKeyGuard(config).forgetHost()
|
|
1534
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1023
1537
|
]
|
|
1024
1538
|
|
|
1025
1539
|
const disposers = routes.map((r) => webServer.register(r))
|