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