dsh-remote 0.7.0 → 0.7.2
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 +189 -174
- package/README.zh.md +178 -165
- package/cordis.patch.yml +24 -6
- package/lib/client.js +788 -728
- package/lib/index.js +1736 -1736
- package/package.json +62 -61
package/lib/index.js
CHANGED
|
@@ -1,1737 +1,1737 @@
|
|
|
1
|
-
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
|
-
//
|
|
3
|
-
// Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
|
|
4
|
-
// • one persistent SSH/SFTP pool per configured remote (password OR private key),
|
|
5
|
-
// • a "current remote workspace" — a remote directory the agent treats as the
|
|
6
|
-
// active project root (user@host:/path) — injected into every system prompt,
|
|
7
|
-
// • model tools `rw_info` / `rw_connect` / `rw_pick_workspace` /
|
|
8
|
-
// `rw_list_dir` / `rw_read_file` / `rw_exec`,
|
|
9
|
-
// • JSON endpoints the client settings page uses to connect → browse → select the
|
|
10
|
-
// remote workspace over the harness `webServer`.
|
|
11
|
-
//
|
|
12
|
-
// The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
|
|
13
|
-
// proven by dsh-remote-debug, extended with password auth and a mutable workspace:
|
|
14
|
-
// `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
|
|
15
|
-
// presented as such to the model and UI, not a replacement of the local one.
|
|
16
|
-
//
|
|
17
|
-
// Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
|
|
18
|
-
import z from '@deepseek-ai/schemastery'
|
|
19
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
-
import ssh2 from 'ssh2'
|
|
21
|
-
import { 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'
|
|
24
|
-
import { homedir } from 'node:os'
|
|
25
|
-
import path from 'node:path'
|
|
26
|
-
|
|
27
|
-
const { Client } = ssh2
|
|
28
|
-
|
|
29
|
-
export const name = 'dsh-remote'
|
|
30
|
-
|
|
31
|
-
// tools + systemPrompt + webServer are required. webServer is INJECTED (not just
|
|
32
|
-
// lazily read) so this plugin activates only after the web server is up — otherwise
|
|
33
|
-
// apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
|
|
34
|
-
// (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
|
|
35
|
-
export const inject = ['tools', 'systemPrompt', 'webServer']
|
|
36
|
-
|
|
37
|
-
export const Config = z.object({
|
|
38
|
-
/** Remote SSH host (empty → the plugin starts disconnected). */
|
|
39
|
-
host: z.string().default(''),
|
|
40
|
-
/** Remote SSH port (22 unless the machine uses a custom port). */
|
|
41
|
-
port: z.number().step(1).min(1).max(65535).default(22),
|
|
42
|
-
/** SSH login user. */
|
|
43
|
-
username: z.string().default(''),
|
|
44
|
-
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
45
|
-
password: z.string().default(''),
|
|
46
|
-
/** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
|
|
47
|
-
privateKeyPath: z.string().default(''),
|
|
48
|
-
/** Key passphrase when the key is encrypted. */
|
|
49
|
-
passphrase: z.string().default(''),
|
|
50
|
-
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
51
|
-
workspace: z.string().default(''),
|
|
52
|
-
/** Per-command timeout. */
|
|
53
|
-
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
54
|
-
/** SSH connection establishment timeout. */
|
|
55
|
-
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
56
|
-
/** Hard ceiling on collected remote output per call. */
|
|
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'),
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
68
|
-
|
|
69
|
-
/** Single-quote one shell argument verbatim. */
|
|
70
|
-
function shq(s) {
|
|
71
|
-
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Normalize a remote path into a clean absolute path, supporting BOTH POSIX
|
|
76
|
-
* (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
|
|
77
|
-
*
|
|
78
|
-
* - POSIX paths keep `/` separators and a leading `/`.
|
|
79
|
-
* - Windows drive paths keep their `X:` prefix and are returned with `\`
|
|
80
|
-
* separators (the native form on a Windows remote); a leading `/` is NOT
|
|
81
|
-
* added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
|
|
82
|
-
* - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
|
|
83
|
-
* - `.` / `..` segments are collapsed in both separator styles.
|
|
84
|
-
*/
|
|
85
|
-
function normalizeRemotePath(p) {
|
|
86
|
-
const s = String(p)
|
|
87
|
-
// Windows UNC: \\server\share\…
|
|
88
|
-
const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
|
|
89
|
-
if (unc) {
|
|
90
|
-
const [prefix, rest] = [unc[1], unc[2]]
|
|
91
|
-
const parts = []
|
|
92
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
93
|
-
if (seg === '' || seg === '.') continue
|
|
94
|
-
if (seg === '..') { parts.pop(); continue }
|
|
95
|
-
parts.push(seg)
|
|
96
|
-
}
|
|
97
|
-
return parts.length ? prefix + '\\' + parts.join('\\') : prefix
|
|
98
|
-
}
|
|
99
|
-
// Windows drive: X:\… or X:/…
|
|
100
|
-
const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
|
|
101
|
-
if (drive) {
|
|
102
|
-
const [prefix, rest] = [drive[1], drive[2]]
|
|
103
|
-
const parts = []
|
|
104
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
105
|
-
if (seg === '' || seg === '.') continue
|
|
106
|
-
if (seg === '..') { parts.pop(); continue }
|
|
107
|
-
parts.push(seg)
|
|
108
|
-
}
|
|
109
|
-
return prefix + '\\' + parts.join('\\')
|
|
110
|
-
}
|
|
111
|
-
// POSIX: /a/b/c
|
|
112
|
-
const parts = []
|
|
113
|
-
for (const seg of s.split('/')) {
|
|
114
|
-
if (seg === '' || seg === '.') continue
|
|
115
|
-
if (seg === '..') {
|
|
116
|
-
parts.pop()
|
|
117
|
-
continue
|
|
118
|
-
}
|
|
119
|
-
parts.push(seg)
|
|
120
|
-
}
|
|
121
|
-
return '/' + parts.join('/')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Join a remote dir + entry name, honoring the dir's own separator style. */
|
|
125
|
-
function joinRemotePath(dir, name) {
|
|
126
|
-
const d = String(dir)
|
|
127
|
-
if (!d) return String(name)
|
|
128
|
-
if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
|
|
129
|
-
return d + (d.includes('\\') ? '\\' : '/') + String(name)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Create every level of a remote dir via SFTP (mkdir -p semantics), working
|
|
133
|
-
* for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
|
|
134
|
-
* existing or permission-denied level is skipped. */
|
|
135
|
-
async function mkdirRemoteDirs(sftp, dir) {
|
|
136
|
-
const parent = remoteDirname(dir)
|
|
137
|
-
const isWin = parent.includes('\\')
|
|
138
|
-
// Split off a Windows drive prefix (D:) so it is not treated as a segment.
|
|
139
|
-
const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
|
|
140
|
-
const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
|
|
141
|
-
let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
|
|
142
|
-
// Drive root (D:\) itself, best effort (usually exists).
|
|
143
|
-
if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
|
|
144
|
-
for (const s of segs) {
|
|
145
|
-
if (cur) {
|
|
146
|
-
if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
|
|
147
|
-
cur += s
|
|
148
|
-
} else {
|
|
149
|
-
cur = (isWin ? s + ':' : '/' + s)
|
|
150
|
-
}
|
|
151
|
-
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
|
|
156
|
-
function remoteDirname(p) {
|
|
157
|
-
const norm = normalizeRemotePath(p)
|
|
158
|
-
// Windows drive/UNC root: D:\ or \\server\share
|
|
159
|
-
if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
|
|
160
|
-
// Ensure a drive root ends with a backslash: D: → D:\
|
|
161
|
-
const m = norm.match(/^([a-zA-Z]:)$/)
|
|
162
|
-
return m ? m[1] + '\\' : norm
|
|
163
|
-
}
|
|
164
|
-
const sep = norm.includes('\\') ? '\\' : '/'
|
|
165
|
-
const idx = norm.lastIndexOf(sep)
|
|
166
|
-
if (idx <= 0) {
|
|
167
|
-
// Windows drive with a child (D:\Code) → D:\
|
|
168
|
-
const m = norm.match(/^([a-zA-Z]:)\\/)
|
|
169
|
-
return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
|
|
170
|
-
}
|
|
171
|
-
// A parent that is a Windows drive root also needs the trailing backslash.
|
|
172
|
-
const parent = norm.slice(0, idx)
|
|
173
|
-
if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
|
|
174
|
-
return parent
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function truncate(s, max) {
|
|
178
|
-
if (s.length <= max) return s
|
|
179
|
-
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── local mirror of an OS remote workspace ────────────────────────────────
|
|
183
|
-
// Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
|
|
184
|
-
// so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
|
|
185
|
-
// workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
|
|
186
|
-
|
|
187
|
-
const remotePathBase = (p) => {
|
|
188
|
-
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
|
189
|
-
const base = norm.split(/[\\/]/).pop()
|
|
190
|
-
// Windows drive root (D:\) has no meaningful basename
|
|
191
|
-
if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
|
|
192
|
-
return base
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
|
|
197
|
-
* path format the SFTP server understands.
|
|
198
|
-
*
|
|
199
|
-
* Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
|
|
200
|
-
* written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
|
|
201
|
-
* UNC paths are kept as-is (the server resolves `\\server\share` itself).
|
|
202
|
-
*/
|
|
203
|
-
function toSftpPath(p) {
|
|
204
|
-
const norm = normalizeRemotePath(p)
|
|
205
|
-
const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
|
|
206
|
-
if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
|
|
207
|
-
return norm
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
|
|
211
|
-
* own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
|
|
212
|
-
* data under the same root the harness uses means uninstalling / upgrading the
|
|
213
|
-
* app no longer leaves state behind in the home directory. */
|
|
214
|
-
function dshBase() {
|
|
215
|
-
const env = process.env.DSH_HOME
|
|
216
|
-
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
217
|
-
return path.join(homedir(), '.dsh')
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Root holding every remote host's mirrors + the machine registry. */
|
|
221
|
-
function remoteWorkspacesRoot() {
|
|
222
|
-
return path.join(dshBase(), 'remote-workspaces')
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
|
|
226
|
-
function copyDirSync(from, to) {
|
|
227
|
-
mkdirSync(to, { recursive: true })
|
|
228
|
-
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
229
|
-
const s = path.join(from, e.name)
|
|
230
|
-
const d = path.join(to, e.name)
|
|
231
|
-
if (e.isDirectory()) copyDirSync(s, d)
|
|
232
|
-
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
|
|
237
|
-
* regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
|
|
238
|
-
* desktop install keeps its existing machines and mirrors. Idempotent; a
|
|
239
|
-
* rename across devices falls back to a copy. */
|
|
240
|
-
function migrateLegacyData() {
|
|
241
|
-
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
242
|
-
const target = remoteWorkspacesRoot()
|
|
243
|
-
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
244
|
-
try {
|
|
245
|
-
mkdirSync(path.dirname(target), { recursive: true })
|
|
246
|
-
try {
|
|
247
|
-
renameSync(legacy, target)
|
|
248
|
-
} catch (err) {
|
|
249
|
-
if (err.code !== 'EXDEV') throw err
|
|
250
|
-
copyDirSync(legacy, target)
|
|
251
|
-
}
|
|
252
|
-
} catch {
|
|
253
|
-
// Migration is best-effort: a fresh registry is created on next save.
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
258
|
-
function shortHash(s) {
|
|
259
|
-
let h = 0
|
|
260
|
-
for (let i = 0; i < s.length; i++) {
|
|
261
|
-
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
262
|
-
}
|
|
263
|
-
return (h >>> 0).toString(36)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Stable local root for one remote host's mirrors */
|
|
267
|
-
function mirrorRootFor(host, user, port) {
|
|
268
|
-
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
269
|
-
return path.join(remoteWorkspacesRoot(), tag)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
273
|
-
* Named after the remote directory's basename so the harness workspace label
|
|
274
|
-
* reads cleanly (e.g. .../project). When the plain basename dir is already
|
|
275
|
-
* taken by a DIFFERENT remote path on the same host, a short path-hash suffix
|
|
276
|
-
* is appended so mirrors never collide. */
|
|
277
|
-
function mirrorDirFor(remotePath, host, user, port) {
|
|
278
|
-
const base = remotePathBase(remotePath)
|
|
279
|
-
const root = mirrorRootFor(host, user, port)
|
|
280
|
-
const plain = path.join(root, base)
|
|
281
|
-
const norm = normalizeRemotePath(remotePath)
|
|
282
|
-
// A pre-existing mirror for this exact remote origin → reuse it (idempotent).
|
|
283
|
-
try {
|
|
284
|
-
const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
|
|
285
|
-
if (meta.remotePath === norm) return plain
|
|
286
|
-
} catch {
|
|
287
|
-
/* no mirror yet → fall through */
|
|
288
|
-
}
|
|
289
|
-
// Plain dir is free → first mirror for this basename keeps the clean name.
|
|
290
|
-
// It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
|
|
291
|
-
if (!existsSync(plain)) return plain
|
|
292
|
-
return path.join(root, base + '-' + shortHash(norm))
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
296
|
-
function ensureMirror(remotePath, host, user, port) {
|
|
297
|
-
const dir = mirrorDirFor(remotePath, host, user, port)
|
|
298
|
-
mkdirSync(dir, { recursive: true })
|
|
299
|
-
writeFileSync(
|
|
300
|
-
path.join(dir, '.dsh-remote-meta.json'),
|
|
301
|
-
JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
|
|
302
|
-
)
|
|
303
|
-
return dir
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
|
|
307
|
-
async function mapLimit(items, limit, fn) {
|
|
308
|
-
const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
|
|
309
|
-
let next = 0
|
|
310
|
-
const worker = async () => {
|
|
311
|
-
while (next < items.length) {
|
|
312
|
-
const i = next++
|
|
313
|
-
try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
const workers = []
|
|
317
|
-
for (let w = 0; w < limitN; w++) workers.push(worker())
|
|
318
|
-
await Promise.all(workers)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Recursively sync remote → local mirror. Bounded by depth/files/size;
|
|
322
|
-
* skips files whose size+mtime already match locally. Returns counters. */
|
|
323
|
-
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
|
|
324
|
-
const entries = await sftp.readdir(remoteDir).then(
|
|
325
|
-
(list) => list,
|
|
326
|
-
() => [],
|
|
327
|
-
)
|
|
328
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
|
|
329
|
-
|
|
330
|
-
// Directories first (sequential, depth-bounded) so every file has a home.
|
|
331
|
-
for (const e of entries) {
|
|
332
|
-
const name = String(e.filename)
|
|
333
|
-
if (name === '.' || name === '..') continue
|
|
334
|
-
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
335
|
-
if (!isDir) continue
|
|
336
|
-
if (maxDepth <= 0 || stats.files >= maxFiles) continue
|
|
337
|
-
const rp = joinRemotePath(remoteDir, name)
|
|
338
|
-
const lp = path.join(localDir, name)
|
|
339
|
-
mkdirSync(lp, { recursive: true })
|
|
340
|
-
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
|
|
341
|
-
stats.dirs += sub.dirs + 1
|
|
342
|
-
stats.files += sub.files
|
|
343
|
-
stats.skippedUnchanged += sub.skippedUnchanged
|
|
344
|
-
stats.skippedLarge += sub.skippedLarge
|
|
345
|
-
stats.touched.push(...sub.touched)
|
|
346
|
-
if (stats.files >= maxFiles) break
|
|
347
|
-
}
|
|
348
|
-
if (stats.files >= maxFiles) return stats
|
|
349
|
-
|
|
350
|
-
// Files in one bounded-parallel sweep per directory level.
|
|
351
|
-
const fileEntries = entries.filter(
|
|
352
|
-
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
353
|
-
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
354
|
-
)
|
|
355
|
-
await mapLimit(fileEntries, 4, async (e) => {
|
|
356
|
-
if (stats.files >= maxFiles) return
|
|
357
|
-
const name = String(e.filename)
|
|
358
|
-
const rp = joinRemotePath(remoteDir, name)
|
|
359
|
-
const lp = path.join(localDir, name)
|
|
360
|
-
try {
|
|
361
|
-
const st = await sftp.stat(rp)
|
|
362
|
-
if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
363
|
-
const lpStat = existsSync(lp) ? statSync(lp) : null
|
|
364
|
-
const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
|
|
365
|
-
if (unchanged) { stats.skippedUnchanged++; return }
|
|
366
|
-
const buf = await sftp.readFile(rp)
|
|
367
|
-
writeFileSync(lp, buf)
|
|
368
|
-
try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
|
|
369
|
-
stats.files++
|
|
370
|
-
stats.touched.push(rp)
|
|
371
|
-
} catch {
|
|
372
|
-
/* skip unreadable */
|
|
373
|
-
}
|
|
374
|
-
})
|
|
375
|
-
return stats
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
|
|
379
|
-
* skips files whose remote copy already matches (same size, remote no older). */
|
|
380
|
-
async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
|
|
381
|
-
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
382
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
|
|
383
|
-
|
|
384
|
-
for (const e of entries) {
|
|
385
|
-
if (!e.isDirectory()) continue
|
|
386
|
-
if (stats.files >= maxFiles) break
|
|
387
|
-
const rp = joinRemotePath(remoteDir, e.name)
|
|
388
|
-
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
389
|
-
stats.dirs++
|
|
390
|
-
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
|
|
391
|
-
stats.files += sub.files
|
|
392
|
-
stats.skippedUnchanged += sub.skippedUnchanged
|
|
393
|
-
stats.skippedLarge += sub.skippedLarge
|
|
394
|
-
stats.pushed.push(...sub.pushed)
|
|
395
|
-
if (stats.files >= maxFiles) break
|
|
396
|
-
}
|
|
397
|
-
if (stats.files >= maxFiles) return stats
|
|
398
|
-
|
|
399
|
-
const fileEntries = entries.filter((e) => !e.isDirectory())
|
|
400
|
-
await mapLimit(fileEntries, 4, async (e) => {
|
|
401
|
-
if (stats.files >= maxFiles) return
|
|
402
|
-
const lp = path.join(localDir, e.name)
|
|
403
|
-
const rp = joinRemotePath(remoteDir, e.name)
|
|
404
|
-
try {
|
|
405
|
-
const lstat = statSync(lp)
|
|
406
|
-
if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
407
|
-
let rstat = null
|
|
408
|
-
try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
|
|
409
|
-
const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
|
|
410
|
-
if (unchanged) { stats.skippedUnchanged++; return }
|
|
411
|
-
const buf = readFileSync(lp)
|
|
412
|
-
await sftp.writeFile(rp, buf)
|
|
413
|
-
stats.files++
|
|
414
|
-
stats.pushed.push(rp)
|
|
415
|
-
} catch {
|
|
416
|
-
/* skip unreadable / unwritable */
|
|
417
|
-
}
|
|
418
|
-
})
|
|
419
|
-
return stats
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
423
|
-
const MACHINES_FILE = 'machines.json'
|
|
424
|
-
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
425
|
-
function loadMachines() {
|
|
426
|
-
try {
|
|
427
|
-
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
428
|
-
if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
|
|
429
|
-
} catch {}
|
|
430
|
-
return { list: [], currentId: null }
|
|
431
|
-
}
|
|
432
|
-
function saveMachines(list, currentId) {
|
|
433
|
-
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
434
|
-
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
435
|
-
}
|
|
436
|
-
function sanitizeMachine(m) {
|
|
437
|
-
if (!m) return m
|
|
438
|
-
const { password, ...rest } = m
|
|
439
|
-
return { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
440
|
-
}
|
|
441
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
442
|
-
function applyMachine(config, m) {
|
|
443
|
-
if (!m) return
|
|
444
|
-
config.host = m.host
|
|
445
|
-
config.port = Number(m.port) || 22
|
|
446
|
-
config.username = m.username || ''
|
|
447
|
-
config.password = m.password || ''
|
|
448
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
449
|
-
config.passphrase = m.passphrase || ''
|
|
450
|
-
config.workspace = m.workspace || (config.workspace || '')
|
|
451
|
-
}
|
|
452
|
-
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
453
|
-
|
|
454
|
-
// ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
|
|
455
|
-
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
456
|
-
const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
|
|
457
|
-
|
|
458
|
-
/** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
|
|
459
|
-
function loadKnownHosts() {
|
|
460
|
-
try {
|
|
461
|
-
const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
|
|
462
|
-
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
463
|
-
} catch {}
|
|
464
|
-
return {}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/** Extract the host-key algorithm name from a raw SSH host-key blob
|
|
468
|
-
* (SSH wire format: `uint32 len` + algorithm string + key data). */
|
|
469
|
-
function blobAlgorithm(blob) {
|
|
470
|
-
if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
|
|
471
|
-
try {
|
|
472
|
-
const len = blob.readUInt32BE(0)
|
|
473
|
-
return blob.toString('utf8', 4, 4 + len)
|
|
474
|
-
} catch {
|
|
475
|
-
return ''
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
|
|
480
|
-
* in a known_hosts file (without the `SHA256:` prefix).
|
|
481
|
-
*
|
|
482
|
-
* ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
|
|
483
|
-
* wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
|
|
484
|
-
* object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
|
|
485
|
-
* argument must be of type string or an instance of Buffer, TypedArray, or
|
|
486
|
-
* DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
|
|
487
|
-
* v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
|
|
488
|
-
* shapes defensively. */
|
|
489
|
-
function keyFingerprint(key) {
|
|
490
|
-
const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
|
|
491
|
-
if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
|
|
492
|
-
return createHash('sha256').update(blob).digest('base64')
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** Build an ssh2 `hostVerifier` bound to the current config. Returns
|
|
496
|
-
* `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
|
|
497
|
-
* - `off` → always accepts;
|
|
498
|
-
* - `accept-new` → records a host's key on first connect, rejects any CHANGE
|
|
499
|
-
* afterwards (classic TOFU / MITM detection);
|
|
500
|
-
* - `verify` → same, plus rejects hosts never recorded before. */
|
|
501
|
-
function createHostKeyGuard(config) {
|
|
502
|
-
const id = () => `${config.host}:${config.port}`
|
|
503
|
-
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
|
|
504
|
-
? config.hostKeyMode
|
|
505
|
-
: 'accept-new'
|
|
506
|
-
const guard = {
|
|
507
|
-
mode,
|
|
508
|
-
lastError: null,
|
|
509
|
-
knownHosts: loadKnownHosts,
|
|
510
|
-
forgetHost() {
|
|
511
|
-
const kh = loadKnownHosts()
|
|
512
|
-
delete kh[id()]
|
|
513
|
-
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
514
|
-
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
515
|
-
},
|
|
516
|
-
verifier(key) {
|
|
517
|
-
if (mode === 'off') return true
|
|
518
|
-
const fp = keyFingerprint(key)
|
|
519
|
-
const kh = loadKnownHosts()
|
|
520
|
-
const stored = kh[id()]
|
|
521
|
-
if (stored) {
|
|
522
|
-
if (stored.fingerprint === fp) return true
|
|
523
|
-
guard.lastError =
|
|
524
|
-
`host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
|
|
525
|
-
'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
|
|
526
|
-
return false
|
|
527
|
-
}
|
|
528
|
-
if (mode === 'verify') {
|
|
529
|
-
guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
|
|
530
|
-
return false
|
|
531
|
-
}
|
|
532
|
-
kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
|
|
533
|
-
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
534
|
-
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
535
|
-
return true
|
|
536
|
-
},
|
|
537
|
-
}
|
|
538
|
-
return guard
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Whether the current target's key has been recorded/trusted before. */
|
|
542
|
-
function isHostKeyKnown(host, port) {
|
|
543
|
-
return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
547
|
-
|
|
548
|
-
class SshPool {
|
|
549
|
-
constructor(config) {
|
|
550
|
-
this.config = config
|
|
551
|
-
this.client = null
|
|
552
|
-
this.connecting = null
|
|
553
|
-
// Generational token: bumped on every target change / close so a stale
|
|
554
|
-
// in-flight connect can never hand this pool a connection to an old host.
|
|
555
|
-
this.epoch = 0
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
resolveKeyPath() {
|
|
559
|
-
const p = this.config.privateKeyPath
|
|
560
|
-
if (!p) return ''
|
|
561
|
-
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
562
|
-
return p
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
566
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
567
|
-
if (host !== undefined) this.config.host = String(host)
|
|
568
|
-
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
569
|
-
if (username !== undefined) this.config.username = String(username)
|
|
570
|
-
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
571
|
-
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
572
|
-
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
573
|
-
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
574
|
-
this.close()
|
|
575
|
-
return this
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
connect() {
|
|
579
|
-
if (this.client) return Promise.resolve(this.client)
|
|
580
|
-
if (this.connecting) return this.connecting
|
|
581
|
-
const epoch = this.epoch
|
|
582
|
-
const pending = this._doConnect(epoch)
|
|
583
|
-
this.connecting = pending
|
|
584
|
-
const clear = () => {
|
|
585
|
-
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
586
|
-
}
|
|
587
|
-
// Keep the chain clean in both directions: the raw pending is what callers
|
|
588
|
-
// await (they still see rejections); this side just clears the slot.
|
|
589
|
-
pending.then(clear, clear)
|
|
590
|
-
return pending
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
_doConnect(epoch) {
|
|
594
|
-
return new Promise((resolve, reject) => {
|
|
595
|
-
const client = new Client()
|
|
596
|
-
let settled = false
|
|
597
|
-
// Fresh host-key guard per connect attempt so lastError is never stale.
|
|
598
|
-
const guard = createHostKeyGuard(this.config)
|
|
599
|
-
const isCurrent = () => this.epoch === epoch
|
|
600
|
-
const fail = (err) => {
|
|
601
|
-
if (settled) return
|
|
602
|
-
settled = true
|
|
603
|
-
if (isCurrent() && this.client === client) this.client = null
|
|
604
|
-
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
605
|
-
}
|
|
606
|
-
client.on('ready', () => {
|
|
607
|
-
if (settled) return
|
|
608
|
-
settled = true
|
|
609
|
-
if (!isCurrent()) {
|
|
610
|
-
// The target changed while we were connecting — never adopt this
|
|
611
|
-
// client, and make sure the caller learns the connect is void.
|
|
612
|
-
try { client.end() } catch {}
|
|
613
|
-
reject(new Error('ssh target changed during connect'))
|
|
614
|
-
return
|
|
615
|
-
}
|
|
616
|
-
this.client = client
|
|
617
|
-
resolve(client)
|
|
618
|
-
})
|
|
619
|
-
client.on('error', fail)
|
|
620
|
-
client.on('close', () => {
|
|
621
|
-
if (isCurrent() && this.client === client) this.client = null
|
|
622
|
-
fail(new Error('ssh connection closed'))
|
|
623
|
-
})
|
|
624
|
-
|
|
625
|
-
const opts = {
|
|
626
|
-
host: this.config.host,
|
|
627
|
-
port: this.config.port,
|
|
628
|
-
username: this.config.username,
|
|
629
|
-
readyTimeout: this.config.connectTimeoutMs,
|
|
630
|
-
keepaliveInterval: 15000,
|
|
631
|
-
keepaliveCountMax: 3,
|
|
632
|
-
hostVerifier: (key) => guard.verifier(key),
|
|
633
|
-
}
|
|
634
|
-
if (this.config.password) {
|
|
635
|
-
opts.password = this.config.password
|
|
636
|
-
} else {
|
|
637
|
-
const keyPath = this.resolveKeyPath()
|
|
638
|
-
if (!keyPath) {
|
|
639
|
-
return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
|
|
640
|
-
}
|
|
641
|
-
let key
|
|
642
|
-
try {
|
|
643
|
-
key = readFileSync(keyPath)
|
|
644
|
-
} catch (err) {
|
|
645
|
-
return fail(new Error(`cannot read private key "${keyPath}": ${err && err.message}`))
|
|
646
|
-
}
|
|
647
|
-
opts.privateKey = key
|
|
648
|
-
opts.passphrase = this.config.passphrase || undefined
|
|
649
|
-
}
|
|
650
|
-
client.connect(opts)
|
|
651
|
-
})
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/** Run one remote command; resolves { code, signal, stdout, stderr }. */
|
|
655
|
-
exec(command, timeoutMs) {
|
|
656
|
-
return this.connect().then(
|
|
657
|
-
(client) =>
|
|
658
|
-
new Promise((resolve, reject) => {
|
|
659
|
-
client.exec(command, (err, stream) => {
|
|
660
|
-
if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
661
|
-
let stdout = ''
|
|
662
|
-
let stderr = ''
|
|
663
|
-
let settled = false
|
|
664
|
-
let exitCode = null
|
|
665
|
-
let exitSignal = null
|
|
666
|
-
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
667
|
-
const settle = () => {
|
|
668
|
-
if (settled) return
|
|
669
|
-
settled = true
|
|
670
|
-
clearTimeout(timer)
|
|
671
|
-
resolve({
|
|
672
|
-
code: exitCode,
|
|
673
|
-
signal: exitSignal,
|
|
674
|
-
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
675
|
-
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
676
|
-
})
|
|
677
|
-
}
|
|
678
|
-
const timer = setTimeout(() => {
|
|
679
|
-
if (settled) return
|
|
680
|
-
exitCode = -1
|
|
681
|
-
exitSignal = 'TIMEOUT'
|
|
682
|
-
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
683
|
-
// channel, so a runaway process cannot keep running and holding
|
|
684
|
-
// the SSH connection after we've given up on its output. The hard
|
|
685
|
-
// close below frees the channel even if the process ignores it.
|
|
686
|
-
try {
|
|
687
|
-
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
688
|
-
} catch {}
|
|
689
|
-
const hardClose = setTimeout(() => {
|
|
690
|
-
try { stream.close() } catch {}
|
|
691
|
-
}, 800)
|
|
692
|
-
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
693
|
-
settle()
|
|
694
|
-
}, timeoutMs || this.config.commandTimeoutMs)
|
|
695
|
-
stream.on('close', (code, signal) => {
|
|
696
|
-
if (settled) return
|
|
697
|
-
exitCode = code
|
|
698
|
-
exitSignal = signal
|
|
699
|
-
settle()
|
|
700
|
-
})
|
|
701
|
-
stream.on('data', (d) => {
|
|
702
|
-
if (stdout.length < hardCap) stdout += d
|
|
703
|
-
})
|
|
704
|
-
stream.stderr.on('data', (d) => {
|
|
705
|
-
if (stderr.length < hardCap) stderr += d
|
|
706
|
-
})
|
|
707
|
-
stream.on('error', (e) => {
|
|
708
|
-
if (settled) return
|
|
709
|
-
settled = true
|
|
710
|
-
clearTimeout(timer)
|
|
711
|
-
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
712
|
-
})
|
|
713
|
-
})
|
|
714
|
-
}),
|
|
715
|
-
)
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/** Resolve a promisified SFTP client for binary file transfers (sync).
|
|
719
|
-
* All path arguments are normalized via toSftpPath() so Windows remotes
|
|
720
|
-
* (drive-letter paths) work too. */
|
|
721
|
-
sftp() {
|
|
722
|
-
return this.connect().then(
|
|
723
|
-
(client) =>
|
|
724
|
-
new Promise((resolve, reject) => {
|
|
725
|
-
client.sftp((err, sftp) => {
|
|
726
|
-
if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
727
|
-
// Wrap a raw SFTP method so a stuck server can never hang a tool call.
|
|
728
|
-
const withTimeout = (fn) => (...args) =>
|
|
729
|
-
new Promise((r2, j2) => {
|
|
730
|
-
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
731
|
-
const done = (e, v) => {
|
|
732
|
-
clearTimeout(timer)
|
|
733
|
-
e ? j2(e) : r2(v)
|
|
734
|
-
}
|
|
735
|
-
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
736
|
-
})
|
|
737
|
-
const P = (p) => toSftpPath(p)
|
|
738
|
-
resolve({
|
|
739
|
-
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
740
|
-
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
741
|
-
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
742
|
-
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
743
|
-
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
744
|
-
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
745
|
-
})
|
|
746
|
-
})
|
|
747
|
-
}),
|
|
748
|
-
)
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
close() {
|
|
752
|
-
// Bump the epoch so any in-flight connect is orphaned, drop the pending
|
|
753
|
-
// promise (its rejection becomes nobody's problem — swallow it), and end
|
|
754
|
-
// the live client if there is one.
|
|
755
|
-
this.epoch++
|
|
756
|
-
const client = this.client
|
|
757
|
-
this.client = null
|
|
758
|
-
const pending = this.connecting
|
|
759
|
-
this.connecting = null
|
|
760
|
-
if (pending && typeof pending.catch === 'function') {
|
|
761
|
-
try { pending.catch(() => {}) } catch {}
|
|
762
|
-
}
|
|
763
|
-
if (client) {
|
|
764
|
-
try {
|
|
765
|
-
client.end()
|
|
766
|
-
} catch {}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// ── helper: run + text ─────────────────────────────────────────────────────
|
|
772
|
-
|
|
773
|
-
export async function apply(ctx, config) {
|
|
774
|
-
const pool = new SshPool(config)
|
|
775
|
-
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
776
|
-
|
|
777
|
-
// Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
|
|
778
|
-
// location so existing machines + mirrors keep working after the move.
|
|
779
|
-
migrateLegacyData()
|
|
780
|
-
|
|
781
|
-
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
782
|
-
const store = loadMachines()
|
|
783
|
-
const machines = store.list
|
|
784
|
-
const machineIndex = (id) => machines.findIndex((m) => m.id === id)
|
|
785
|
-
const currentMachine = () => {
|
|
786
|
-
if (store.currentId) {
|
|
787
|
-
const i = machineIndex(store.currentId)
|
|
788
|
-
if (i >= 0) return machines[i]
|
|
789
|
-
}
|
|
790
|
-
// fall back to config-derived default (host set via cordis config)
|
|
791
|
-
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 }
|
|
792
|
-
return null
|
|
793
|
-
}
|
|
794
|
-
const setCurrent = (id) => {
|
|
795
|
-
const i = machineIndex(id)
|
|
796
|
-
if (i < 0) return false
|
|
797
|
-
store.currentId = id
|
|
798
|
-
saveMachines(machines, id)
|
|
799
|
-
applyMachine(config, machines[i])
|
|
800
|
-
pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
|
|
801
|
-
return true
|
|
802
|
-
}
|
|
803
|
-
// If no stored current, adopt a CLI-provided default as the active machine.
|
|
804
|
-
{
|
|
805
|
-
const cur = currentMachine()
|
|
806
|
-
if (cur && cur.host) applyMachine(config, cur)
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/** Set the active remote workspace AND persist it on the current machine so
|
|
810
|
-
* it survives restarts (previously a tool-set workspace reset on reload). */
|
|
811
|
-
const persistWorkspace = (p) => {
|
|
812
|
-
config.workspace = p
|
|
813
|
-
if (store.currentId) {
|
|
814
|
-
const i = machineIndex(store.currentId)
|
|
815
|
-
if (i >= 0) {
|
|
816
|
-
machines[i].workspace = p
|
|
817
|
-
saveMachines(machines, store.currentId)
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
const run = async (cmd, opts = {}) => {
|
|
823
|
-
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
824
|
-
const parts = []
|
|
825
|
-
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
826
|
-
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
827
|
-
if (!parts.length) parts.push('(no output)')
|
|
828
|
-
let text = parts.join('\n')
|
|
829
|
-
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
|
|
830
|
-
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
831
|
-
return text
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/** Structured-listing of a remote dir: name + usable type.
|
|
835
|
-
*
|
|
836
|
-
* Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
|
|
837
|
-
* macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
|
|
838
|
-
* shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
|
|
839
|
-
* resolved with one bounded lstat (if it fails they degrade to files). */
|
|
840
|
-
const listDirStructured = async (p, timeoutMs) => {
|
|
841
|
-
const target = normalizeRemotePath(p || '/')
|
|
842
|
-
let sftp
|
|
843
|
-
try {
|
|
844
|
-
sftp = await pool.sftp()
|
|
845
|
-
} catch (err) {
|
|
846
|
-
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
847
|
-
}
|
|
848
|
-
let list = []
|
|
849
|
-
try {
|
|
850
|
-
list = await sftp.readdir(target)
|
|
851
|
-
} catch (err) {
|
|
852
|
-
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
853
|
-
}
|
|
854
|
-
const items = []
|
|
855
|
-
const symIdx = []
|
|
856
|
-
for (const e of list) {
|
|
857
|
-
const name = String(e.filename)
|
|
858
|
-
if (name === '.' || name === '..' || !name) continue
|
|
859
|
-
const a = e.attrs || {}
|
|
860
|
-
if (a.isSymbolicLink && a.isSymbolicLink()) {
|
|
861
|
-
items.push({ type: 'symlink', name })
|
|
862
|
-
symIdx.push(items.length - 1)
|
|
863
|
-
} else if (a.isDirectory && a.isDirectory()) {
|
|
864
|
-
items.push({ type: 'dir', name })
|
|
865
|
-
} else {
|
|
866
|
-
items.push({ type: 'file', name })
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
// Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
|
|
870
|
-
if (symIdx.length) {
|
|
871
|
-
await Promise.all(symIdx.map(async (i) => {
|
|
872
|
-
const full = joinRemotePath(target, items[i].name)
|
|
873
|
-
try {
|
|
874
|
-
const st = await sftp.lstat(full)
|
|
875
|
-
items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
|
|
876
|
-
} catch { /* degrade to file */ }
|
|
877
|
-
}))
|
|
878
|
-
}
|
|
879
|
-
return { path: target, items }
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/** Verify a remote path is an existing directory, via SFTP stat (works on
|
|
883
|
-
* any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
|
|
884
|
-
* path is missing, is a file, or the stat errors. */
|
|
885
|
-
const isRemoteDir = async (p) => {
|
|
886
|
-
const target = normalizeRemotePath(p)
|
|
887
|
-
try {
|
|
888
|
-
const sftp = await pool.sftp()
|
|
889
|
-
const st = await sftp.stat(target)
|
|
890
|
-
return !!(st && st.isDirectory && st.isDirectory())
|
|
891
|
-
} catch {
|
|
892
|
-
return false
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// ── remote workspace state ────────────────────────────────────────────────
|
|
897
|
-
|
|
898
|
-
const wsPath = () => (config.workspace || '').trim()
|
|
899
|
-
const status = () => ({
|
|
900
|
-
host: config.host,
|
|
901
|
-
port: config.port,
|
|
902
|
-
username: config.username,
|
|
903
|
-
connected: !!pool.client,
|
|
904
|
-
workspace: wsPath(),
|
|
905
|
-
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
906
|
-
currentId: store.currentId || null,
|
|
907
|
-
machines: machines.map(sanitizeMachine),
|
|
908
|
-
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
909
|
-
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
910
|
-
})
|
|
911
|
-
|
|
912
|
-
// ── tools ─────────────────────────────────────────────────────────────────
|
|
913
|
-
|
|
914
|
-
const renderErr = (err) => ({
|
|
915
|
-
kind: 'error',
|
|
916
|
-
text: String((err && err.message) || err),
|
|
917
|
-
})
|
|
918
|
-
|
|
919
|
-
const tools = [
|
|
920
|
-
defineTool({
|
|
921
|
-
name: 'rw_info',
|
|
922
|
-
description:
|
|
923
|
-
'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.',
|
|
924
|
-
parameters: {},
|
|
925
|
-
output: {
|
|
926
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
927
|
-
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
928
|
-
},
|
|
929
|
-
async execute() {
|
|
930
|
-
const s = status()
|
|
931
|
-
const lines = [
|
|
932
|
-
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
933
|
-
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
934
|
-
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
935
|
-
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
936
|
-
'',
|
|
937
|
-
]
|
|
938
|
-
if (s.host && s.workspace) {
|
|
939
|
-
try {
|
|
940
|
-
// `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
|
|
941
|
-
const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
942
|
-
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
943
|
-
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
944
|
-
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
945
|
-
} catch (err) {
|
|
946
|
-
lines.push('Ping: FAILED — ' + ((err && err.message) || err))
|
|
947
|
-
}
|
|
948
|
-
} else {
|
|
949
|
-
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
950
|
-
}
|
|
951
|
-
return { text: lines.join('\n') }
|
|
952
|
-
},
|
|
953
|
-
}),
|
|
954
|
-
|
|
955
|
-
defineTool({
|
|
956
|
-
name: 'rw_connect',
|
|
957
|
-
description:
|
|
958
|
-
'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.',
|
|
959
|
-
parameters: {
|
|
960
|
-
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
961
|
-
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
962
|
-
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
963
|
-
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
964
|
-
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
965
|
-
},
|
|
966
|
-
output: {
|
|
967
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
968
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
969
|
-
},
|
|
970
|
-
async execute(args) {
|
|
971
|
-
const host = String(args.host || '').trim()
|
|
972
|
-
if (!host) throw new Error('rw_connect: host is required')
|
|
973
|
-
pool.setTarget({
|
|
974
|
-
host,
|
|
975
|
-
username: args.username || config.username || 'root',
|
|
976
|
-
port: args.port || undefined,
|
|
977
|
-
password: args.password !== undefined ? args.password : undefined,
|
|
978
|
-
privateKeyPath: args.privateKeyPath || undefined,
|
|
979
|
-
})
|
|
980
|
-
try {
|
|
981
|
-
const res = await pool.exec('echo ok', 8000)
|
|
982
|
-
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
983
|
-
return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
984
|
-
} catch (err) {
|
|
985
|
-
throw err
|
|
986
|
-
}
|
|
987
|
-
},
|
|
988
|
-
}),
|
|
989
|
-
|
|
990
|
-
defineTool({
|
|
991
|
-
name: 'rw_pick_workspace',
|
|
992
|
-
description:
|
|
993
|
-
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
|
|
994
|
-
parameters: {
|
|
995
|
-
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
996
|
-
},
|
|
997
|
-
output: {
|
|
998
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
999
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1000
|
-
},
|
|
1001
|
-
async execute(args) {
|
|
1002
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
1003
|
-
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
1004
|
-
const ok = await isRemoteDir(p)
|
|
1005
|
-
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
1006
|
-
persistWorkspace(p)
|
|
1007
|
-
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1008
|
-
return {
|
|
1009
|
-
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.`,
|
|
1010
|
-
}
|
|
1011
|
-
},
|
|
1012
|
-
}),
|
|
1013
|
-
|
|
1014
|
-
defineTool({
|
|
1015
|
-
name: 'rw_sync',
|
|
1016
|
-
description:
|
|
1017
|
-
'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.',
|
|
1018
|
-
parameters: {
|
|
1019
|
-
depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
|
|
1020
|
-
maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
|
|
1021
|
-
},
|
|
1022
|
-
output: {
|
|
1023
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1024
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1025
|
-
},
|
|
1026
|
-
async execute(args) {
|
|
1027
|
-
const p = wsPath()
|
|
1028
|
-
if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
|
|
1029
|
-
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
1030
|
-
mkdirSync(local, { recursive: true })
|
|
1031
|
-
const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
|
|
1032
|
-
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1033
|
-
let sftp
|
|
1034
|
-
try {
|
|
1035
|
-
sftp = await pool.sftp()
|
|
1036
|
-
} catch (err) {
|
|
1037
|
-
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
1038
|
-
}
|
|
1039
|
-
const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
|
|
1040
|
-
let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
|
|
1041
|
-
if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
|
|
1042
|
-
if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1043
|
-
return { text }
|
|
1044
|
-
},
|
|
1045
|
-
}),
|
|
1046
|
-
|
|
1047
|
-
defineTool({
|
|
1048
|
-
name: 'rw_push',
|
|
1049
|
-
description:
|
|
1050
|
-
'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).',
|
|
1051
|
-
parameters: {
|
|
1052
|
-
maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
|
|
1053
|
-
},
|
|
1054
|
-
output: {
|
|
1055
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1056
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1057
|
-
},
|
|
1058
|
-
async execute(args) {
|
|
1059
|
-
const p = wsPath()
|
|
1060
|
-
if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
|
|
1061
|
-
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
1062
|
-
if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
|
|
1063
|
-
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1064
|
-
let sftp
|
|
1065
|
-
try {
|
|
1066
|
-
sftp = await pool.sftp()
|
|
1067
|
-
} catch (err) {
|
|
1068
|
-
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
1069
|
-
}
|
|
1070
|
-
const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
|
|
1071
|
-
let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
|
|
1072
|
-
if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
|
|
1073
|
-
if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1074
|
-
return { text }
|
|
1075
|
-
},
|
|
1076
|
-
}),
|
|
1077
|
-
|
|
1078
|
-
defineTool({
|
|
1079
|
-
name: 'rw_list_dir',
|
|
1080
|
-
description:
|
|
1081
|
-
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
1082
|
-
parameters: {
|
|
1083
|
-
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1084
|
-
},
|
|
1085
|
-
output: {
|
|
1086
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1087
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1088
|
-
},
|
|
1089
|
-
async execute(args) {
|
|
1090
|
-
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
1091
|
-
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
1092
|
-
// SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
|
|
1093
|
-
// PowerShell) unlike `ls -la`.
|
|
1094
|
-
let list
|
|
1095
|
-
try {
|
|
1096
|
-
const sftp = await pool.sftp()
|
|
1097
|
-
list = await sftp.readdir(p)
|
|
1098
|
-
} catch (err) {
|
|
1099
|
-
throw new Error('rw_list_dir: ' + ((err && err.message) || err))
|
|
1100
|
-
}
|
|
1101
|
-
const lines = list
|
|
1102
|
-
.filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
|
|
1103
|
-
.map((e) => {
|
|
1104
|
-
const a = e.attrs || {}
|
|
1105
|
-
const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
|
|
1106
|
-
const size = typeof a.size === 'number' ? String(a.size) : '?'
|
|
1107
|
-
return `${type} ${size.padStart(10)} ${String(e.filename)}`
|
|
1108
|
-
})
|
|
1109
|
-
return { text: lines.length ? lines.join('\n') : '(empty directory)' }
|
|
1110
|
-
},
|
|
1111
|
-
}),
|
|
1112
|
-
|
|
1113
|
-
defineTool({
|
|
1114
|
-
name: 'rw_read_file',
|
|
1115
|
-
description:
|
|
1116
|
-
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
|
|
1117
|
-
parameters: {
|
|
1118
|
-
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1119
|
-
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
1120
|
-
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
1121
|
-
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
1122
|
-
},
|
|
1123
|
-
output: {
|
|
1124
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1125
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1126
|
-
},
|
|
1127
|
-
async execute(args) {
|
|
1128
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
1129
|
-
if (!p) throw new Error('rw_read_file: path is required')
|
|
1130
|
-
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
1131
|
-
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
1132
|
-
let to = Number(args.endLine) || 0
|
|
1133
|
-
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
1134
|
-
// SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
|
|
1135
|
-
// / PowerShell, no POSIX sed) work too.
|
|
1136
|
-
let buf
|
|
1137
|
-
try {
|
|
1138
|
-
const sftp = await pool.sftp()
|
|
1139
|
-
buf = await sftp.readFile(p)
|
|
1140
|
-
} catch (err) {
|
|
1141
|
-
throw new Error('rw_read_file: ' + ((err && err.message) || err))
|
|
1142
|
-
}
|
|
1143
|
-
const content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
1144
|
-
const allLines = content.split('\n')
|
|
1145
|
-
const page = allLines.slice(from - 1, to)
|
|
1146
|
-
const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
1147
|
-
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
1148
|
-
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
1149
|
-
return { text }
|
|
1150
|
-
},
|
|
1151
|
-
}),
|
|
1152
|
-
|
|
1153
|
-
defineTool({
|
|
1154
|
-
name: 'rw_exec',
|
|
1155
|
-
description:
|
|
1156
|
-
'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.',
|
|
1157
|
-
parameters: {
|
|
1158
|
-
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1159
|
-
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
1160
|
-
},
|
|
1161
|
-
output: {
|
|
1162
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1163
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1164
|
-
},
|
|
1165
|
-
async execute(args) {
|
|
1166
|
-
const cmd = String(args.command || '')
|
|
1167
|
-
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1168
|
-
const ws = wsPath()
|
|
1169
|
-
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1170
|
-
// Windows remotes: no POSIX cd/single-quotes — let the command run as-is
|
|
1171
|
-
// (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
|
|
1172
|
-
const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
|
|
1173
|
-
return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
|
|
1174
|
-
},
|
|
1175
|
-
}),
|
|
1176
|
-
|
|
1177
|
-
defineTool({
|
|
1178
|
-
name: 'rw_write_file',
|
|
1179
|
-
description:
|
|
1180
|
-
'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.',
|
|
1181
|
-
parameters: {
|
|
1182
|
-
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1183
|
-
content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
|
|
1184
|
-
mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
|
|
1185
|
-
},
|
|
1186
|
-
output: {
|
|
1187
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1188
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
|
|
1189
|
-
},
|
|
1190
|
-
async execute(args) {
|
|
1191
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
1192
|
-
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
1193
|
-
const content = String(args.content == null ? '' : args.content)
|
|
1194
|
-
let sftp
|
|
1195
|
-
try {
|
|
1196
|
-
sftp = await pool.sftp()
|
|
1197
|
-
} catch (err) {
|
|
1198
|
-
throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
|
|
1199
|
-
}
|
|
1200
|
-
const mkdir = args.mkdir !== false
|
|
1201
|
-
if (mkdir) {
|
|
1202
|
-
// create each level from the root so mkdir -p semantics survive even
|
|
1203
|
-
// when intermediate privileged-parent dirs don't allow create (best effort)
|
|
1204
|
-
await mkdirRemoteDirs(sftp, p)
|
|
1205
|
-
}
|
|
1206
|
-
const buf = Buffer.from(content, 'utf8')
|
|
1207
|
-
await sftp.writeFile(p, buf)
|
|
1208
|
-
const bytes = Buffer.byteLength(content, 'utf8')
|
|
1209
|
-
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
1210
|
-
},
|
|
1211
|
-
}),
|
|
1212
|
-
|
|
1213
|
-
defineTool({
|
|
1214
|
-
name: 'rw_search',
|
|
1215
|
-
description:
|
|
1216
|
-
'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.',
|
|
1217
|
-
parameters: {
|
|
1218
|
-
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1219
|
-
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
1220
|
-
glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
|
|
1221
|
-
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1222
|
-
},
|
|
1223
|
-
output: {
|
|
1224
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1225
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1226
|
-
},
|
|
1227
|
-
async execute(args) {
|
|
1228
|
-
const pattern = String(args.pattern || '')
|
|
1229
|
-
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1230
|
-
const ws = wsPath()
|
|
1231
|
-
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
1232
|
-
if (!dir) throw new Error('rw_search: no path and no remote workspace set')
|
|
1233
|
-
if (dir.includes('\\')) {
|
|
1234
|
-
// Windows remotes have no POSIX find/grep; the search would need a
|
|
1235
|
-
// recursive SFTP walk. Give an actionable hint instead of a broken cmd.
|
|
1236
|
-
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}'"` }
|
|
1237
|
-
}
|
|
1238
|
-
const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
|
|
1239
|
-
const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
|
|
1240
|
-
// find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
|
|
1241
|
-
// differ on BSD, and GNU-only --include is avoided in favor of find -name.
|
|
1242
|
-
const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
|
|
1243
|
-
return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
|
|
1244
|
-
},
|
|
1245
|
-
}),
|
|
1246
|
-
|
|
1247
|
-
defineTool({
|
|
1248
|
-
name: 'rw_download',
|
|
1249
|
-
description:
|
|
1250
|
-
'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.',
|
|
1251
|
-
parameters: {
|
|
1252
|
-
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1253
|
-
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1254
|
-
},
|
|
1255
|
-
output: {
|
|
1256
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1257
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
|
|
1258
|
-
},
|
|
1259
|
-
async execute(args) {
|
|
1260
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
1261
|
-
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1262
|
-
let sftp
|
|
1263
|
-
try {
|
|
1264
|
-
sftp = await pool.sftp()
|
|
1265
|
-
} catch (err) {
|
|
1266
|
-
throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
|
|
1267
|
-
}
|
|
1268
|
-
let local
|
|
1269
|
-
if (args.localPath) {
|
|
1270
|
-
local = path.resolve(String(args.localPath))
|
|
1271
|
-
} else {
|
|
1272
|
-
const ws = wsPath()
|
|
1273
|
-
if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
|
|
1274
|
-
const base = mirrorDirFor(ws, config.host, config.username, config.port)
|
|
1275
|
-
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
1276
|
-
local = path.join(base, rel)
|
|
1277
|
-
}
|
|
1278
|
-
mkdirSync(path.dirname(local), { recursive: true })
|
|
1279
|
-
const buf = await sftp.readFile(p)
|
|
1280
|
-
writeFileSync(local, buf)
|
|
1281
|
-
const bytes = buf.byteLength
|
|
1282
|
-
return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
|
|
1283
|
-
},
|
|
1284
|
-
}),
|
|
1285
|
-
|
|
1286
|
-
defineTool({
|
|
1287
|
-
name: 'rw_upload',
|
|
1288
|
-
description:
|
|
1289
|
-
'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.',
|
|
1290
|
-
parameters: {
|
|
1291
|
-
localPath: { type: 'string', required: true, description: 'Absolute local file path' },
|
|
1292
|
-
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1293
|
-
},
|
|
1294
|
-
output: {
|
|
1295
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1296
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
|
|
1297
|
-
},
|
|
1298
|
-
async execute(args) {
|
|
1299
|
-
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1300
|
-
const lp = String(args.localPath || '')
|
|
1301
|
-
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1302
|
-
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1303
|
-
let sftp
|
|
1304
|
-
try {
|
|
1305
|
-
sftp = await pool.sftp()
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
|
|
1308
|
-
}
|
|
1309
|
-
await mkdirRemoteDirs(sftp, rp)
|
|
1310
|
-
const buf = readFileSync(lp)
|
|
1311
|
-
await sftp.writeFile(rp, buf)
|
|
1312
|
-
const bytes = buf.byteLength
|
|
1313
|
-
return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
|
|
1314
|
-
},
|
|
1315
|
-
}),
|
|
1316
|
-
|
|
1317
|
-
defineTool({
|
|
1318
|
-
name: 'rw_disconnect',
|
|
1319
|
-
description:
|
|
1320
|
-
'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
|
|
1321
|
-
parameters: {},
|
|
1322
|
-
output: {
|
|
1323
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
|
|
1324
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1325
|
-
},
|
|
1326
|
-
async execute() {
|
|
1327
|
-
pool.close()
|
|
1328
|
-
return { ok: true, text: 'disconnected' }
|
|
1329
|
-
},
|
|
1330
|
-
}),
|
|
1331
|
-
]
|
|
1332
|
-
|
|
1333
|
-
for (const t of tools) {
|
|
1334
|
-
ctx.tools.register(t)
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// ── system-prompt injection: the current remote workspace ─────────────────
|
|
1338
|
-
ctx.systemPrompt.section({
|
|
1339
|
-
name: 'dsh-remote',
|
|
1340
|
-
order: 88,
|
|
1341
|
-
text: () => {
|
|
1342
|
-
const w = wsPath()
|
|
1343
|
-
if (!w || !config.host) return ''
|
|
1344
|
-
return (
|
|
1345
|
-
'## Remote workspace\n' +
|
|
1346
|
-
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
1347
|
-
'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.'
|
|
1348
|
-
)
|
|
1349
|
-
},
|
|
1350
|
-
})
|
|
1351
|
-
|
|
1352
|
-
// ── slash command: /remote reports status + connection hints ──────────────
|
|
1353
|
-
const commands = ctx.get('commands')
|
|
1354
|
-
if (commands !== undefined) {
|
|
1355
|
-
commands.register({
|
|
1356
|
-
name: 'remote',
|
|
1357
|
-
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
1358
|
-
handler: (invocation) => {
|
|
1359
|
-
const s = status()
|
|
1360
|
-
return {
|
|
1361
|
-
kind: 'success',
|
|
1362
|
-
text:
|
|
1363
|
-
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
1364
|
-
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
1365
|
-
`Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
|
|
1366
|
-
(s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
|
|
1367
|
-
`\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
|
|
1368
|
-
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
1369
|
-
}
|
|
1370
|
-
},
|
|
1371
|
-
})
|
|
1372
|
-
commands.register({
|
|
1373
|
-
name: 'remote-forget-key',
|
|
1374
|
-
description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
|
|
1375
|
-
handler: () => {
|
|
1376
|
-
createHostKeyGuard(config).forgetHost()
|
|
1377
|
-
return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
|
|
1378
|
-
},
|
|
1379
|
-
})
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
1383
|
-
const webServer = ctx.get('webServer')
|
|
1384
|
-
if (webServer === undefined) return
|
|
1385
|
-
|
|
1386
|
-
const sendJson = (res, status, body) => {
|
|
1387
|
-
res.statusCode = status
|
|
1388
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
1389
|
-
res.end(JSON.stringify(body))
|
|
1390
|
-
}
|
|
1391
|
-
// Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
|
|
1392
|
-
// unbounded memory on a local-only route.
|
|
1393
|
-
const MAX_BODY_BYTES = 1024 * 1024
|
|
1394
|
-
const readBody = (req) =>
|
|
1395
|
-
new Promise((resolve) => {
|
|
1396
|
-
const chunks = []
|
|
1397
|
-
let total = 0
|
|
1398
|
-
req.on('data', (c) => {
|
|
1399
|
-
total += c.length
|
|
1400
|
-
if (total > MAX_BODY_BYTES) {
|
|
1401
|
-
req.removeAllListeners('data')
|
|
1402
|
-
resolve('{}')
|
|
1403
|
-
return
|
|
1404
|
-
}
|
|
1405
|
-
chunks.push(c)
|
|
1406
|
-
})
|
|
1407
|
-
req.on('end', () => resolve(chunks.join('')))
|
|
1408
|
-
})
|
|
1409
|
-
|
|
1410
|
-
// ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
|
|
1411
|
-
// 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
|
|
1412
|
-
// loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
|
|
1413
|
-
// 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
|
|
1414
|
-
// native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
|
|
1415
|
-
const PICK_TIMEOUT_MS = 120000
|
|
1416
|
-
const runPick = (bin, args) =>
|
|
1417
|
-
new Promise((resolve, reject) => {
|
|
1418
|
-
execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
1419
|
-
if (err) {
|
|
1420
|
-
const code = err.code
|
|
1421
|
-
const msg = String(stderr || '')
|
|
1422
|
-
// 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
|
|
1423
|
-
if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
|
|
1424
|
-
if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
|
|
1425
|
-
if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
|
|
1426
|
-
return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
|
|
1427
|
-
}
|
|
1428
|
-
const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
|
|
1429
|
-
resolve(p ? { path: p } : { cancelled: true })
|
|
1430
|
-
})
|
|
1431
|
-
})
|
|
1432
|
-
const pickLocalNative = async () => {
|
|
1433
|
-
const platform = process.platform
|
|
1434
|
-
if (platform === 'darwin') {
|
|
1435
|
-
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
1436
|
-
}
|
|
1437
|
-
if (platform === 'linux') {
|
|
1438
|
-
try {
|
|
1439
|
-
return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
|
|
1440
|
-
} catch (err) {
|
|
1441
|
-
if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
|
|
1442
|
-
throw err
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
const routes = [
|
|
1449
|
-
{
|
|
1450
|
-
kind: 'exact',
|
|
1451
|
-
path: '/dsh-remote/status',
|
|
1452
|
-
handler: async (req, res) => {
|
|
1453
|
-
if (req.method === 'GET') return sendJson(res, 200, status())
|
|
1454
|
-
sendJson(res, 405, { error: 'method not allowed' })
|
|
1455
|
-
},
|
|
1456
|
-
},
|
|
1457
|
-
{
|
|
1458
|
-
kind: 'exact',
|
|
1459
|
-
path: '/dsh-remote/connect',
|
|
1460
|
-
handler: async (req, res) => {
|
|
1461
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1462
|
-
try {
|
|
1463
|
-
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1464
|
-
pool.setTarget({
|
|
1465
|
-
host: payload.host,
|
|
1466
|
-
port: payload.port,
|
|
1467
|
-
username: payload.username,
|
|
1468
|
-
password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
|
|
1469
|
-
privateKeyPath: payload.privateKeyPath,
|
|
1470
|
-
workspace: payload.workspace,
|
|
1471
|
-
})
|
|
1472
|
-
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
1473
|
-
return sendJson(res, 200, { ok: true, ...status() })
|
|
1474
|
-
} catch (err) {
|
|
1475
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1476
|
-
}
|
|
1477
|
-
},
|
|
1478
|
-
},
|
|
1479
|
-
{
|
|
1480
|
-
kind: 'exact',
|
|
1481
|
-
path: '/dsh-remote/ls',
|
|
1482
|
-
handler: async (req, res) => {
|
|
1483
|
-
try {
|
|
1484
|
-
// Proper URL parsing so `+` in a path decodes to a literal space
|
|
1485
|
-
// (the old regex missed it).
|
|
1486
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1487
|
-
const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
1488
|
-
const out = await listDirStructured(p || '/')
|
|
1489
|
-
return sendJson(res, 200, { path: p, items: out.items })
|
|
1490
|
-
} catch (err) {
|
|
1491
|
-
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1492
|
-
}
|
|
1493
|
-
},
|
|
1494
|
-
},
|
|
1495
|
-
{
|
|
1496
|
-
// Read a remote file over SFTP (live, not the local mirror) for the
|
|
1497
|
-
// sidebar's remote file viewer. Text files come back as UTF-8 content
|
|
1498
|
-
// (CRLF→LF), binary files are reported as binary with size+head bytes
|
|
1499
|
-
// (base64) so the client can sniff the format without loading the
|
|
1500
|
-
// whole file. `maxBytes` caps how much text is returned.
|
|
1501
|
-
kind: 'exact',
|
|
1502
|
-
path: '/dsh-remote/read',
|
|
1503
|
-
handler: async (req, res) => {
|
|
1504
|
-
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1505
|
-
try {
|
|
1506
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1507
|
-
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
1508
|
-
if (req.method === 'POST') {
|
|
1509
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1510
|
-
if (!p) p = String(body.path || '')
|
|
1511
|
-
}
|
|
1512
|
-
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
1513
|
-
const target = normalizeRemotePath(p)
|
|
1514
|
-
const maxBytes = Math.min(Math.max(Number(q.get('maxBytes')) || 256 * 1024, 1024), 2 * 1024 * 1024)
|
|
1515
|
-
let sftp
|
|
1516
|
-
try {
|
|
1517
|
-
sftp = await pool.sftp()
|
|
1518
|
-
} catch (err) {
|
|
1519
|
-
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
1520
|
-
}
|
|
1521
|
-
let buf
|
|
1522
|
-
try {
|
|
1523
|
-
buf = await sftp.readFile(target)
|
|
1524
|
-
} catch (err) {
|
|
1525
|
-
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
1526
|
-
}
|
|
1527
|
-
// Binary sniff: NUL byte in the first 8KB (or the whole file when
|
|
1528
|
-
// smaller) marks the file binary — matches the sidebar's convention.
|
|
1529
|
-
const headN = Math.min(buf.length, 8192)
|
|
1530
|
-
let binary = false
|
|
1531
|
-
for (let i = 0; i < headN; i++) {
|
|
1532
|
-
if (buf[i] === 0) { binary = true; break }
|
|
1533
|
-
}
|
|
1534
|
-
if (binary) {
|
|
1535
|
-
return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
|
|
1536
|
-
}
|
|
1537
|
-
let content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
1538
|
-
const truncated = content.length > maxBytes
|
|
1539
|
-
if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
|
|
1540
|
-
return sendJson(res, 200, { ok: true, binary: false, content, truncated })
|
|
1541
|
-
} catch (err) {
|
|
1542
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1543
|
-
}
|
|
1544
|
-
},
|
|
1545
|
-
},
|
|
1546
|
-
{
|
|
1547
|
-
kind: 'exact',
|
|
1548
|
-
path: '/dsh-remote/workspace',
|
|
1549
|
-
handler: async (req, res) => {
|
|
1550
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1551
|
-
try {
|
|
1552
|
-
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1553
|
-
const p = normalizeRemotePath(String(payload.path || ''))
|
|
1554
|
-
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
1555
|
-
const okDir = await isRemoteDir(p)
|
|
1556
|
-
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
1557
|
-
persistWorkspace(p)
|
|
1558
|
-
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1559
|
-
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
1560
|
-
} catch (err) {
|
|
1561
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1562
|
-
}
|
|
1563
|
-
},
|
|
1564
|
-
},
|
|
1565
|
-
{
|
|
1566
|
-
kind: 'exact',
|
|
1567
|
-
path: '/dsh-remote/mirror',
|
|
1568
|
-
handler: async (req, res) => {
|
|
1569
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1570
|
-
try {
|
|
1571
|
-
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1572
|
-
const p = normalizeRemotePath(String(payload.path || ''))
|
|
1573
|
-
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
1574
|
-
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
1575
|
-
// Always verify over SSH (connecting if needed) so an unconnected or
|
|
1576
|
-
// wrong host can never silently mint a mirror for a bogus path.
|
|
1577
|
-
const okDir = await isRemoteDir(p)
|
|
1578
|
-
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
1579
|
-
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1580
|
-
persistWorkspace(p)
|
|
1581
|
-
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
1582
|
-
} catch (err) {
|
|
1583
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1584
|
-
}
|
|
1585
|
-
},
|
|
1586
|
-
},
|
|
1587
|
-
{
|
|
1588
|
-
kind: 'exact',
|
|
1589
|
-
path: '/dsh-remote/local-pick',
|
|
1590
|
-
handler: async (req, res) => {
|
|
1591
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1592
|
-
try {
|
|
1593
|
-
// 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
|
|
1594
|
-
// ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
|
|
1595
|
-
// "cannot get property ... without inject"(该名字未在本插件
|
|
1596
|
-
// inject 里声明),ctx.get() 无此要求、缺失返回 null。
|
|
1597
|
-
// 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
|
|
1598
|
-
// 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
|
|
1599
|
-
let outcome = null
|
|
1600
|
-
let via = 'service'
|
|
1601
|
-
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1602
|
-
if (dp && typeof dp.capability === 'function') {
|
|
1603
|
-
try {
|
|
1604
|
-
const cap = await Promise.resolve(dp.capability())
|
|
1605
|
-
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
1606
|
-
const pickAbort = new AbortController()
|
|
1607
|
-
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
1608
|
-
pickAbort.abort()
|
|
1609
|
-
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
1610
|
-
}
|
|
1611
|
-
} catch (err) {
|
|
1612
|
-
outcome = null // DSH 服务出错 → 落到自持兜底
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
if (!outcome) {
|
|
1616
|
-
via = 'own'
|
|
1617
|
-
try {
|
|
1618
|
-
outcome = await pickLocalNative()
|
|
1619
|
-
} catch (err) {
|
|
1620
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
|
|
1624
|
-
return sendJson(res, 200, { ok: true, path: outcome.path, via })
|
|
1625
|
-
} catch (err) {
|
|
1626
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1627
|
-
}
|
|
1628
|
-
},
|
|
1629
|
-
},
|
|
1630
|
-
{
|
|
1631
|
-
kind: 'exact',
|
|
1632
|
-
path: '/dsh-remote/machines',
|
|
1633
|
-
handler: async (req, res) => {
|
|
1634
|
-
if (req.method === 'GET') {
|
|
1635
|
-
return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1636
|
-
}
|
|
1637
|
-
if (req.method === 'POST') {
|
|
1638
|
-
try {
|
|
1639
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1640
|
-
const action = body.action || 'add'
|
|
1641
|
-
if (action === 'add' || action === 'update') {
|
|
1642
|
-
const host = String(body.host || '').trim()
|
|
1643
|
-
if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
|
|
1644
|
-
const rec = {
|
|
1645
|
-
id: body.id || machineId(),
|
|
1646
|
-
name: String(body.name || '').trim() || host,
|
|
1647
|
-
host,
|
|
1648
|
-
port: Number(body.port) || 22,
|
|
1649
|
-
username: String(body.username || '').trim() || 'root',
|
|
1650
|
-
password: body.password || '',
|
|
1651
|
-
privateKeyPath: String(body.privateKeyPath || '').trim(),
|
|
1652
|
-
passphrase: body.passphrase || '',
|
|
1653
|
-
workspace: String(body.workspace || '').trim(),
|
|
1654
|
-
}
|
|
1655
|
-
const i = machineIndex(rec.id)
|
|
1656
|
-
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
1657
|
-
if (!store.currentId) { store.currentId = rec.id }
|
|
1658
|
-
saveMachines(machines, store.currentId)
|
|
1659
|
-
if (store.currentId === rec.id) applyMachine(config, rec)
|
|
1660
|
-
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1661
|
-
}
|
|
1662
|
-
if (action === 'delete') {
|
|
1663
|
-
const i = machineIndex(String(body.id || ''))
|
|
1664
|
-
if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1665
|
-
machines.splice(i, 1)
|
|
1666
|
-
if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
|
|
1667
|
-
saveMachines(machines, store.currentId)
|
|
1668
|
-
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1669
|
-
}
|
|
1670
|
-
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
1671
|
-
} catch (err) {
|
|
1672
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1676
|
-
},
|
|
1677
|
-
},
|
|
1678
|
-
{
|
|
1679
|
-
kind: 'exact',
|
|
1680
|
-
path: '/dsh-remote/test-connect',
|
|
1681
|
-
handler: async (req, res) => {
|
|
1682
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1683
|
-
try {
|
|
1684
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1685
|
-
const probe = new SshPool({
|
|
1686
|
-
...config,
|
|
1687
|
-
host: String(body.host || config.host),
|
|
1688
|
-
port: Number(body.port) || config.port,
|
|
1689
|
-
username: String(body.username || config.username),
|
|
1690
|
-
password: String(body.password || ''),
|
|
1691
|
-
privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
|
|
1692
|
-
passphrase: String(body.passphrase || ''),
|
|
1693
|
-
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
1694
|
-
commandTimeoutMs: 10000,
|
|
1695
|
-
})
|
|
1696
|
-
const started = Date.now()
|
|
1697
|
-
await probe.connect()
|
|
1698
|
-
await probe.exec('true', 10000)
|
|
1699
|
-
probe.close()
|
|
1700
|
-
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
|
|
1701
|
-
} catch (err) {
|
|
1702
|
-
return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
|
|
1703
|
-
}
|
|
1704
|
-
},
|
|
1705
|
-
},
|
|
1706
|
-
{
|
|
1707
|
-
kind: 'exact',
|
|
1708
|
-
path: '/dsh-remote/current',
|
|
1709
|
-
handler: async (req, res) => {
|
|
1710
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1711
|
-
try {
|
|
1712
|
-
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1713
|
-
const okSet = setCurrent(String(body.id || ''))
|
|
1714
|
-
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1715
|
-
return sendJson(res, 200, { ok: true, ...status() })
|
|
1716
|
-
} catch (err) {
|
|
1717
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1718
|
-
}
|
|
1719
|
-
},
|
|
1720
|
-
},
|
|
1721
|
-
{
|
|
1722
|
-
kind: 'exact',
|
|
1723
|
-
path: '/dsh-remote/forget-key',
|
|
1724
|
-
handler: async (req, res) => {
|
|
1725
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1726
|
-
// Drop the trusted host-key record for the CURRENT target so the next
|
|
1727
|
-
// connect re-records it (use after an intentional host reinstall or a
|
|
1728
|
-
// false MITM alarm).
|
|
1729
|
-
createHostKeyGuard(config).forgetHost()
|
|
1730
|
-
return sendJson(res, 200, { ok: true, ...status() })
|
|
1731
|
-
},
|
|
1732
|
-
},
|
|
1733
|
-
]
|
|
1734
|
-
|
|
1735
|
-
const disposers = routes.map((r) => webServer.register(r))
|
|
1736
|
-
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
1
|
+
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
|
+
//
|
|
3
|
+
// Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
|
|
4
|
+
// • one persistent SSH/SFTP pool per configured remote (password OR private key),
|
|
5
|
+
// • a "current remote workspace" — a remote directory the agent treats as the
|
|
6
|
+
// active project root (user@host:/path) — injected into every system prompt,
|
|
7
|
+
// • model tools `rw_info` / `rw_connect` / `rw_pick_workspace` /
|
|
8
|
+
// `rw_list_dir` / `rw_read_file` / `rw_exec`,
|
|
9
|
+
// • JSON endpoints the client settings page uses to connect → browse → select the
|
|
10
|
+
// remote workspace over the harness `webServer`.
|
|
11
|
+
//
|
|
12
|
+
// The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
|
|
13
|
+
// proven by dsh-remote-debug, extended with password auth and a mutable workspace:
|
|
14
|
+
// `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
|
|
15
|
+
// presented as such to the model and UI, not a replacement of the local one.
|
|
16
|
+
//
|
|
17
|
+
// Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import ssh2 from 'ssh2'
|
|
21
|
+
import { 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'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
const { Client } = ssh2
|
|
28
|
+
|
|
29
|
+
export const name = 'dsh-remote'
|
|
30
|
+
|
|
31
|
+
// tools + systemPrompt + webServer are required. webServer is INJECTED (not just
|
|
32
|
+
// lazily read) so this plugin activates only after the web server is up — otherwise
|
|
33
|
+
// apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
|
|
34
|
+
// (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
|
|
35
|
+
export const inject = ['tools', 'systemPrompt', 'webServer']
|
|
36
|
+
|
|
37
|
+
export const Config = z.object({
|
|
38
|
+
/** Remote SSH host (empty → the plugin starts disconnected). */
|
|
39
|
+
host: z.string().default(''),
|
|
40
|
+
/** Remote SSH port (22 unless the machine uses a custom port). */
|
|
41
|
+
port: z.number().step(1).min(1).max(65535).default(22),
|
|
42
|
+
/** SSH login user. */
|
|
43
|
+
username: z.string().default(''),
|
|
44
|
+
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
45
|
+
password: z.string().default(''),
|
|
46
|
+
/** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
|
|
47
|
+
privateKeyPath: z.string().default(''),
|
|
48
|
+
/** Key passphrase when the key is encrypted. */
|
|
49
|
+
passphrase: z.string().default(''),
|
|
50
|
+
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
51
|
+
workspace: z.string().default(''),
|
|
52
|
+
/** Per-command timeout. */
|
|
53
|
+
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
54
|
+
/** SSH connection establishment timeout. */
|
|
55
|
+
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
56
|
+
/** Hard ceiling on collected remote output per call. */
|
|
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'),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
68
|
+
|
|
69
|
+
/** Single-quote one shell argument verbatim. */
|
|
70
|
+
function shq(s) {
|
|
71
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a remote path into a clean absolute path, supporting BOTH POSIX
|
|
76
|
+
* (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
|
|
77
|
+
*
|
|
78
|
+
* - POSIX paths keep `/` separators and a leading `/`.
|
|
79
|
+
* - Windows drive paths keep their `X:` prefix and are returned with `\`
|
|
80
|
+
* separators (the native form on a Windows remote); a leading `/` is NOT
|
|
81
|
+
* added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
|
|
82
|
+
* - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
|
|
83
|
+
* - `.` / `..` segments are collapsed in both separator styles.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeRemotePath(p) {
|
|
86
|
+
const s = String(p)
|
|
87
|
+
// Windows UNC: \\server\share\…
|
|
88
|
+
const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
|
|
89
|
+
if (unc) {
|
|
90
|
+
const [prefix, rest] = [unc[1], unc[2]]
|
|
91
|
+
const parts = []
|
|
92
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
93
|
+
if (seg === '' || seg === '.') continue
|
|
94
|
+
if (seg === '..') { parts.pop(); continue }
|
|
95
|
+
parts.push(seg)
|
|
96
|
+
}
|
|
97
|
+
return parts.length ? prefix + '\\' + parts.join('\\') : prefix
|
|
98
|
+
}
|
|
99
|
+
// Windows drive: X:\… or X:/…
|
|
100
|
+
const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
|
|
101
|
+
if (drive) {
|
|
102
|
+
const [prefix, rest] = [drive[1], drive[2]]
|
|
103
|
+
const parts = []
|
|
104
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
105
|
+
if (seg === '' || seg === '.') continue
|
|
106
|
+
if (seg === '..') { parts.pop(); continue }
|
|
107
|
+
parts.push(seg)
|
|
108
|
+
}
|
|
109
|
+
return prefix + '\\' + parts.join('\\')
|
|
110
|
+
}
|
|
111
|
+
// POSIX: /a/b/c
|
|
112
|
+
const parts = []
|
|
113
|
+
for (const seg of s.split('/')) {
|
|
114
|
+
if (seg === '' || seg === '.') continue
|
|
115
|
+
if (seg === '..') {
|
|
116
|
+
parts.pop()
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
parts.push(seg)
|
|
120
|
+
}
|
|
121
|
+
return '/' + parts.join('/')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Join a remote dir + entry name, honoring the dir's own separator style. */
|
|
125
|
+
function joinRemotePath(dir, name) {
|
|
126
|
+
const d = String(dir)
|
|
127
|
+
if (!d) return String(name)
|
|
128
|
+
if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
|
|
129
|
+
return d + (d.includes('\\') ? '\\' : '/') + String(name)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Create every level of a remote dir via SFTP (mkdir -p semantics), working
|
|
133
|
+
* for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
|
|
134
|
+
* existing or permission-denied level is skipped. */
|
|
135
|
+
async function mkdirRemoteDirs(sftp, dir) {
|
|
136
|
+
const parent = remoteDirname(dir)
|
|
137
|
+
const isWin = parent.includes('\\')
|
|
138
|
+
// Split off a Windows drive prefix (D:) so it is not treated as a segment.
|
|
139
|
+
const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
|
|
140
|
+
const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
|
|
141
|
+
let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
|
|
142
|
+
// Drive root (D:\) itself, best effort (usually exists).
|
|
143
|
+
if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
|
|
144
|
+
for (const s of segs) {
|
|
145
|
+
if (cur) {
|
|
146
|
+
if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
|
|
147
|
+
cur += s
|
|
148
|
+
} else {
|
|
149
|
+
cur = (isWin ? s + ':' : '/' + s)
|
|
150
|
+
}
|
|
151
|
+
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
|
|
156
|
+
function remoteDirname(p) {
|
|
157
|
+
const norm = normalizeRemotePath(p)
|
|
158
|
+
// Windows drive/UNC root: D:\ or \\server\share
|
|
159
|
+
if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
|
|
160
|
+
// Ensure a drive root ends with a backslash: D: → D:\
|
|
161
|
+
const m = norm.match(/^([a-zA-Z]:)$/)
|
|
162
|
+
return m ? m[1] + '\\' : norm
|
|
163
|
+
}
|
|
164
|
+
const sep = norm.includes('\\') ? '\\' : '/'
|
|
165
|
+
const idx = norm.lastIndexOf(sep)
|
|
166
|
+
if (idx <= 0) {
|
|
167
|
+
// Windows drive with a child (D:\Code) → D:\
|
|
168
|
+
const m = norm.match(/^([a-zA-Z]:)\\/)
|
|
169
|
+
return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
|
|
170
|
+
}
|
|
171
|
+
// A parent that is a Windows drive root also needs the trailing backslash.
|
|
172
|
+
const parent = norm.slice(0, idx)
|
|
173
|
+
if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
|
|
174
|
+
return parent
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function truncate(s, max) {
|
|
178
|
+
if (s.length <= max) return s
|
|
179
|
+
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── local mirror of an OS remote workspace ────────────────────────────────
|
|
183
|
+
// Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
|
|
184
|
+
// so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
|
|
185
|
+
// workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
|
|
186
|
+
|
|
187
|
+
const remotePathBase = (p) => {
|
|
188
|
+
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
|
189
|
+
const base = norm.split(/[\\/]/).pop()
|
|
190
|
+
// Windows drive root (D:\) has no meaningful basename
|
|
191
|
+
if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
|
|
192
|
+
return base
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
|
|
197
|
+
* path format the SFTP server understands.
|
|
198
|
+
*
|
|
199
|
+
* Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
|
|
200
|
+
* written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
|
|
201
|
+
* UNC paths are kept as-is (the server resolves `\\server\share` itself).
|
|
202
|
+
*/
|
|
203
|
+
function toSftpPath(p) {
|
|
204
|
+
const norm = normalizeRemotePath(p)
|
|
205
|
+
const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
|
|
206
|
+
if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
|
|
207
|
+
return norm
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
|
|
211
|
+
* own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
|
|
212
|
+
* data under the same root the harness uses means uninstalling / upgrading the
|
|
213
|
+
* app no longer leaves state behind in the home directory. */
|
|
214
|
+
function dshBase() {
|
|
215
|
+
const env = process.env.DSH_HOME
|
|
216
|
+
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
217
|
+
return path.join(homedir(), '.dsh')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Root holding every remote host's mirrors + the machine registry. */
|
|
221
|
+
function remoteWorkspacesRoot() {
|
|
222
|
+
return path.join(dshBase(), 'remote-workspaces')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
|
|
226
|
+
function copyDirSync(from, to) {
|
|
227
|
+
mkdirSync(to, { recursive: true })
|
|
228
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
229
|
+
const s = path.join(from, e.name)
|
|
230
|
+
const d = path.join(to, e.name)
|
|
231
|
+
if (e.isDirectory()) copyDirSync(s, d)
|
|
232
|
+
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
|
|
237
|
+
* regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
|
|
238
|
+
* desktop install keeps its existing machines and mirrors. Idempotent; a
|
|
239
|
+
* rename across devices falls back to a copy. */
|
|
240
|
+
function migrateLegacyData() {
|
|
241
|
+
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
242
|
+
const target = remoteWorkspacesRoot()
|
|
243
|
+
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
244
|
+
try {
|
|
245
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
246
|
+
try {
|
|
247
|
+
renameSync(legacy, target)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err.code !== 'EXDEV') throw err
|
|
250
|
+
copyDirSync(legacy, target)
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Migration is best-effort: a fresh registry is created on next save.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
258
|
+
function shortHash(s) {
|
|
259
|
+
let h = 0
|
|
260
|
+
for (let i = 0; i < s.length; i++) {
|
|
261
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
262
|
+
}
|
|
263
|
+
return (h >>> 0).toString(36)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Stable local root for one remote host's mirrors */
|
|
267
|
+
function mirrorRootFor(host, user, port) {
|
|
268
|
+
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
269
|
+
return path.join(remoteWorkspacesRoot(), tag)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
273
|
+
* Named after the remote directory's basename so the harness workspace label
|
|
274
|
+
* reads cleanly (e.g. .../project). When the plain basename dir is already
|
|
275
|
+
* taken by a DIFFERENT remote path on the same host, a short path-hash suffix
|
|
276
|
+
* is appended so mirrors never collide. */
|
|
277
|
+
function mirrorDirFor(remotePath, host, user, port) {
|
|
278
|
+
const base = remotePathBase(remotePath)
|
|
279
|
+
const root = mirrorRootFor(host, user, port)
|
|
280
|
+
const plain = path.join(root, base)
|
|
281
|
+
const norm = normalizeRemotePath(remotePath)
|
|
282
|
+
// A pre-existing mirror for this exact remote origin → reuse it (idempotent).
|
|
283
|
+
try {
|
|
284
|
+
const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
|
|
285
|
+
if (meta.remotePath === norm) return plain
|
|
286
|
+
} catch {
|
|
287
|
+
/* no mirror yet → fall through */
|
|
288
|
+
}
|
|
289
|
+
// Plain dir is free → first mirror for this basename keeps the clean name.
|
|
290
|
+
// It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
|
|
291
|
+
if (!existsSync(plain)) return plain
|
|
292
|
+
return path.join(root, base + '-' + shortHash(norm))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
296
|
+
function ensureMirror(remotePath, host, user, port) {
|
|
297
|
+
const dir = mirrorDirFor(remotePath, host, user, port)
|
|
298
|
+
mkdirSync(dir, { recursive: true })
|
|
299
|
+
writeFileSync(
|
|
300
|
+
path.join(dir, '.dsh-remote-meta.json'),
|
|
301
|
+
JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
|
|
302
|
+
)
|
|
303
|
+
return dir
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
|
|
307
|
+
async function mapLimit(items, limit, fn) {
|
|
308
|
+
const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
|
|
309
|
+
let next = 0
|
|
310
|
+
const worker = async () => {
|
|
311
|
+
while (next < items.length) {
|
|
312
|
+
const i = next++
|
|
313
|
+
try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const workers = []
|
|
317
|
+
for (let w = 0; w < limitN; w++) workers.push(worker())
|
|
318
|
+
await Promise.all(workers)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Recursively sync remote → local mirror. Bounded by depth/files/size;
|
|
322
|
+
* skips files whose size+mtime already match locally. Returns counters. */
|
|
323
|
+
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
|
|
324
|
+
const entries = await sftp.readdir(remoteDir).then(
|
|
325
|
+
(list) => list,
|
|
326
|
+
() => [],
|
|
327
|
+
)
|
|
328
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
|
|
329
|
+
|
|
330
|
+
// Directories first (sequential, depth-bounded) so every file has a home.
|
|
331
|
+
for (const e of entries) {
|
|
332
|
+
const name = String(e.filename)
|
|
333
|
+
if (name === '.' || name === '..') continue
|
|
334
|
+
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
335
|
+
if (!isDir) continue
|
|
336
|
+
if (maxDepth <= 0 || stats.files >= maxFiles) continue
|
|
337
|
+
const rp = joinRemotePath(remoteDir, name)
|
|
338
|
+
const lp = path.join(localDir, name)
|
|
339
|
+
mkdirSync(lp, { recursive: true })
|
|
340
|
+
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
|
|
341
|
+
stats.dirs += sub.dirs + 1
|
|
342
|
+
stats.files += sub.files
|
|
343
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
344
|
+
stats.skippedLarge += sub.skippedLarge
|
|
345
|
+
stats.touched.push(...sub.touched)
|
|
346
|
+
if (stats.files >= maxFiles) break
|
|
347
|
+
}
|
|
348
|
+
if (stats.files >= maxFiles) return stats
|
|
349
|
+
|
|
350
|
+
// Files in one bounded-parallel sweep per directory level.
|
|
351
|
+
const fileEntries = entries.filter(
|
|
352
|
+
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
353
|
+
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
354
|
+
)
|
|
355
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
356
|
+
if (stats.files >= maxFiles) return
|
|
357
|
+
const name = String(e.filename)
|
|
358
|
+
const rp = joinRemotePath(remoteDir, name)
|
|
359
|
+
const lp = path.join(localDir, name)
|
|
360
|
+
try {
|
|
361
|
+
const st = await sftp.stat(rp)
|
|
362
|
+
if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
363
|
+
const lpStat = existsSync(lp) ? statSync(lp) : null
|
|
364
|
+
const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
|
|
365
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
366
|
+
const buf = await sftp.readFile(rp)
|
|
367
|
+
writeFileSync(lp, buf)
|
|
368
|
+
try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
|
|
369
|
+
stats.files++
|
|
370
|
+
stats.touched.push(rp)
|
|
371
|
+
} catch {
|
|
372
|
+
/* skip unreadable */
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
return stats
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
|
|
379
|
+
* skips files whose remote copy already matches (same size, remote no older). */
|
|
380
|
+
async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
|
|
381
|
+
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
382
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
|
|
383
|
+
|
|
384
|
+
for (const e of entries) {
|
|
385
|
+
if (!e.isDirectory()) continue
|
|
386
|
+
if (stats.files >= maxFiles) break
|
|
387
|
+
const rp = joinRemotePath(remoteDir, e.name)
|
|
388
|
+
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
389
|
+
stats.dirs++
|
|
390
|
+
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
|
|
391
|
+
stats.files += sub.files
|
|
392
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
393
|
+
stats.skippedLarge += sub.skippedLarge
|
|
394
|
+
stats.pushed.push(...sub.pushed)
|
|
395
|
+
if (stats.files >= maxFiles) break
|
|
396
|
+
}
|
|
397
|
+
if (stats.files >= maxFiles) return stats
|
|
398
|
+
|
|
399
|
+
const fileEntries = entries.filter((e) => !e.isDirectory())
|
|
400
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
401
|
+
if (stats.files >= maxFiles) return
|
|
402
|
+
const lp = path.join(localDir, e.name)
|
|
403
|
+
const rp = joinRemotePath(remoteDir, e.name)
|
|
404
|
+
try {
|
|
405
|
+
const lstat = statSync(lp)
|
|
406
|
+
if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
407
|
+
let rstat = null
|
|
408
|
+
try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
|
|
409
|
+
const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
|
|
410
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
411
|
+
const buf = readFileSync(lp)
|
|
412
|
+
await sftp.writeFile(rp, buf)
|
|
413
|
+
stats.files++
|
|
414
|
+
stats.pushed.push(rp)
|
|
415
|
+
} catch {
|
|
416
|
+
/* skip unreadable / unwritable */
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
return stats
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
423
|
+
const MACHINES_FILE = 'machines.json'
|
|
424
|
+
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
425
|
+
function loadMachines() {
|
|
426
|
+
try {
|
|
427
|
+
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
428
|
+
if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
|
|
429
|
+
} catch {}
|
|
430
|
+
return { list: [], currentId: null }
|
|
431
|
+
}
|
|
432
|
+
function saveMachines(list, currentId) {
|
|
433
|
+
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
434
|
+
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
435
|
+
}
|
|
436
|
+
function sanitizeMachine(m) {
|
|
437
|
+
if (!m) return m
|
|
438
|
+
const { password, ...rest } = m
|
|
439
|
+
return { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
440
|
+
}
|
|
441
|
+
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
442
|
+
function applyMachine(config, m) {
|
|
443
|
+
if (!m) return
|
|
444
|
+
config.host = m.host
|
|
445
|
+
config.port = Number(m.port) || 22
|
|
446
|
+
config.username = m.username || ''
|
|
447
|
+
config.password = m.password || ''
|
|
448
|
+
config.privateKeyPath = m.privateKeyPath || ''
|
|
449
|
+
config.passphrase = m.passphrase || ''
|
|
450
|
+
config.workspace = m.workspace || (config.workspace || '')
|
|
451
|
+
}
|
|
452
|
+
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
453
|
+
|
|
454
|
+
// ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
|
|
455
|
+
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
456
|
+
const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
|
|
457
|
+
|
|
458
|
+
/** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
|
|
459
|
+
function loadKnownHosts() {
|
|
460
|
+
try {
|
|
461
|
+
const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
|
|
462
|
+
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
463
|
+
} catch {}
|
|
464
|
+
return {}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Extract the host-key algorithm name from a raw SSH host-key blob
|
|
468
|
+
* (SSH wire format: `uint32 len` + algorithm string + key data). */
|
|
469
|
+
function blobAlgorithm(blob) {
|
|
470
|
+
if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
|
|
471
|
+
try {
|
|
472
|
+
const len = blob.readUInt32BE(0)
|
|
473
|
+
return blob.toString('utf8', 4, 4 + len)
|
|
474
|
+
} catch {
|
|
475
|
+
return ''
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
|
|
480
|
+
* in a known_hosts file (without the `SHA256:` prefix).
|
|
481
|
+
*
|
|
482
|
+
* ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
|
|
483
|
+
* wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
|
|
484
|
+
* object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
|
|
485
|
+
* argument must be of type string or an instance of Buffer, TypedArray, or
|
|
486
|
+
* DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
|
|
487
|
+
* v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
|
|
488
|
+
* shapes defensively. */
|
|
489
|
+
function keyFingerprint(key) {
|
|
490
|
+
const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
|
|
491
|
+
if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
|
|
492
|
+
return createHash('sha256').update(blob).digest('base64')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Build an ssh2 `hostVerifier` bound to the current config. Returns
|
|
496
|
+
* `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
|
|
497
|
+
* - `off` → always accepts;
|
|
498
|
+
* - `accept-new` → records a host's key on first connect, rejects any CHANGE
|
|
499
|
+
* afterwards (classic TOFU / MITM detection);
|
|
500
|
+
* - `verify` → same, plus rejects hosts never recorded before. */
|
|
501
|
+
function createHostKeyGuard(config) {
|
|
502
|
+
const id = () => `${config.host}:${config.port}`
|
|
503
|
+
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
|
|
504
|
+
? config.hostKeyMode
|
|
505
|
+
: 'accept-new'
|
|
506
|
+
const guard = {
|
|
507
|
+
mode,
|
|
508
|
+
lastError: null,
|
|
509
|
+
knownHosts: loadKnownHosts,
|
|
510
|
+
forgetHost() {
|
|
511
|
+
const kh = loadKnownHosts()
|
|
512
|
+
delete kh[id()]
|
|
513
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
514
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
515
|
+
},
|
|
516
|
+
verifier(key) {
|
|
517
|
+
if (mode === 'off') return true
|
|
518
|
+
const fp = keyFingerprint(key)
|
|
519
|
+
const kh = loadKnownHosts()
|
|
520
|
+
const stored = kh[id()]
|
|
521
|
+
if (stored) {
|
|
522
|
+
if (stored.fingerprint === fp) return true
|
|
523
|
+
guard.lastError =
|
|
524
|
+
`host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
|
|
525
|
+
'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
if (mode === 'verify') {
|
|
529
|
+
guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
|
|
533
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
534
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
535
|
+
return true
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
return guard
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether the current target's key has been recorded/trusted before. */
|
|
542
|
+
function isHostKeyKnown(host, port) {
|
|
543
|
+
return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
class SshPool {
|
|
549
|
+
constructor(config) {
|
|
550
|
+
this.config = config
|
|
551
|
+
this.client = null
|
|
552
|
+
this.connecting = null
|
|
553
|
+
// Generational token: bumped on every target change / close so a stale
|
|
554
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
555
|
+
this.epoch = 0
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
resolveKeyPath() {
|
|
559
|
+
const p = this.config.privateKeyPath
|
|
560
|
+
if (!p) return ''
|
|
561
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
562
|
+
return p
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
566
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
567
|
+
if (host !== undefined) this.config.host = String(host)
|
|
568
|
+
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
569
|
+
if (username !== undefined) this.config.username = String(username)
|
|
570
|
+
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
571
|
+
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
572
|
+
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
573
|
+
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
574
|
+
this.close()
|
|
575
|
+
return this
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
connect() {
|
|
579
|
+
if (this.client) return Promise.resolve(this.client)
|
|
580
|
+
if (this.connecting) return this.connecting
|
|
581
|
+
const epoch = this.epoch
|
|
582
|
+
const pending = this._doConnect(epoch)
|
|
583
|
+
this.connecting = pending
|
|
584
|
+
const clear = () => {
|
|
585
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
586
|
+
}
|
|
587
|
+
// Keep the chain clean in both directions: the raw pending is what callers
|
|
588
|
+
// await (they still see rejections); this side just clears the slot.
|
|
589
|
+
pending.then(clear, clear)
|
|
590
|
+
return pending
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
_doConnect(epoch) {
|
|
594
|
+
return new Promise((resolve, reject) => {
|
|
595
|
+
const client = new Client()
|
|
596
|
+
let settled = false
|
|
597
|
+
// Fresh host-key guard per connect attempt so lastError is never stale.
|
|
598
|
+
const guard = createHostKeyGuard(this.config)
|
|
599
|
+
const isCurrent = () => this.epoch === epoch
|
|
600
|
+
const fail = (err) => {
|
|
601
|
+
if (settled) return
|
|
602
|
+
settled = true
|
|
603
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
604
|
+
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
605
|
+
}
|
|
606
|
+
client.on('ready', () => {
|
|
607
|
+
if (settled) return
|
|
608
|
+
settled = true
|
|
609
|
+
if (!isCurrent()) {
|
|
610
|
+
// The target changed while we were connecting — never adopt this
|
|
611
|
+
// client, and make sure the caller learns the connect is void.
|
|
612
|
+
try { client.end() } catch {}
|
|
613
|
+
reject(new Error('ssh target changed during connect'))
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
this.client = client
|
|
617
|
+
resolve(client)
|
|
618
|
+
})
|
|
619
|
+
client.on('error', fail)
|
|
620
|
+
client.on('close', () => {
|
|
621
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
622
|
+
fail(new Error('ssh connection closed'))
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
const opts = {
|
|
626
|
+
host: this.config.host,
|
|
627
|
+
port: this.config.port,
|
|
628
|
+
username: this.config.username,
|
|
629
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
630
|
+
keepaliveInterval: 15000,
|
|
631
|
+
keepaliveCountMax: 3,
|
|
632
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
633
|
+
}
|
|
634
|
+
if (this.config.password) {
|
|
635
|
+
opts.password = this.config.password
|
|
636
|
+
} else {
|
|
637
|
+
const keyPath = this.resolveKeyPath()
|
|
638
|
+
if (!keyPath) {
|
|
639
|
+
return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
|
|
640
|
+
}
|
|
641
|
+
let key
|
|
642
|
+
try {
|
|
643
|
+
key = readFileSync(keyPath)
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return fail(new Error(`cannot read private key "${keyPath}": ${err && err.message}`))
|
|
646
|
+
}
|
|
647
|
+
opts.privateKey = key
|
|
648
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
649
|
+
}
|
|
650
|
+
client.connect(opts)
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }. */
|
|
655
|
+
exec(command, timeoutMs) {
|
|
656
|
+
return this.connect().then(
|
|
657
|
+
(client) =>
|
|
658
|
+
new Promise((resolve, reject) => {
|
|
659
|
+
client.exec(command, (err, stream) => {
|
|
660
|
+
if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
661
|
+
let stdout = ''
|
|
662
|
+
let stderr = ''
|
|
663
|
+
let settled = false
|
|
664
|
+
let exitCode = null
|
|
665
|
+
let exitSignal = null
|
|
666
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
667
|
+
const settle = () => {
|
|
668
|
+
if (settled) return
|
|
669
|
+
settled = true
|
|
670
|
+
clearTimeout(timer)
|
|
671
|
+
resolve({
|
|
672
|
+
code: exitCode,
|
|
673
|
+
signal: exitSignal,
|
|
674
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
675
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
const timer = setTimeout(() => {
|
|
679
|
+
if (settled) return
|
|
680
|
+
exitCode = -1
|
|
681
|
+
exitSignal = 'TIMEOUT'
|
|
682
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
683
|
+
// channel, so a runaway process cannot keep running and holding
|
|
684
|
+
// the SSH connection after we've given up on its output. The hard
|
|
685
|
+
// close below frees the channel even if the process ignores it.
|
|
686
|
+
try {
|
|
687
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
688
|
+
} catch {}
|
|
689
|
+
const hardClose = setTimeout(() => {
|
|
690
|
+
try { stream.close() } catch {}
|
|
691
|
+
}, 800)
|
|
692
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
693
|
+
settle()
|
|
694
|
+
}, timeoutMs || this.config.commandTimeoutMs)
|
|
695
|
+
stream.on('close', (code, signal) => {
|
|
696
|
+
if (settled) return
|
|
697
|
+
exitCode = code
|
|
698
|
+
exitSignal = signal
|
|
699
|
+
settle()
|
|
700
|
+
})
|
|
701
|
+
stream.on('data', (d) => {
|
|
702
|
+
if (stdout.length < hardCap) stdout += d
|
|
703
|
+
})
|
|
704
|
+
stream.stderr.on('data', (d) => {
|
|
705
|
+
if (stderr.length < hardCap) stderr += d
|
|
706
|
+
})
|
|
707
|
+
stream.on('error', (e) => {
|
|
708
|
+
if (settled) return
|
|
709
|
+
settled = true
|
|
710
|
+
clearTimeout(timer)
|
|
711
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
712
|
+
})
|
|
713
|
+
})
|
|
714
|
+
}),
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Resolve a promisified SFTP client for binary file transfers (sync).
|
|
719
|
+
* All path arguments are normalized via toSftpPath() so Windows remotes
|
|
720
|
+
* (drive-letter paths) work too. */
|
|
721
|
+
sftp() {
|
|
722
|
+
return this.connect().then(
|
|
723
|
+
(client) =>
|
|
724
|
+
new Promise((resolve, reject) => {
|
|
725
|
+
client.sftp((err, sftp) => {
|
|
726
|
+
if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
727
|
+
// Wrap a raw SFTP method so a stuck server can never hang a tool call.
|
|
728
|
+
const withTimeout = (fn) => (...args) =>
|
|
729
|
+
new Promise((r2, j2) => {
|
|
730
|
+
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
731
|
+
const done = (e, v) => {
|
|
732
|
+
clearTimeout(timer)
|
|
733
|
+
e ? j2(e) : r2(v)
|
|
734
|
+
}
|
|
735
|
+
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
736
|
+
})
|
|
737
|
+
const P = (p) => toSftpPath(p)
|
|
738
|
+
resolve({
|
|
739
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
740
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
741
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
742
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
743
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
744
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
745
|
+
})
|
|
746
|
+
})
|
|
747
|
+
}),
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
close() {
|
|
752
|
+
// Bump the epoch so any in-flight connect is orphaned, drop the pending
|
|
753
|
+
// promise (its rejection becomes nobody's problem — swallow it), and end
|
|
754
|
+
// the live client if there is one.
|
|
755
|
+
this.epoch++
|
|
756
|
+
const client = this.client
|
|
757
|
+
this.client = null
|
|
758
|
+
const pending = this.connecting
|
|
759
|
+
this.connecting = null
|
|
760
|
+
if (pending && typeof pending.catch === 'function') {
|
|
761
|
+
try { pending.catch(() => {}) } catch {}
|
|
762
|
+
}
|
|
763
|
+
if (client) {
|
|
764
|
+
try {
|
|
765
|
+
client.end()
|
|
766
|
+
} catch {}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── helper: run + text ─────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
export async function apply(ctx, config) {
|
|
774
|
+
const pool = new SshPool(config)
|
|
775
|
+
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
776
|
+
|
|
777
|
+
// Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
|
|
778
|
+
// location so existing machines + mirrors keep working after the move.
|
|
779
|
+
migrateLegacyData()
|
|
780
|
+
|
|
781
|
+
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
782
|
+
const store = loadMachines()
|
|
783
|
+
const machines = store.list
|
|
784
|
+
const machineIndex = (id) => machines.findIndex((m) => m.id === id)
|
|
785
|
+
const currentMachine = () => {
|
|
786
|
+
if (store.currentId) {
|
|
787
|
+
const i = machineIndex(store.currentId)
|
|
788
|
+
if (i >= 0) return machines[i]
|
|
789
|
+
}
|
|
790
|
+
// fall back to config-derived default (host set via cordis config)
|
|
791
|
+
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 }
|
|
792
|
+
return null
|
|
793
|
+
}
|
|
794
|
+
const setCurrent = (id) => {
|
|
795
|
+
const i = machineIndex(id)
|
|
796
|
+
if (i < 0) return false
|
|
797
|
+
store.currentId = id
|
|
798
|
+
saveMachines(machines, id)
|
|
799
|
+
applyMachine(config, machines[i])
|
|
800
|
+
pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
|
|
801
|
+
return true
|
|
802
|
+
}
|
|
803
|
+
// If no stored current, adopt a CLI-provided default as the active machine.
|
|
804
|
+
{
|
|
805
|
+
const cur = currentMachine()
|
|
806
|
+
if (cur && cur.host) applyMachine(config, cur)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Set the active remote workspace AND persist it on the current machine so
|
|
810
|
+
* it survives restarts (previously a tool-set workspace reset on reload). */
|
|
811
|
+
const persistWorkspace = (p) => {
|
|
812
|
+
config.workspace = p
|
|
813
|
+
if (store.currentId) {
|
|
814
|
+
const i = machineIndex(store.currentId)
|
|
815
|
+
if (i >= 0) {
|
|
816
|
+
machines[i].workspace = p
|
|
817
|
+
saveMachines(machines, store.currentId)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const run = async (cmd, opts = {}) => {
|
|
823
|
+
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
824
|
+
const parts = []
|
|
825
|
+
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
826
|
+
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
827
|
+
if (!parts.length) parts.push('(no output)')
|
|
828
|
+
let text = parts.join('\n')
|
|
829
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
|
|
830
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
831
|
+
return text
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Structured-listing of a remote dir: name + usable type.
|
|
835
|
+
*
|
|
836
|
+
* Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
|
|
837
|
+
* macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
|
|
838
|
+
* shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
|
|
839
|
+
* resolved with one bounded lstat (if it fails they degrade to files). */
|
|
840
|
+
const listDirStructured = async (p, timeoutMs) => {
|
|
841
|
+
const target = normalizeRemotePath(p || '/')
|
|
842
|
+
let sftp
|
|
843
|
+
try {
|
|
844
|
+
sftp = await pool.sftp()
|
|
845
|
+
} catch (err) {
|
|
846
|
+
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
847
|
+
}
|
|
848
|
+
let list = []
|
|
849
|
+
try {
|
|
850
|
+
list = await sftp.readdir(target)
|
|
851
|
+
} catch (err) {
|
|
852
|
+
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
853
|
+
}
|
|
854
|
+
const items = []
|
|
855
|
+
const symIdx = []
|
|
856
|
+
for (const e of list) {
|
|
857
|
+
const name = String(e.filename)
|
|
858
|
+
if (name === '.' || name === '..' || !name) continue
|
|
859
|
+
const a = e.attrs || {}
|
|
860
|
+
if (a.isSymbolicLink && a.isSymbolicLink()) {
|
|
861
|
+
items.push({ type: 'symlink', name })
|
|
862
|
+
symIdx.push(items.length - 1)
|
|
863
|
+
} else if (a.isDirectory && a.isDirectory()) {
|
|
864
|
+
items.push({ type: 'dir', name })
|
|
865
|
+
} else {
|
|
866
|
+
items.push({ type: 'file', name })
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
|
|
870
|
+
if (symIdx.length) {
|
|
871
|
+
await Promise.all(symIdx.map(async (i) => {
|
|
872
|
+
const full = joinRemotePath(target, items[i].name)
|
|
873
|
+
try {
|
|
874
|
+
const st = await sftp.lstat(full)
|
|
875
|
+
items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
|
|
876
|
+
} catch { /* degrade to file */ }
|
|
877
|
+
}))
|
|
878
|
+
}
|
|
879
|
+
return { path: target, items }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Verify a remote path is an existing directory, via SFTP stat (works on
|
|
883
|
+
* any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
|
|
884
|
+
* path is missing, is a file, or the stat errors. */
|
|
885
|
+
const isRemoteDir = async (p) => {
|
|
886
|
+
const target = normalizeRemotePath(p)
|
|
887
|
+
try {
|
|
888
|
+
const sftp = await pool.sftp()
|
|
889
|
+
const st = await sftp.stat(target)
|
|
890
|
+
return !!(st && st.isDirectory && st.isDirectory())
|
|
891
|
+
} catch {
|
|
892
|
+
return false
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ── remote workspace state ────────────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
const wsPath = () => (config.workspace || '').trim()
|
|
899
|
+
const status = () => ({
|
|
900
|
+
host: config.host,
|
|
901
|
+
port: config.port,
|
|
902
|
+
username: config.username,
|
|
903
|
+
connected: !!pool.client,
|
|
904
|
+
workspace: wsPath(),
|
|
905
|
+
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
906
|
+
currentId: store.currentId || null,
|
|
907
|
+
machines: machines.map(sanitizeMachine),
|
|
908
|
+
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
909
|
+
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
// ── tools ─────────────────────────────────────────────────────────────────
|
|
913
|
+
|
|
914
|
+
const renderErr = (err) => ({
|
|
915
|
+
kind: 'error',
|
|
916
|
+
text: String((err && err.message) || err),
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const tools = [
|
|
920
|
+
defineTool({
|
|
921
|
+
name: 'rw_info',
|
|
922
|
+
description:
|
|
923
|
+
'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.',
|
|
924
|
+
parameters: {},
|
|
925
|
+
output: {
|
|
926
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
927
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
928
|
+
},
|
|
929
|
+
async execute() {
|
|
930
|
+
const s = status()
|
|
931
|
+
const lines = [
|
|
932
|
+
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
933
|
+
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
934
|
+
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
935
|
+
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
936
|
+
'',
|
|
937
|
+
]
|
|
938
|
+
if (s.host && s.workspace) {
|
|
939
|
+
try {
|
|
940
|
+
// `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
|
|
941
|
+
const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
942
|
+
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
943
|
+
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
944
|
+
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
945
|
+
} catch (err) {
|
|
946
|
+
lines.push('Ping: FAILED — ' + ((err && err.message) || err))
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
950
|
+
}
|
|
951
|
+
return { text: lines.join('\n') }
|
|
952
|
+
},
|
|
953
|
+
}),
|
|
954
|
+
|
|
955
|
+
defineTool({
|
|
956
|
+
name: 'rw_connect',
|
|
957
|
+
description:
|
|
958
|
+
'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.',
|
|
959
|
+
parameters: {
|
|
960
|
+
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
961
|
+
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
962
|
+
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
963
|
+
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
964
|
+
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
965
|
+
},
|
|
966
|
+
output: {
|
|
967
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
968
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
969
|
+
},
|
|
970
|
+
async execute(args) {
|
|
971
|
+
const host = String(args.host || '').trim()
|
|
972
|
+
if (!host) throw new Error('rw_connect: host is required')
|
|
973
|
+
pool.setTarget({
|
|
974
|
+
host,
|
|
975
|
+
username: args.username || config.username || 'root',
|
|
976
|
+
port: args.port || undefined,
|
|
977
|
+
password: args.password !== undefined ? args.password : undefined,
|
|
978
|
+
privateKeyPath: args.privateKeyPath || undefined,
|
|
979
|
+
})
|
|
980
|
+
try {
|
|
981
|
+
const res = await pool.exec('echo ok', 8000)
|
|
982
|
+
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
983
|
+
return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
984
|
+
} catch (err) {
|
|
985
|
+
throw err
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
}),
|
|
989
|
+
|
|
990
|
+
defineTool({
|
|
991
|
+
name: 'rw_pick_workspace',
|
|
992
|
+
description:
|
|
993
|
+
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
|
|
994
|
+
parameters: {
|
|
995
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
996
|
+
},
|
|
997
|
+
output: {
|
|
998
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
999
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1000
|
+
},
|
|
1001
|
+
async execute(args) {
|
|
1002
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1003
|
+
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
1004
|
+
const ok = await isRemoteDir(p)
|
|
1005
|
+
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
1006
|
+
persistWorkspace(p)
|
|
1007
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1008
|
+
return {
|
|
1009
|
+
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.`,
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
|
|
1014
|
+
defineTool({
|
|
1015
|
+
name: 'rw_sync',
|
|
1016
|
+
description:
|
|
1017
|
+
'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.',
|
|
1018
|
+
parameters: {
|
|
1019
|
+
depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
|
|
1020
|
+
maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
|
|
1021
|
+
},
|
|
1022
|
+
output: {
|
|
1023
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1024
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1025
|
+
},
|
|
1026
|
+
async execute(args) {
|
|
1027
|
+
const p = wsPath()
|
|
1028
|
+
if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
|
|
1029
|
+
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
1030
|
+
mkdirSync(local, { recursive: true })
|
|
1031
|
+
const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
|
|
1032
|
+
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1033
|
+
let sftp
|
|
1034
|
+
try {
|
|
1035
|
+
sftp = await pool.sftp()
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
1038
|
+
}
|
|
1039
|
+
const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
|
|
1040
|
+
let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
|
|
1041
|
+
if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
|
|
1042
|
+
if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1043
|
+
return { text }
|
|
1044
|
+
},
|
|
1045
|
+
}),
|
|
1046
|
+
|
|
1047
|
+
defineTool({
|
|
1048
|
+
name: 'rw_push',
|
|
1049
|
+
description:
|
|
1050
|
+
'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).',
|
|
1051
|
+
parameters: {
|
|
1052
|
+
maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
|
|
1053
|
+
},
|
|
1054
|
+
output: {
|
|
1055
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1056
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1057
|
+
},
|
|
1058
|
+
async execute(args) {
|
|
1059
|
+
const p = wsPath()
|
|
1060
|
+
if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
|
|
1061
|
+
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
1062
|
+
if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
|
|
1063
|
+
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1064
|
+
let sftp
|
|
1065
|
+
try {
|
|
1066
|
+
sftp = await pool.sftp()
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
1069
|
+
}
|
|
1070
|
+
const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
|
|
1071
|
+
let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
|
|
1072
|
+
if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
|
|
1073
|
+
if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1074
|
+
return { text }
|
|
1075
|
+
},
|
|
1076
|
+
}),
|
|
1077
|
+
|
|
1078
|
+
defineTool({
|
|
1079
|
+
name: 'rw_list_dir',
|
|
1080
|
+
description:
|
|
1081
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
1082
|
+
parameters: {
|
|
1083
|
+
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1084
|
+
},
|
|
1085
|
+
output: {
|
|
1086
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1087
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1088
|
+
},
|
|
1089
|
+
async execute(args) {
|
|
1090
|
+
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
1091
|
+
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
1092
|
+
// SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
|
|
1093
|
+
// PowerShell) unlike `ls -la`.
|
|
1094
|
+
let list
|
|
1095
|
+
try {
|
|
1096
|
+
const sftp = await pool.sftp()
|
|
1097
|
+
list = await sftp.readdir(p)
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
throw new Error('rw_list_dir: ' + ((err && err.message) || err))
|
|
1100
|
+
}
|
|
1101
|
+
const lines = list
|
|
1102
|
+
.filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
|
|
1103
|
+
.map((e) => {
|
|
1104
|
+
const a = e.attrs || {}
|
|
1105
|
+
const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
|
|
1106
|
+
const size = typeof a.size === 'number' ? String(a.size) : '?'
|
|
1107
|
+
return `${type} ${size.padStart(10)} ${String(e.filename)}`
|
|
1108
|
+
})
|
|
1109
|
+
return { text: lines.length ? lines.join('\n') : '(empty directory)' }
|
|
1110
|
+
},
|
|
1111
|
+
}),
|
|
1112
|
+
|
|
1113
|
+
defineTool({
|
|
1114
|
+
name: 'rw_read_file',
|
|
1115
|
+
description:
|
|
1116
|
+
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
|
|
1117
|
+
parameters: {
|
|
1118
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1119
|
+
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
1120
|
+
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
1121
|
+
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
1122
|
+
},
|
|
1123
|
+
output: {
|
|
1124
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1125
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1126
|
+
},
|
|
1127
|
+
async execute(args) {
|
|
1128
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1129
|
+
if (!p) throw new Error('rw_read_file: path is required')
|
|
1130
|
+
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
1131
|
+
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
1132
|
+
let to = Number(args.endLine) || 0
|
|
1133
|
+
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
1134
|
+
// SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
|
|
1135
|
+
// / PowerShell, no POSIX sed) work too.
|
|
1136
|
+
let buf
|
|
1137
|
+
try {
|
|
1138
|
+
const sftp = await pool.sftp()
|
|
1139
|
+
buf = await sftp.readFile(p)
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
throw new Error('rw_read_file: ' + ((err && err.message) || err))
|
|
1142
|
+
}
|
|
1143
|
+
const content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
1144
|
+
const allLines = content.split('\n')
|
|
1145
|
+
const page = allLines.slice(from - 1, to)
|
|
1146
|
+
const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
1147
|
+
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
1148
|
+
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
1149
|
+
return { text }
|
|
1150
|
+
},
|
|
1151
|
+
}),
|
|
1152
|
+
|
|
1153
|
+
defineTool({
|
|
1154
|
+
name: 'rw_exec',
|
|
1155
|
+
description:
|
|
1156
|
+
'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.',
|
|
1157
|
+
parameters: {
|
|
1158
|
+
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1159
|
+
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
1160
|
+
},
|
|
1161
|
+
output: {
|
|
1162
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1163
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1164
|
+
},
|
|
1165
|
+
async execute(args) {
|
|
1166
|
+
const cmd = String(args.command || '')
|
|
1167
|
+
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1168
|
+
const ws = wsPath()
|
|
1169
|
+
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1170
|
+
// Windows remotes: no POSIX cd/single-quotes — let the command run as-is
|
|
1171
|
+
// (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
|
|
1172
|
+
const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
|
|
1173
|
+
return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
|
|
1174
|
+
},
|
|
1175
|
+
}),
|
|
1176
|
+
|
|
1177
|
+
defineTool({
|
|
1178
|
+
name: 'rw_write_file',
|
|
1179
|
+
description:
|
|
1180
|
+
'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.',
|
|
1181
|
+
parameters: {
|
|
1182
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1183
|
+
content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
|
|
1184
|
+
mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
|
|
1185
|
+
},
|
|
1186
|
+
output: {
|
|
1187
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1188
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
|
|
1189
|
+
},
|
|
1190
|
+
async execute(args) {
|
|
1191
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1192
|
+
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
1193
|
+
const content = String(args.content == null ? '' : args.content)
|
|
1194
|
+
let sftp
|
|
1195
|
+
try {
|
|
1196
|
+
sftp = await pool.sftp()
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
|
|
1199
|
+
}
|
|
1200
|
+
const mkdir = args.mkdir !== false
|
|
1201
|
+
if (mkdir) {
|
|
1202
|
+
// create each level from the root so mkdir -p semantics survive even
|
|
1203
|
+
// when intermediate privileged-parent dirs don't allow create (best effort)
|
|
1204
|
+
await mkdirRemoteDirs(sftp, p)
|
|
1205
|
+
}
|
|
1206
|
+
const buf = Buffer.from(content, 'utf8')
|
|
1207
|
+
await sftp.writeFile(p, buf)
|
|
1208
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
1209
|
+
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
1210
|
+
},
|
|
1211
|
+
}),
|
|
1212
|
+
|
|
1213
|
+
defineTool({
|
|
1214
|
+
name: 'rw_search',
|
|
1215
|
+
description:
|
|
1216
|
+
'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.',
|
|
1217
|
+
parameters: {
|
|
1218
|
+
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1219
|
+
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
1220
|
+
glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
|
|
1221
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1222
|
+
},
|
|
1223
|
+
output: {
|
|
1224
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1225
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1226
|
+
},
|
|
1227
|
+
async execute(args) {
|
|
1228
|
+
const pattern = String(args.pattern || '')
|
|
1229
|
+
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1230
|
+
const ws = wsPath()
|
|
1231
|
+
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
1232
|
+
if (!dir) throw new Error('rw_search: no path and no remote workspace set')
|
|
1233
|
+
if (dir.includes('\\')) {
|
|
1234
|
+
// Windows remotes have no POSIX find/grep; the search would need a
|
|
1235
|
+
// recursive SFTP walk. Give an actionable hint instead of a broken cmd.
|
|
1236
|
+
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}'"` }
|
|
1237
|
+
}
|
|
1238
|
+
const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
|
|
1239
|
+
const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
|
|
1240
|
+
// find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
|
|
1241
|
+
// differ on BSD, and GNU-only --include is avoided in favor of find -name.
|
|
1242
|
+
const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
|
|
1243
|
+
return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
|
|
1244
|
+
},
|
|
1245
|
+
}),
|
|
1246
|
+
|
|
1247
|
+
defineTool({
|
|
1248
|
+
name: 'rw_download',
|
|
1249
|
+
description:
|
|
1250
|
+
'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.',
|
|
1251
|
+
parameters: {
|
|
1252
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1253
|
+
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1254
|
+
},
|
|
1255
|
+
output: {
|
|
1256
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1257
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
|
|
1258
|
+
},
|
|
1259
|
+
async execute(args) {
|
|
1260
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1261
|
+
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1262
|
+
let sftp
|
|
1263
|
+
try {
|
|
1264
|
+
sftp = await pool.sftp()
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
|
|
1267
|
+
}
|
|
1268
|
+
let local
|
|
1269
|
+
if (args.localPath) {
|
|
1270
|
+
local = path.resolve(String(args.localPath))
|
|
1271
|
+
} else {
|
|
1272
|
+
const ws = wsPath()
|
|
1273
|
+
if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
|
|
1274
|
+
const base = mirrorDirFor(ws, config.host, config.username, config.port)
|
|
1275
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
1276
|
+
local = path.join(base, rel)
|
|
1277
|
+
}
|
|
1278
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
1279
|
+
const buf = await sftp.readFile(p)
|
|
1280
|
+
writeFileSync(local, buf)
|
|
1281
|
+
const bytes = buf.byteLength
|
|
1282
|
+
return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
|
|
1283
|
+
},
|
|
1284
|
+
}),
|
|
1285
|
+
|
|
1286
|
+
defineTool({
|
|
1287
|
+
name: 'rw_upload',
|
|
1288
|
+
description:
|
|
1289
|
+
'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.',
|
|
1290
|
+
parameters: {
|
|
1291
|
+
localPath: { type: 'string', required: true, description: 'Absolute local file path' },
|
|
1292
|
+
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1293
|
+
},
|
|
1294
|
+
output: {
|
|
1295
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1296
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
|
|
1297
|
+
},
|
|
1298
|
+
async execute(args) {
|
|
1299
|
+
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1300
|
+
const lp = String(args.localPath || '')
|
|
1301
|
+
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1302
|
+
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1303
|
+
let sftp
|
|
1304
|
+
try {
|
|
1305
|
+
sftp = await pool.sftp()
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
|
|
1308
|
+
}
|
|
1309
|
+
await mkdirRemoteDirs(sftp, rp)
|
|
1310
|
+
const buf = readFileSync(lp)
|
|
1311
|
+
await sftp.writeFile(rp, buf)
|
|
1312
|
+
const bytes = buf.byteLength
|
|
1313
|
+
return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
|
|
1314
|
+
},
|
|
1315
|
+
}),
|
|
1316
|
+
|
|
1317
|
+
defineTool({
|
|
1318
|
+
name: 'rw_disconnect',
|
|
1319
|
+
description:
|
|
1320
|
+
'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
|
|
1321
|
+
parameters: {},
|
|
1322
|
+
output: {
|
|
1323
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
|
|
1324
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1325
|
+
},
|
|
1326
|
+
async execute() {
|
|
1327
|
+
pool.close()
|
|
1328
|
+
return { ok: true, text: 'disconnected' }
|
|
1329
|
+
},
|
|
1330
|
+
}),
|
|
1331
|
+
]
|
|
1332
|
+
|
|
1333
|
+
for (const t of tools) {
|
|
1334
|
+
ctx.tools.register(t)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// ── system-prompt injection: the current remote workspace ─────────────────
|
|
1338
|
+
ctx.systemPrompt.section({
|
|
1339
|
+
name: 'dsh-remote',
|
|
1340
|
+
order: 88,
|
|
1341
|
+
text: () => {
|
|
1342
|
+
const w = wsPath()
|
|
1343
|
+
if (!w || !config.host) return ''
|
|
1344
|
+
return (
|
|
1345
|
+
'## Remote workspace\n' +
|
|
1346
|
+
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
1347
|
+
'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.'
|
|
1348
|
+
)
|
|
1349
|
+
},
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
// ── slash command: /remote reports status + connection hints ──────────────
|
|
1353
|
+
const commands = ctx.get('commands')
|
|
1354
|
+
if (commands !== undefined) {
|
|
1355
|
+
commands.register({
|
|
1356
|
+
name: 'remote',
|
|
1357
|
+
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
1358
|
+
handler: (invocation) => {
|
|
1359
|
+
const s = status()
|
|
1360
|
+
return {
|
|
1361
|
+
kind: 'success',
|
|
1362
|
+
text:
|
|
1363
|
+
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
1364
|
+
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
1365
|
+
`Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
|
|
1366
|
+
(s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
|
|
1367
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
|
|
1368
|
+
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
})
|
|
1372
|
+
commands.register({
|
|
1373
|
+
name: 'remote-forget-key',
|
|
1374
|
+
description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
|
|
1375
|
+
handler: () => {
|
|
1376
|
+
createHostKeyGuard(config).forgetHost()
|
|
1377
|
+
return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
|
|
1378
|
+
},
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
1383
|
+
const webServer = ctx.get('webServer')
|
|
1384
|
+
if (webServer === undefined) return
|
|
1385
|
+
|
|
1386
|
+
const sendJson = (res, status, body) => {
|
|
1387
|
+
res.statusCode = status
|
|
1388
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
1389
|
+
res.end(JSON.stringify(body))
|
|
1390
|
+
}
|
|
1391
|
+
// Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
|
|
1392
|
+
// unbounded memory on a local-only route.
|
|
1393
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
1394
|
+
const readBody = (req) =>
|
|
1395
|
+
new Promise((resolve) => {
|
|
1396
|
+
const chunks = []
|
|
1397
|
+
let total = 0
|
|
1398
|
+
req.on('data', (c) => {
|
|
1399
|
+
total += c.length
|
|
1400
|
+
if (total > MAX_BODY_BYTES) {
|
|
1401
|
+
req.removeAllListeners('data')
|
|
1402
|
+
resolve('{}')
|
|
1403
|
+
return
|
|
1404
|
+
}
|
|
1405
|
+
chunks.push(c)
|
|
1406
|
+
})
|
|
1407
|
+
req.on('end', () => resolve(chunks.join('')))
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
// ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
|
|
1411
|
+
// 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
|
|
1412
|
+
// loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
|
|
1413
|
+
// 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
|
|
1414
|
+
// native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
|
|
1415
|
+
const PICK_TIMEOUT_MS = 120000
|
|
1416
|
+
const runPick = (bin, args) =>
|
|
1417
|
+
new Promise((resolve, reject) => {
|
|
1418
|
+
execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
1419
|
+
if (err) {
|
|
1420
|
+
const code = err.code
|
|
1421
|
+
const msg = String(stderr || '')
|
|
1422
|
+
// 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
|
|
1423
|
+
if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
|
|
1424
|
+
if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
|
|
1425
|
+
if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
|
|
1426
|
+
return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
|
|
1427
|
+
}
|
|
1428
|
+
const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
|
|
1429
|
+
resolve(p ? { path: p } : { cancelled: true })
|
|
1430
|
+
})
|
|
1431
|
+
})
|
|
1432
|
+
const pickLocalNative = async () => {
|
|
1433
|
+
const platform = process.platform
|
|
1434
|
+
if (platform === 'darwin') {
|
|
1435
|
+
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
1436
|
+
}
|
|
1437
|
+
if (platform === 'linux') {
|
|
1438
|
+
try {
|
|
1439
|
+
return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
|
|
1442
|
+
throw err
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const routes = [
|
|
1449
|
+
{
|
|
1450
|
+
kind: 'exact',
|
|
1451
|
+
path: '/dsh-remote/status',
|
|
1452
|
+
handler: async (req, res) => {
|
|
1453
|
+
if (req.method === 'GET') return sendJson(res, 200, status())
|
|
1454
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
kind: 'exact',
|
|
1459
|
+
path: '/dsh-remote/connect',
|
|
1460
|
+
handler: async (req, res) => {
|
|
1461
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1462
|
+
try {
|
|
1463
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1464
|
+
pool.setTarget({
|
|
1465
|
+
host: payload.host,
|
|
1466
|
+
port: payload.port,
|
|
1467
|
+
username: payload.username,
|
|
1468
|
+
password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
|
|
1469
|
+
privateKeyPath: payload.privateKeyPath,
|
|
1470
|
+
workspace: payload.workspace,
|
|
1471
|
+
})
|
|
1472
|
+
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
1473
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1476
|
+
}
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
kind: 'exact',
|
|
1481
|
+
path: '/dsh-remote/ls',
|
|
1482
|
+
handler: async (req, res) => {
|
|
1483
|
+
try {
|
|
1484
|
+
// Proper URL parsing so `+` in a path decodes to a literal space
|
|
1485
|
+
// (the old regex missed it).
|
|
1486
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1487
|
+
const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
1488
|
+
const out = await listDirStructured(p || '/')
|
|
1489
|
+
return sendJson(res, 200, { path: p, items: out.items })
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1492
|
+
}
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
// Read a remote file over SFTP (live, not the local mirror) for the
|
|
1497
|
+
// sidebar's remote file viewer. Text files come back as UTF-8 content
|
|
1498
|
+
// (CRLF→LF), binary files are reported as binary with size+head bytes
|
|
1499
|
+
// (base64) so the client can sniff the format without loading the
|
|
1500
|
+
// whole file. `maxBytes` caps how much text is returned.
|
|
1501
|
+
kind: 'exact',
|
|
1502
|
+
path: '/dsh-remote/read',
|
|
1503
|
+
handler: async (req, res) => {
|
|
1504
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1505
|
+
try {
|
|
1506
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1507
|
+
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
1508
|
+
if (req.method === 'POST') {
|
|
1509
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1510
|
+
if (!p) p = String(body.path || '')
|
|
1511
|
+
}
|
|
1512
|
+
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
1513
|
+
const target = normalizeRemotePath(p)
|
|
1514
|
+
const maxBytes = Math.min(Math.max(Number(q.get('maxBytes')) || 256 * 1024, 1024), 2 * 1024 * 1024)
|
|
1515
|
+
let sftp
|
|
1516
|
+
try {
|
|
1517
|
+
sftp = await pool.sftp()
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
1520
|
+
}
|
|
1521
|
+
let buf
|
|
1522
|
+
try {
|
|
1523
|
+
buf = await sftp.readFile(target)
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
1526
|
+
}
|
|
1527
|
+
// Binary sniff: NUL byte in the first 8KB (or the whole file when
|
|
1528
|
+
// smaller) marks the file binary — matches the sidebar's convention.
|
|
1529
|
+
const headN = Math.min(buf.length, 8192)
|
|
1530
|
+
let binary = false
|
|
1531
|
+
for (let i = 0; i < headN; i++) {
|
|
1532
|
+
if (buf[i] === 0) { binary = true; break }
|
|
1533
|
+
}
|
|
1534
|
+
if (binary) {
|
|
1535
|
+
return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
|
|
1536
|
+
}
|
|
1537
|
+
let content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
1538
|
+
const truncated = content.length > maxBytes
|
|
1539
|
+
if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
|
|
1540
|
+
return sendJson(res, 200, { ok: true, binary: false, content, truncated })
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
kind: 'exact',
|
|
1548
|
+
path: '/dsh-remote/workspace',
|
|
1549
|
+
handler: async (req, res) => {
|
|
1550
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1551
|
+
try {
|
|
1552
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1553
|
+
const p = normalizeRemotePath(String(payload.path || ''))
|
|
1554
|
+
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
1555
|
+
const okDir = await isRemoteDir(p)
|
|
1556
|
+
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
1557
|
+
persistWorkspace(p)
|
|
1558
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1559
|
+
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
kind: 'exact',
|
|
1567
|
+
path: '/dsh-remote/mirror',
|
|
1568
|
+
handler: async (req, res) => {
|
|
1569
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1570
|
+
try {
|
|
1571
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
1572
|
+
const p = normalizeRemotePath(String(payload.path || ''))
|
|
1573
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
1574
|
+
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
1575
|
+
// Always verify over SSH (connecting if needed) so an unconnected or
|
|
1576
|
+
// wrong host can never silently mint a mirror for a bogus path.
|
|
1577
|
+
const okDir = await isRemoteDir(p)
|
|
1578
|
+
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
1579
|
+
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1580
|
+
persistWorkspace(p)
|
|
1581
|
+
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
kind: 'exact',
|
|
1589
|
+
path: '/dsh-remote/local-pick',
|
|
1590
|
+
handler: async (req, res) => {
|
|
1591
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1592
|
+
try {
|
|
1593
|
+
// 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
|
|
1594
|
+
// ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
|
|
1595
|
+
// "cannot get property ... without inject"(该名字未在本插件
|
|
1596
|
+
// inject 里声明),ctx.get() 无此要求、缺失返回 null。
|
|
1597
|
+
// 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
|
|
1598
|
+
// 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
|
|
1599
|
+
let outcome = null
|
|
1600
|
+
let via = 'service'
|
|
1601
|
+
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1602
|
+
if (dp && typeof dp.capability === 'function') {
|
|
1603
|
+
try {
|
|
1604
|
+
const cap = await Promise.resolve(dp.capability())
|
|
1605
|
+
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
1606
|
+
const pickAbort = new AbortController()
|
|
1607
|
+
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
1608
|
+
pickAbort.abort()
|
|
1609
|
+
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
1610
|
+
}
|
|
1611
|
+
} catch (err) {
|
|
1612
|
+
outcome = null // DSH 服务出错 → 落到自持兜底
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (!outcome) {
|
|
1616
|
+
via = 'own'
|
|
1617
|
+
try {
|
|
1618
|
+
outcome = await pickLocalNative()
|
|
1619
|
+
} catch (err) {
|
|
1620
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
|
|
1624
|
+
return sendJson(res, 200, { ok: true, path: outcome.path, via })
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
kind: 'exact',
|
|
1632
|
+
path: '/dsh-remote/machines',
|
|
1633
|
+
handler: async (req, res) => {
|
|
1634
|
+
if (req.method === 'GET') {
|
|
1635
|
+
return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1636
|
+
}
|
|
1637
|
+
if (req.method === 'POST') {
|
|
1638
|
+
try {
|
|
1639
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1640
|
+
const action = body.action || 'add'
|
|
1641
|
+
if (action === 'add' || action === 'update') {
|
|
1642
|
+
const host = String(body.host || '').trim()
|
|
1643
|
+
if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
|
|
1644
|
+
const rec = {
|
|
1645
|
+
id: body.id || machineId(),
|
|
1646
|
+
name: String(body.name || '').trim() || host,
|
|
1647
|
+
host,
|
|
1648
|
+
port: Number(body.port) || 22,
|
|
1649
|
+
username: String(body.username || '').trim() || 'root',
|
|
1650
|
+
password: body.password || '',
|
|
1651
|
+
privateKeyPath: String(body.privateKeyPath || '').trim(),
|
|
1652
|
+
passphrase: body.passphrase || '',
|
|
1653
|
+
workspace: String(body.workspace || '').trim(),
|
|
1654
|
+
}
|
|
1655
|
+
const i = machineIndex(rec.id)
|
|
1656
|
+
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
1657
|
+
if (!store.currentId) { store.currentId = rec.id }
|
|
1658
|
+
saveMachines(machines, store.currentId)
|
|
1659
|
+
if (store.currentId === rec.id) applyMachine(config, rec)
|
|
1660
|
+
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1661
|
+
}
|
|
1662
|
+
if (action === 'delete') {
|
|
1663
|
+
const i = machineIndex(String(body.id || ''))
|
|
1664
|
+
if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1665
|
+
machines.splice(i, 1)
|
|
1666
|
+
if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
|
|
1667
|
+
saveMachines(machines, store.currentId)
|
|
1668
|
+
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1669
|
+
}
|
|
1670
|
+
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1676
|
+
},
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
kind: 'exact',
|
|
1680
|
+
path: '/dsh-remote/test-connect',
|
|
1681
|
+
handler: async (req, res) => {
|
|
1682
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1683
|
+
try {
|
|
1684
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1685
|
+
const probe = new SshPool({
|
|
1686
|
+
...config,
|
|
1687
|
+
host: String(body.host || config.host),
|
|
1688
|
+
port: Number(body.port) || config.port,
|
|
1689
|
+
username: String(body.username || config.username),
|
|
1690
|
+
password: String(body.password || ''),
|
|
1691
|
+
privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
|
|
1692
|
+
passphrase: String(body.passphrase || ''),
|
|
1693
|
+
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
1694
|
+
commandTimeoutMs: 10000,
|
|
1695
|
+
})
|
|
1696
|
+
const started = Date.now()
|
|
1697
|
+
await probe.connect()
|
|
1698
|
+
await probe.exec('true', 10000)
|
|
1699
|
+
probe.close()
|
|
1700
|
+
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
|
|
1703
|
+
}
|
|
1704
|
+
},
|
|
1705
|
+
},
|
|
1706
|
+
{
|
|
1707
|
+
kind: 'exact',
|
|
1708
|
+
path: '/dsh-remote/current',
|
|
1709
|
+
handler: async (req, res) => {
|
|
1710
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1711
|
+
try {
|
|
1712
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1713
|
+
const okSet = setCurrent(String(body.id || ''))
|
|
1714
|
+
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1715
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1718
|
+
}
|
|
1719
|
+
},
|
|
1720
|
+
},
|
|
1721
|
+
{
|
|
1722
|
+
kind: 'exact',
|
|
1723
|
+
path: '/dsh-remote/forget-key',
|
|
1724
|
+
handler: async (req, res) => {
|
|
1725
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1726
|
+
// Drop the trusted host-key record for the CURRENT target so the next
|
|
1727
|
+
// connect re-records it (use after an intentional host reinstall or a
|
|
1728
|
+
// false MITM alarm).
|
|
1729
|
+
createHostKeyGuard(config).forgetHost()
|
|
1730
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
]
|
|
1734
|
+
|
|
1735
|
+
const disposers = routes.map((r) => webServer.register(r))
|
|
1736
|
+
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
1737
1737
|
}
|