dsh-ssh-workbench 0.0.0-stage → 1.7.0

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
@@ -0,0 +1,761 @@
1
+ /**
2
+ * SSH transport facade.
3
+ *
4
+ * The actual connection lifecycle lives in `broker.js` (one pooled connection
5
+ * per server). This module keeps the stable `createSshRuntime` surface the
6
+ * remote-bash router and the routes data plane use, and adds:
7
+ * - facts TTL cache (low-level helper; no longer exposed as a model tool),
8
+ * - SFTP helpers on top of the pooled connection,
9
+ * - chunked transfer sessions on dedicated connections (long-lived by design).
10
+ */
11
+ import { createSessionBroker } from './broker.js'
12
+ import { SshError, isBenignReadOnlyExit, suggestedActionFor, toSftpError } from './errors.js'
13
+ import path from 'node:path'
14
+ import { effectiveDialect, normalizeDialect } from './dialect.js'
15
+
16
+ export { isBenignReadOnlyExit, suggestedActionFor }
17
+
18
+ const DEFAULT_FACTS_TTL_MS = 60000
19
+
20
+ export function dialectFromProbe(stdout, exitCode) {
21
+ if (exitCode !== 0) return undefined
22
+ const text = String(stdout ?? '').trim()
23
+ if (!text) return undefined
24
+ if (/Microsoft Windows/i.test(text)) return 'windows'
25
+ if (/^(Linux|Darwin|FreeBSD|OpenBSD|NetBSD|DragonFly|SunOS|AIX|HP-UX|CYGWIN|MINGW|MSYS|GNU)/im.test(text)) return 'posix'
26
+ return undefined
27
+ }
28
+
29
+
30
+
31
+ function serverCacheKey(server) {
32
+ const ref = server.authRef ?? {}
33
+ return `${server.host}|${server.port}|${server.username}|${ref.type ?? ''}|${ref.path ?? ''}`
34
+ }
35
+
36
+ export function createSshRuntime({ getSecret, knownHosts, config = {}, log = () => {}, ClientCtor } = {}) {
37
+ const broker = createSessionBroker({ getSecret, knownHosts, config, log, ClientCtor })
38
+ const factsTtlMs = Number.isInteger(config.factsTtlMs) ? config.factsTtlMs : DEFAULT_FACTS_TTL_MS
39
+ const factsCache = new Map()
40
+ const dialectCache = new Map()
41
+ const timeoutGuardCache = new Map()
42
+ const TIMEOUT_GUARD_TTL_MS = 5 * 60 * 1000
43
+ const DEFAULT_TIMEOUT_GUARD = Object.freeze({ mode: 'none', shell: '/bin/sh' })
44
+ // The probe runs under `sh -c`, so it works no matter what the remote login
45
+ // shell is (fish/csh cannot parse POSIX `if ...; then`). It reports both the
46
+ // timeout flavour and the login shell path, so the wrapper can reference the
47
+ // shell as a literal instead of relying on POSIX `${SHELL:-...}` expansion.
48
+ const TIMEOUT_GUARD_PROBE =
49
+ `sh -c 'SH=\${SHELL:-/bin/sh}; ` +
50
+ 'if command -v timeout >/dev/null 2>&1; then ' +
51
+ 'if timeout -k 1 1 true >/dev/null 2>&1; then printf "kill|%s" "$SH"; ' +
52
+ 'else printf "basic|%s" "$SH"; fi; ' +
53
+ 'else printf "none|%s" "$SH"; fi\''
54
+
55
+ /**
56
+ * Parse the one-line probe response (`kill|/bin/bash`, `none|/bin/sh`, ...).
57
+ * Unknown/garbled output degrades to `none` (no wrapper), never to a wrapper.
58
+ */
59
+ function parseTimeoutGuard(stdout) {
60
+ const line = String(stdout ?? '')
61
+ .split(/\r?\n/)
62
+ .map((value) => value.trim())
63
+ .filter(Boolean)
64
+ .at(-1)
65
+ if (!line) return { ...DEFAULT_TIMEOUT_GUARD }
66
+ const [mode, shell] = line.split('|')
67
+ if (mode !== 'kill' && mode !== 'basic' && mode !== 'none') return { ...DEFAULT_TIMEOUT_GUARD }
68
+ const candidateShell = typeof shell === 'string' ? shell.trim() : ''
69
+ return { mode, shell: candidateShell.startsWith('/') ? candidateShell : DEFAULT_TIMEOUT_GUARD.shell }
70
+ }
71
+
72
+ /**
73
+ * Detect remote timeout support once per server (TTL-cached) so a timed-out
74
+ * command can stop the remote process group. An `exitCode` of any value means
75
+ * the probe actually ran on the remote, so even a "no timeout here" answer is
76
+ * cached; only transport failures skip the cache. Concurrent first calls
77
+ * share one in-flight probe.
78
+ *
79
+ * @returns {Promise<{mode: 'kill'|'basic'|'none', shell: string}>}
80
+ */
81
+ function timeoutGuard(server) {
82
+ if (!server || effectiveDialect(server) === 'windows') return Promise.resolve({ ...DEFAULT_TIMEOUT_GUARD })
83
+ const cacheKey = serverCacheKey(server)
84
+ const cached = timeoutGuardCache.get(server.id)
85
+ if (cached && cached.cacheKey === cacheKey) {
86
+ if (cached.pending) return cached.pending
87
+ if (Date.now() - cached.at < TIMEOUT_GUARD_TTL_MS) return Promise.resolve(cached.value)
88
+ }
89
+ const pending = (async () => {
90
+ let value = { ...DEFAULT_TIMEOUT_GUARD }
91
+ let cacheable = false
92
+ try {
93
+ const result = await broker.exec(server, TIMEOUT_GUARD_PROBE, {
94
+ timeoutMs: 15000,
95
+ readOnly: true,
96
+ cap: 4096,
97
+ })
98
+ if (result.exitCode !== null && result.exitCode !== undefined) {
99
+ value = parseTimeoutGuard(result.stdout)
100
+ cacheable = true
101
+ }
102
+ } catch {
103
+ // Transport failure says nothing about the remote: retry next time.
104
+ }
105
+ const current = timeoutGuardCache.get(server.id)
106
+ if (current && current.pending === pending) {
107
+ if (cacheable) timeoutGuardCache.set(server.id, { cacheKey, value, at: Date.now() })
108
+ else timeoutGuardCache.delete(server.id)
109
+ }
110
+ return value
111
+ })()
112
+ timeoutGuardCache.set(server.id, { cacheKey, pending, at: Date.now() })
113
+ return pending
114
+ }
115
+
116
+ const DIALECT_TTL_MS = 10 * 60 * 1000
117
+ const DIALECT_PROBE_TIMEOUT_MS = 8000
118
+ const POSIX_PROBE = 'uname -s'
119
+ const WINDOWS_PROBE = 'cmd /c ver'
120
+
121
+ /**
122
+ * Classify one dialect probe output. Both probes are single, shell-native
123
+ * commands, so a wrong guess only costs one failed SSH exec — never a
124
+ * command against the wrong dialect.
125
+ */
126
+ /**
127
+ * Detect the target dialect once per server (TTL-cached) instead of trusting
128
+ * the hand-maintained `platform` hint. `uname -s` answers on POSIX targets;
129
+ * `cmd /c ver` answers on Windows targets regardless of whether sshd's
130
+ * default shell is `cmd.exe` or PowerShell.
131
+ *
132
+ * @returns {Promise<'posix'|'windows'|undefined>}
133
+ */
134
+ function probeDialect(server) {
135
+ if (!server) return Promise.resolve(undefined)
136
+ const cacheKey = serverCacheKey(server)
137
+ const cached = dialectCache.get(server.id)
138
+ if (cached && cached.cacheKey === cacheKey) {
139
+ if (cached.pending) return cached.pending
140
+ if (Date.now() - cached.at < DIALECT_TTL_MS) return Promise.resolve(cached.dialect)
141
+ }
142
+ const pending = (async () => {
143
+ let dialect
144
+ try {
145
+ const posix = await broker.exec(server, POSIX_PROBE, {
146
+ timeoutMs: DIALECT_PROBE_TIMEOUT_MS,
147
+ readOnly: true,
148
+ cap: 4096,
149
+ })
150
+ dialect = dialectFromProbe(posix?.stdout, posix?.exitCode ?? null)
151
+ } catch {
152
+ // Transport failure: try the other probe? No — a dead connection is a
153
+ // dead connection; fall through to the last-known/record hint.
154
+ }
155
+ if (!dialect) {
156
+ try {
157
+ const windows = await broker.exec(server, WINDOWS_PROBE, {
158
+ timeoutMs: DIALECT_PROBE_TIMEOUT_MS,
159
+ readOnly: true,
160
+ cap: 4096,
161
+ })
162
+ dialect = dialectFromProbe(windows?.stdout, windows?.exitCode ?? null)
163
+ } catch {
164
+ dialect = undefined
165
+ }
166
+ }
167
+ const current = dialectCache.get(server.id)
168
+ if (current && current.pending === pending) {
169
+ if (dialect) dialectCache.set(server.id, { cacheKey, dialect, at: Date.now() })
170
+ else dialectCache.delete(server.id)
171
+ }
172
+ return dialect
173
+ })()
174
+ dialectCache.set(server.id, { cacheKey, pending, at: Date.now() })
175
+ return pending
176
+ }
177
+
178
+ async function facts(server, options = {}) {
179
+ const cacheKey = serverCacheKey(server)
180
+ const cached = factsCache.get(server.id)
181
+ if (
182
+ options.refresh !== true &&
183
+ cached &&
184
+ cached.cacheKey === cacheKey &&
185
+ cached.facts?.dialect &&
186
+ Date.now() - cached.at < factsTtlMs
187
+ ) {
188
+ return {
189
+ outcome: 'success',
190
+ exitCode: 0,
191
+ signal: null,
192
+ stdout: '',
193
+ stderr: '',
194
+ durationMs: 0,
195
+ truncated: { stdout: false, stderr: false },
196
+ facts: cached.facts,
197
+ cached: true,
198
+ collectedAt: cached.collectedAt,
199
+ warm: true,
200
+ timings: { connectMs: 0, channelMs: 0, commandMs: 0, totalMs: 0 },
201
+ }
202
+ }
203
+ // Probe before collecting: the facts script itself is dialect-specific.
204
+ let dialect = options.dialect
205
+ if (dialect !== 'posix' && dialect !== 'windows') {
206
+ try {
207
+ dialect = await probeDialect(server)
208
+ } catch {
209
+ dialect = undefined
210
+ }
211
+ }
212
+ dialect = dialect === 'windows' ? 'windows' : dialect === 'posix' ? 'posix' : normalizeDialect(server.platform)
213
+ const script =
214
+ dialect === 'windows'
215
+ ? 'echo __SDH_HOST__ & hostname & echo __SDH_OS__ & ver & echo __SDH_UPTIME__ & echo n/a & echo __SDH_MEM__ & wmic OS get FreePhysicalMemory /value 2>nul & echo __SDH_DISK__ & wmic logicaldisk get DeviceID,Size,FreeSpace 2>nul'
216
+ : "echo __SDH_HOST__; hostname 2>/dev/null; echo __SDH_OS__; uname -sr 2>/dev/null; echo __SDH_UPTIME__; (uptime -p 2>/dev/null || uptime 2>/dev/null); echo __SDH_MEM__; (free -m 2>/dev/null | sed -n '1,2p' || vm_stat 2>/dev/null | head -5); echo __SDH_DISK__; df -Pk / 2>/dev/null | tail -n 2"
217
+ const result = await broker.exec(server, script, {
218
+ timeoutMs: options.timeoutMs ?? 20000,
219
+ signal: options.signal,
220
+ readOnly: true,
221
+ cap: options.cap,
222
+ })
223
+ const parsed = parseFacts(result.stdout, dialect)
224
+ if (result.outcome === 'success') {
225
+ factsCache.set(server.id, {
226
+ cacheKey,
227
+ facts: parsed,
228
+ collectedAt: new Date().toISOString(),
229
+ at: Date.now(),
230
+ })
231
+ }
232
+ return { ...result, facts: parsed, cached: false, collectedAt: new Date().toISOString() }
233
+ }
234
+
235
+ // ── SFTP on the pooled connection ─────────────────────────────────────────
236
+ function withSftp(server, handler, options = {}) {
237
+ return broker.withSftp(server, handler, options)
238
+ }
239
+
240
+ function statOf(attrs) {
241
+ return {
242
+ size: attrs.size,
243
+ mode: attrs.mode,
244
+ uid: attrs.uid,
245
+ gid: attrs.gid,
246
+ atime: attrs.atime,
247
+ mtime: attrs.mtime,
248
+ }
249
+ }
250
+
251
+ async function sftpList(server, remotePath) {
252
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
253
+ sftp.readdir(remotePath, (error, entries) => {
254
+ if (error) return reject(toSftpError(error))
255
+ resolve(entries.map((entry) => ({
256
+ name: entry.filename,
257
+ path: `${remotePath.replace(/\/+$/, '')}/${entry.filename}`.replace(/^\/+/, remotePath.startsWith('/') ? '/' : ''),
258
+ type: entry.longname.startsWith('d') ? 'dir' : entry.longname.startsWith('l') ? 'link' : 'file',
259
+ longname: entry.longname,
260
+ ...statOf(entry.attrs),
261
+ })))
262
+ })
263
+ }))
264
+ }
265
+
266
+ async function sftpStat(server, remotePath) {
267
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
268
+ sftp.stat(remotePath, (error, attrs) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, ...statOf(attrs) })))
269
+ }))
270
+ }
271
+
272
+ function clampReadBytes(value, fallback) {
273
+ const hardCap = 64 * 1024 * 1024
274
+ return Number.isInteger(value) ? Math.max(1024, Math.min(value, hardCap)) : fallback
275
+ }
276
+
277
+ /** Read a remote file into a Buffer (binary-safe, bounded by maxBytes). */
278
+ async function readRemoteBuffer(server, remotePath, maxBytes) {
279
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
280
+ try {
281
+ sftp.stat(remotePath, (error, attrs) => {
282
+ if (error) return reject(toSftpError(error))
283
+ const chunks = []
284
+ let size = 0
285
+ let truncated = false
286
+ const stream = sftp.createReadStream(remotePath)
287
+ stream.on('data', (chunk) => {
288
+ const remaining = maxBytes - size
289
+ if (remaining > 0) {
290
+ chunks.push(chunk.subarray(0, Math.min(chunk.length, remaining)))
291
+ size += Math.min(chunk.length, remaining)
292
+ }
293
+ if (chunk.length > remaining) {
294
+ truncated = true
295
+ stream.destroy()
296
+ }
297
+ })
298
+ stream.once('error', (error2) => reject(toSftpError(error2)))
299
+ stream.once('close', () => {
300
+ const buffer = Buffer.concat(chunks)
301
+ resolve({
302
+ path: remotePath,
303
+ size: attrs.size,
304
+ bytesRead: buffer.length,
305
+ buffer,
306
+ truncated: truncated || attrs.size > buffer.length,
307
+ })
308
+ })
309
+ })
310
+ } catch (error) {
311
+ reject(toSftpError(error))
312
+ }
313
+ }))
314
+ }
315
+
316
+ async function sftpRead(server, remotePath, options = {}) {
317
+ // 64 MiB hard cap covers downloads/previews while keeping one JSON response bounded.
318
+ const maxBytes = clampReadBytes(options.maxBytes, 262144)
319
+ const result = await readRemoteBuffer(server, remotePath, maxBytes)
320
+ const binary = result.buffer.includes(0)
321
+ const formatted = {
322
+ path: result.path,
323
+ size: result.size,
324
+ bytesRead: result.bytesRead,
325
+ truncated: result.truncated,
326
+ binary,
327
+ content: binary ? undefined : result.buffer.toString('utf8'),
328
+ }
329
+ if (options.base64 === true) formatted.base64 = result.buffer.toString('base64')
330
+ return formatted
331
+ }
332
+
333
+ /** Binary-safe read used by agent upload/download flows. */
334
+ async function sftpReadBuffer(server, remotePath, options = {}) {
335
+ const maxBytes = clampReadBytes(options.maxBytes, 64 * 1024 * 1024)
336
+ return await readRemoteBuffer(server, remotePath, maxBytes)
337
+ }
338
+
339
+ async function sftpWrite(server, remotePath, content) {
340
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
341
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
342
+ sftp.writeFile(remotePath, buffer, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, bytes: buffer.length })))
343
+ }))
344
+ }
345
+
346
+ async function sftpMkdir(server, remotePath) {
347
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
348
+ sftp.mkdir(remotePath, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath })))
349
+ }))
350
+ }
351
+
352
+ async function sftpRemove(server, remotePath, isDir = false) {
353
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
354
+ const done = (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, removed: true }))
355
+ if (isDir) sftp.rmdir(remotePath, done)
356
+ else sftp.unlink(remotePath, done)
357
+ }))
358
+ }
359
+
360
+ async function sftpRename(server, fromPath, toPath) {
361
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
362
+ sftp.rename(fromPath, toPath, (error) => (error ? reject(toSftpError(error)) : resolve({ from: fromPath, to: toPath })))
363
+ }))
364
+ }
365
+
366
+ // ── chunked transfer sessions (dedicated connection, idle-closed) ─────────
367
+ const transferSessions = new Map()
368
+ let transferSessionSeq = 0
369
+ const TRANSFER_IDLE_MS = 60000
370
+ const MAX_TRANSFER_SESSIONS = 8
371
+ const MAX_TRANSFER_CHUNK = 4 * 1024 * 1024
372
+ const TRANSFER_CHUNK_TIMEOUT_MS = Number.isInteger(config.transferChunkTimeoutMs)
373
+ ? Math.max(100, config.transferChunkTimeoutMs)
374
+ : 30000
375
+
376
+ /** Accept only an absolute home path; `~`/`.` are not real answers. */
377
+ function normalizeSftpHome(value) {
378
+ if (typeof value !== 'string') return undefined
379
+ let trimmed = value.trim()
380
+ if (!trimmed || trimmed === '.' || trimmed === '~') return undefined
381
+ if (trimmed.startsWith('/')) {
382
+ // Some SFTP servers answer REALPATH "." with "/." or a trailing slash.
383
+ trimmed = path.posix.normalize(trimmed)
384
+ if (trimmed === '.' || trimmed === '/.') return undefined
385
+ return trimmed === '/' ? '/' : trimmed.replace(/\/+$/, '')
386
+ }
387
+ // Windows servers may report a drive-letter path.
388
+ if (/^[A-Za-z]:[\\/]/.test(trimmed)) return trimmed.replace(/[\\/]+$/, '') || '/'
389
+ return undefined
390
+ }
391
+
392
+ /**
393
+ * Resolve a server's login home directory.
394
+ *
395
+ * SFTP sessions start in the login home, so `realpath('.')` is the canonical
396
+ * home (e.g. `/root` for web-1). A few SFTP servers reject relative paths in
397
+ * REALPATH; fall back to `realpath('~')` (OpenSSH expands the tilde) and then
398
+ * to a readable `SFTP_FAILED` error. Never silently answer `/`: on a chrooted
399
+ * or restricted account that would point the file panel at a directory the
400
+ * user cannot list (U5).
401
+ *
402
+ * @param {object} server normalized inventory record
403
+ * @returns {Promise<string>} absolute home directory
404
+ */
405
+ async function sftpHome(server) {
406
+ const label = server?.name ?? server?.id ?? 'server'
407
+ let lastError
408
+ for (const candidate of ['.', '~']) {
409
+ try {
410
+ const resolved = await withSftp(
411
+ server,
412
+ (sftp) =>
413
+ new Promise((resolve, reject) => {
414
+ try {
415
+ sftp.realpath(candidate, (error, resolvedPath) => {
416
+ if (error) return reject(toSftpError(error))
417
+ resolve(resolvedPath)
418
+ })
419
+ } catch (error) {
420
+ reject(toSftpError(error))
421
+ }
422
+ }),
423
+ { timeoutMs: TRANSFER_CHUNK_TIMEOUT_MS },
424
+ )
425
+ const home = normalizeSftpHome(resolved)
426
+ if (home) return home
427
+ lastError = new Error(`realpath("${candidate}") returned ${JSON.stringify(resolved)}`)
428
+ } catch (error) {
429
+ lastError = toSftpError(error)
430
+ }
431
+ }
432
+ throw new SshError(
433
+ 'SFTP_FAILED',
434
+ `could not resolve the login home directory for "${label}": ${lastError instanceof Error ? lastError.message : String(lastError)}`,
435
+ )
436
+ }
437
+
438
+ /**
439
+ * Guard one SFTP chunk callback with a timeout. A half-open connection must
440
+ * not leave the file panel stuck at "uploading" forever; on timeout the
441
+ * session is closed so the client can resume from its last contiguous offset.
442
+ */
443
+ async function withTransferWatchdog(operation, label, onTimeout) {
444
+ let timer
445
+ try {
446
+ return await Promise.race([
447
+ operation,
448
+ new Promise((_resolve, reject) => {
449
+ timer = setTimeout(() => {
450
+ try {
451
+ onTimeout?.()
452
+ } catch {
453
+ // closing a broken session is best effort
454
+ }
455
+ reject(new SshError('SFTP_TIMEOUT', `${label} timed out after ${TRANSFER_CHUNK_TIMEOUT_MS}ms`))
456
+ }, TRANSFER_CHUNK_TIMEOUT_MS)
457
+ // Keep the watchdog referenced while the chunk is in flight: an
458
+ // unref'd timer lets a test/host process exit before the timeout
459
+ // can fire, which both hides the error and leaks the pending promise.
460
+ }),
461
+ ])
462
+ } finally {
463
+ clearTimeout(timer)
464
+ }
465
+ }
466
+
467
+ function armTransferIdle(session) {
468
+ if (session.timer) clearTimeout(session.timer)
469
+ session.timer = setTimeout(() => {
470
+ void closeTransferSession(session.id, true)
471
+ }, TRANSFER_IDLE_MS)
472
+ if (session.timer && typeof session.timer.unref === 'function') session.timer.unref()
473
+ }
474
+
475
+ async function openTransferConnection(server) {
476
+ const { client } = await broker.connectDedicated(server)
477
+ try {
478
+ const sftp = await new Promise((resolve, reject) => {
479
+ client.sftp((error, session) => (error ? reject(toSftpError(error)) : resolve(session)))
480
+ })
481
+ return { connection: { client }, sftp }
482
+ } catch (error) {
483
+ try {
484
+ client.end()
485
+ } catch {
486
+ // already closed
487
+ }
488
+ throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
489
+ }
490
+ }
491
+
492
+ function openRemoteHandle(sftp, remotePath, flags) {
493
+ return new Promise((resolve, reject) => {
494
+ sftp.open(remotePath, flags, (error, handle) => (error ? reject(toSftpError(error)) : resolve(handle)))
495
+ })
496
+ }
497
+
498
+ async function closeTransferSession(id, silent = false, options = {}) {
499
+ const session = transferSessions.get(id)
500
+ if (!session) {
501
+ if (silent) return undefined
502
+ throw new SshError('TRANSFER_SESSION', `unknown transfer session ${id}`)
503
+ }
504
+ transferSessions.delete(id)
505
+ if (session.timer) clearTimeout(session.timer)
506
+ try {
507
+ if (session.handle) {
508
+ await new Promise((resolve) => session.sftp.close(session.handle, () => resolve()))
509
+ }
510
+ } catch {
511
+ // closing a broken handle is best effort
512
+ }
513
+ let verified = false
514
+ try {
515
+ // An explicit close of an upload verifies the remote size, so a dropped
516
+ // final chunk cannot be reported as a successful transfer.
517
+ if (!silent && options.verify !== false && session.kind === 'write' && session.bytes > 0) {
518
+ const attrs = await new Promise((resolve, reject) => {
519
+ try {
520
+ session.sftp.stat(session.path, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
521
+ } catch (error) {
522
+ reject(toSftpError(error))
523
+ }
524
+ })
525
+ if (Number.isInteger(attrs?.size) && attrs.size !== session.bytes) {
526
+ throw new SshError(
527
+ 'SFTP_SIZE_MISMATCH',
528
+ `remote size ${attrs.size} does not match the ${session.bytes} bytes written to ${session.path}`,
529
+ )
530
+ }
531
+ verified = true
532
+ }
533
+ } finally {
534
+ try {
535
+ session.connection.client.end()
536
+ } catch {
537
+ // already closed
538
+ }
539
+ }
540
+ return { session: id, path: session.path, bytes: session.bytes ?? 0, server: session.server, ...(verified ? { verified: true } : {}) }
541
+ }
542
+
543
+ async function sftpOpenWrite(server, remotePath, truncate = true) {
544
+ const { connection, sftp } = await openTransferConnection(server)
545
+ try {
546
+ // 'w' truncates; 'r+' keeps the existing .part bytes for resume.
547
+ const handle = await openRemoteHandle(sftp, remotePath, truncate ? 'w' : 'r+')
548
+ const id = `sftp-write-${++transferSessionSeq}`
549
+ const session = {
550
+ id,
551
+ kind: 'write',
552
+ server: server.id,
553
+ path: remotePath,
554
+ sftp,
555
+ connection,
556
+ handle,
557
+ bytes: 0,
558
+ touchedAt: Date.now(),
559
+ }
560
+ transferSessions.set(id, session)
561
+ if (transferSessions.size > MAX_TRANSFER_SESSIONS) {
562
+ const oldest = [...transferSessions.values()]
563
+ .filter((entry) => entry.id !== id)
564
+ .sort((left, right) => left.touchedAt - right.touchedAt)[0]
565
+ if (oldest) await closeTransferSession(oldest.id, true)
566
+ }
567
+ armTransferIdle(session)
568
+ return { session: id, path: remotePath }
569
+ } catch (error) {
570
+ try {
571
+ connection.client.end()
572
+ } catch {
573
+ // already closed
574
+ }
575
+ throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
576
+ }
577
+ }
578
+
579
+ async function sftpWriteChunk(id, offset, buffer) {
580
+ const session = transferSessions.get(id)
581
+ if (!session || session.kind !== 'write') throw new SshError('TRANSFER_SESSION', `write session '${id}' is not open`)
582
+ if (!Buffer.isBuffer(buffer)) throw new SshError('TRANSFER_FAILED', 'write chunk must be a Buffer')
583
+ if (buffer.length > MAX_TRANSFER_CHUNK) throw new SshError('TRANSFER_FAILED', `chunk exceeds ${MAX_TRANSFER_CHUNK} bytes`)
584
+ const position = Number.isInteger(offset) && offset >= 0 ? offset : 0
585
+ await withTransferWatchdog(
586
+ new Promise((resolve, reject) => {
587
+ session.sftp.write(session.handle, buffer, 0, buffer.length, position, (error) => (error ? reject(toSftpError(error)) : resolve()))
588
+ }),
589
+ 'SFTP write chunk',
590
+ () => {
591
+ void closeTransferSession(id, true)
592
+ },
593
+ )
594
+ session.bytes = Math.max(session.bytes, position + buffer.length)
595
+ session.touchedAt = Date.now()
596
+ armTransferIdle(session)
597
+ return { session: id, bytes: buffer.length }
598
+ }
599
+
600
+ async function sftpOpenRead(server, remotePath) {
601
+ const { connection, sftp } = await openTransferConnection(server)
602
+ try {
603
+ const handle = await openRemoteHandle(sftp, remotePath, 'r')
604
+ const attrs = await new Promise((resolve, reject) => {
605
+ sftp.fstat(handle, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
606
+ })
607
+ const id = `sftp-read-${++transferSessionSeq}`
608
+ const session = {
609
+ id,
610
+ kind: 'read',
611
+ server: server.id,
612
+ path: remotePath,
613
+ sftp,
614
+ connection,
615
+ handle,
616
+ size: attrs.size,
617
+ mtime: attrs.mtime,
618
+ touchedAt: Date.now(),
619
+ }
620
+ transferSessions.set(id, session)
621
+ if (transferSessions.size > MAX_TRANSFER_SESSIONS) {
622
+ const oldest = [...transferSessions.values()]
623
+ .filter((entry) => entry.id !== id)
624
+ .sort((left, right) => left.touchedAt - right.touchedAt)[0]
625
+ if (oldest) await closeTransferSession(oldest.id, true)
626
+ }
627
+ armTransferIdle(session)
628
+ return { session: id, path: remotePath, size: attrs.size, mtime: attrs.mtime }
629
+ } catch (error) {
630
+ try {
631
+ connection.client.end()
632
+ } catch {
633
+ // already closed
634
+ }
635
+ throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
636
+ }
637
+ }
638
+
639
+ async function sftpReadChunk(id, offset, length) {
640
+ const session = transferSessions.get(id)
641
+ if (!session || session.kind !== 'read') throw new SshError('TRANSFER_SESSION', `read session '${id}' is not open`)
642
+ const position = Number.isInteger(offset) && offset >= 0 ? offset : 0
643
+ const wanted = Number.isInteger(length) && length > 0 ? Math.min(length, MAX_TRANSFER_CHUNK) : 256 * 1024
644
+ const buffer = Buffer.alloc(wanted)
645
+ const bytesRead = await withTransferWatchdog(
646
+ new Promise((resolve, reject) => {
647
+ session.sftp.read(session.handle, buffer, 0, wanted, position, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
648
+ }),
649
+ 'SFTP read chunk',
650
+ () => {
651
+ void closeTransferSession(id, true)
652
+ },
653
+ )
654
+ session.touchedAt = Date.now()
655
+ armTransferIdle(session)
656
+ return { session: id, bytesRead, base64: buffer.subarray(0, bytesRead).toString('base64') }
657
+ }
658
+
659
+
660
+
661
+ /** Close every chunked transfer session that belongs to one server (config change/removal). */
662
+ async function closeTransferSessionsForServer(serverId) {
663
+ let closed = 0
664
+ for (const [id, session] of [...transferSessions.entries()]) {
665
+ if (session.server !== serverId) continue
666
+ try {
667
+ await closeTransferSession(id, true)
668
+ closed += 1
669
+ } catch {
670
+ // best effort: the server record is already being replaced/removed
671
+ }
672
+ }
673
+ return closed
674
+ }
675
+
676
+ async function close() {
677
+ for (const id of [...transferSessions.keys()]) {
678
+ try {
679
+ await closeTransferSession(id, true)
680
+ } catch {
681
+ // best effort
682
+ }
683
+ }
684
+ await broker.closeAll()
685
+ }
686
+
687
+ return {
688
+ preload: () => broker.preload(),
689
+ exec: (server, command, options) => broker.exec(server, command, options),
690
+ test: async (server) => {
691
+ const result = await broker.test(server)
692
+ if (!result?.ok) return result
693
+ // Report the probed dialect, not the record hint: the UI and the target
694
+ // context both use this value to pick the command syntax.
695
+ let dialect
696
+ try {
697
+ dialect = await probeDialect(server)
698
+ } catch {
699
+ dialect = undefined
700
+ }
701
+ return dialect ? { ...result, platform: dialect, dialect } : result
702
+ },
703
+ facts,
704
+ probeDialect,
705
+ timeoutGuard,
706
+ shell: (server, options) => broker.shell(server, options),
707
+ withSftp,
708
+ sftpList,
709
+ sftpStat,
710
+ sftpHome,
711
+ sftpRead,
712
+ sftpReadBuffer,
713
+ sftpWrite,
714
+ sftpMkdir,
715
+ sftpRemove,
716
+ sftpRename,
717
+ sftpOpenWrite,
718
+ sftpWriteChunk,
719
+ sftpOpenRead,
720
+ sftpReadChunk,
721
+ sftpCloseTransfer: (id, options) => closeTransferSession(id, false, options),
722
+ closeTransferSessionsForServer,
723
+ warm: (server) => broker.warm(server),
724
+ invalidate: (serverId) => {
725
+ factsCache.delete(serverId)
726
+ dialectCache.delete(serverId)
727
+ timeoutGuardCache.delete(serverId)
728
+ broker.invalidate(serverId)
729
+ },
730
+ close,
731
+ }
732
+ }
733
+
734
+ export function parseFacts(stdout, platform = 'posix') {
735
+ const sections = {}
736
+ let current = null
737
+ for (const line of String(stdout ?? '').split(/\r?\n/)) {
738
+ const marker = /^__SDH_([A-Z]+)__$/.exec(line.trim())
739
+ if (marker) {
740
+ current = marker[1].toLowerCase()
741
+ sections[current] = []
742
+ continue
743
+ }
744
+ if (current) sections[current].push(line)
745
+ }
746
+ const clean = (key) => (sections[key] ?? []).map((line) => line.trim()).filter(Boolean).join(' | ')
747
+ const dialect = normalizeDialect(platform)
748
+ const facts = {
749
+ platform: dialect,
750
+ dialect,
751
+ collectedAt: new Date().toISOString(),
752
+ }
753
+ if (clean('host')) facts.hostname = clean('host')
754
+ if (clean('os')) facts.kernel = clean('os')
755
+ if (clean('uptime')) facts.uptime = clean('uptime')
756
+ if (clean('mem')) facts.memory = clean('mem').slice(0, 500)
757
+ if (clean('disk')) facts.rootDisk = clean('disk').slice(0, 500)
758
+ return facts
759
+ }
760
+
761
+