dsh-ssh-workbench 0.0.0-stage → 1.6.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
package/lib/routes.js ADDED
@@ -0,0 +1,733 @@
1
+ /**
2
+ * JSON data plane for the settings page and the right-sidebar panel.
3
+ *
4
+ * One handler serves both carriers:
5
+ * - Web: `ctx.webServer.register({kind:'prefix', path:'/dsh-ssh-workbench'})`.
6
+ * - Desktop: the Electron shell has no webServer, so the same operations are
7
+ * registered as exact Connection Fetch routes under `/api/dsh-ssh-workbench/*`
8
+ * (GET/POST only, matching the host's route registry).
9
+ *
10
+ * Every browser-facing request is fenced by `ctx.connection.requestRejection`
11
+ * when the Connection service exists.
12
+ */
13
+ import { Readable } from 'node:stream'
14
+
15
+ import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
16
+ import { effectiveDialect } from './core/dialect.js'
17
+ import { SshError } from './core/errors.js'
18
+
19
+ const PREFIX = '/dsh-ssh-workbench'
20
+ const MAX_BODY = 8 * 1024 * 1024
21
+
22
+ /** Marker error for request-level failures that carry their own HTTP status. */
23
+ class HttpStatusError extends Error {
24
+ constructor(status, message) {
25
+ super(message)
26
+ this.name = 'HttpStatusError'
27
+ this.status = status
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Add user-facing operation/path context to a filesystem failure while keeping
33
+ * the original error type (`SshError` code, Node `code`). B6/B12: a bare
34
+ * `Failure` or `No such file` from SFTP tells the user nothing.
35
+ */
36
+ function contextualFileError(operation, remotePath, error) {
37
+ const detail = error instanceof Error ? error.message : String(error)
38
+ const label = typeof remotePath === 'string' && remotePath ? ` "${remotePath}"` : ''
39
+ const message = `${operation}${label} failed: ${detail}`
40
+ if (error instanceof SshError) {
41
+ const wrapped = new SshError(error.code, message)
42
+ if (error.fingerprint !== undefined) wrapped.fingerprint = error.fingerprint
43
+ return wrapped
44
+ }
45
+ if (error instanceof HttpStatusError) return error
46
+ const wrapped = new Error(message)
47
+ wrapped.cause = error
48
+ if (error && typeof error === 'object' && typeof error.code === 'string') wrapped.code = error.code
49
+ return wrapped
50
+ }
51
+
52
+ /** Exact routes exposed to the Desktop Connection Fetch carrier. */
53
+ export const FETCH_ROUTES = [
54
+ { path: `${PREFIX}/servers`, methods: ['GET'] },
55
+ { path: `${PREFIX}/state`, methods: ['GET'] },
56
+ { path: `${PREFIX}/known-hosts`, methods: ['GET'] },
57
+ { path: `${PREFIX}/target`, methods: ['GET'] },
58
+ { path: `${PREFIX}/activity`, methods: ['GET'] },
59
+ { path: `${PREFIX}/activity/clear`, methods: ['POST'] },
60
+ { path: `${PREFIX}/prefs`, methods: ['GET', 'POST'] },
61
+ { path: `${PREFIX}/terminal/open`, methods: ['POST'] },
62
+ { path: `${PREFIX}/terminal/input`, methods: ['POST'] },
63
+ { path: `${PREFIX}/terminal/resize`, methods: ['POST'] },
64
+ { path: `${PREFIX}/terminal/close`, methods: ['POST'] },
65
+ { path: `${PREFIX}/terminal/read`, methods: ['GET'] },
66
+ { path: `${PREFIX}/localfs/root`, methods: ['GET'] },
67
+ { path: `${PREFIX}/sftp/home`, methods: ['GET', 'POST'] },
68
+ { path: `${PREFIX}/server/create`, methods: ['POST'] },
69
+ { path: `${PREFIX}/server/update`, methods: ['POST'] },
70
+ { path: `${PREFIX}/server/remove`, methods: ['POST'] },
71
+ { path: `${PREFIX}/server/test`, methods: ['POST'] },
72
+ { path: `${PREFIX}/server/warm`, methods: ['POST'] },
73
+ { path: `${PREFIX}/server/trust`, methods: ['POST'] },
74
+ { path: `${PREFIX}/server/credential`, methods: ['POST'] },
75
+ { path: `${PREFIX}/sftp/list`, methods: ['POST'] },
76
+ { path: `${PREFIX}/sftp/read`, methods: ['POST'] },
77
+ { path: `${PREFIX}/sftp/open-write`, methods: ['POST'] },
78
+ { path: `${PREFIX}/sftp/write-chunk`, methods: ['POST'] },
79
+ { path: `${PREFIX}/sftp/close-write`, methods: ['POST'] },
80
+ { path: `${PREFIX}/sftp/open-read`, methods: ['POST'] },
81
+ { path: `${PREFIX}/sftp/read-chunk`, methods: ['POST'] },
82
+ { path: `${PREFIX}/sftp/close-read`, methods: ['POST'] },
83
+ { path: `${PREFIX}/sftp/mkdir`, methods: ['POST'] },
84
+ { path: `${PREFIX}/sftp/remove`, methods: ['POST'] },
85
+ { path: `${PREFIX}/sftp/rename`, methods: ['POST'] },
86
+ ]
87
+
88
+ function readBody(req) {
89
+ return new Promise((resolve, reject) => {
90
+ const chunks = []
91
+ let size = 0
92
+ req.on('data', (chunk) => {
93
+ size += chunk.length
94
+ if (size > MAX_BODY) {
95
+ // 413, matching the Desktop Connection Fetch carrier below.
96
+ reject(new HttpStatusError(413, 'request body too large'))
97
+ req.destroy()
98
+ return
99
+ }
100
+ chunks.push(chunk)
101
+ })
102
+ req.on('end', () => {
103
+ const text = Buffer.concat(chunks).toString('utf8')
104
+ if (!text.trim()) return resolve({})
105
+ try {
106
+ resolve(JSON.parse(text))
107
+ } catch (error) {
108
+ reject(new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`))
109
+ }
110
+ })
111
+ req.on('error', reject)
112
+ })
113
+ }
114
+
115
+ function send(res, status, body, headers = {}) {
116
+ const text = JSON.stringify(body)
117
+ res.statusCode = status
118
+ res.setHeader?.('content-type', 'application/json; charset=utf-8')
119
+ res.setHeader?.('cache-control', 'no-store')
120
+ for (const [key, value] of Object.entries(headers)) res.setHeader?.(key, value)
121
+ res.end(text)
122
+ }
123
+
124
+ function errorBody(error) {
125
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
126
+ }
127
+
128
+ function parsePath(url) {
129
+ const parsed = new URL(url, 'http://localhost')
130
+ return { pathname: parsed.pathname, searchParams: parsed.searchParams }
131
+ }
132
+
133
+ function resolveRef(value) {
134
+ return typeof value === 'string' && value ? value : undefined
135
+ }
136
+
137
+ /**
138
+ * @param {object} deps
139
+ * @param {() => object[]} deps.loadServers
140
+ * @param {(servers: object[]) => object[]} deps.saveServers
141
+ * @param {object} deps.runtime
142
+ * @param {object} deps.secrets
143
+ * @param {object} deps.knownHosts
144
+ * @param {{ load: () => object, save: (patch: object) => object }} [deps.prefs]
145
+ * @param {object} [deps.terminals] interactive terminal hub
146
+ * @param {object} [deps.localfs] local filesystem browser
147
+ * @param {() => object} deps.publicConfig
148
+ * @param {{ append: Function, list: Function }} [deps.activity] bounded local activity store
149
+ */
150
+ export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
151
+ const prefsApi = prefs ?? {
152
+ load: () => ({ sidebar: true }),
153
+ save: () => ({ sidebar: true }),
154
+ }
155
+ // Reuse the files panel for the local workspace by giving it the same
156
+ // method surface as the SSH runtime under the synthetic `__local__` ref.
157
+ const localFiles = localfs ? createLocalFileApi(localfs) : null
158
+ // Small UI-facing caches: a repeated directory view or connection test should
159
+ // not pay another round trip. Write operations invalidate the affected server.
160
+ const TEST_TTL_MS = 30000
161
+ const DIR_TTL_MS = 5000
162
+ const testCache = new Map()
163
+ const dirCache = new Map()
164
+ const dirKey = (server, path) => `${server.id}:${path}`
165
+ const clearDirCache = (serverId) => {
166
+ for (const key of [...dirCache.keys()]) if (key.startsWith(`${serverId}:`)) dirCache.delete(key)
167
+ }
168
+ const invalidateServer = (serverId) => {
169
+ testCache.delete(serverId)
170
+ clearDirCache(serverId)
171
+ try {
172
+ runtime.invalidate?.(serverId)
173
+ } catch {
174
+ // pool invalidation is best effort
175
+ }
176
+ // A server edit/removal must not leave uploads/downloads writing through a
177
+ // connection to the old host/credentials.
178
+ try {
179
+ void Promise.resolve(runtime.closeTransferSessionsForServer?.(serverId)).catch(() => {})
180
+ } catch {
181
+ // session cleanup is best effort
182
+ }
183
+ }
184
+
185
+ function stateRows(servers) {
186
+ return servers.map((server) => {
187
+ const hasSecret =
188
+ server.authRef.type === 'password' ? secrets.has(`server:${server.id}:password`) : true
189
+ return {
190
+ id: server.id,
191
+ name: server.name,
192
+ host: server.host,
193
+ port: server.port,
194
+ username: server.username,
195
+ platform: server.platform,
196
+ dialect: effectiveDialect(server),
197
+ tags: server.tags,
198
+ description: server.description,
199
+ defaultShell: server.defaultShell,
200
+ enabled: server.enabled,
201
+ keyPath: server.authRef.type === 'sshKey' ? server.authRef.path : undefined,
202
+ authType: server.authRef.type,
203
+ credentialConfigured: hasSecret,
204
+ hostKeyKnown: knownHosts.has(server),
205
+ hostname: server.facts?.hostname,
206
+ facts: server.facts
207
+ ? {
208
+ hostname: server.facts.hostname,
209
+ kernel: server.facts.kernel,
210
+ uptime: server.facts.uptime,
211
+ rootDisk: server.facts.rootDisk,
212
+ collectedAt: server.facts.collectedAt,
213
+ }
214
+ : undefined,
215
+ }
216
+ })
217
+ }
218
+
219
+ function findTarget(servers, ref) {
220
+ return servers.find((server) => server.id === ref || server.name === ref)
221
+ }
222
+
223
+ function logActivity(entry) {
224
+ try {
225
+ activity?.append(entry)
226
+ } catch {
227
+ // activity recording is best effort
228
+ }
229
+ }
230
+
231
+ /** Wrap one filesystem operation so failures name the operation and path. */
232
+ async function fileOp(operation, remotePath, action) {
233
+ try {
234
+ return await action()
235
+ } catch (error) {
236
+ throw contextualFileError(operation, remotePath, error)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Local "home" for the files panel: the canonical workspace root.
242
+ *
243
+ * `localFileApi.sftpHome()` is the intended interface (added by the localfs
244
+ * workstream); fall back to `localfs.rootReal()` and finally the lexical
245
+ * `localfs.root()` so this route keeps working while the two land.
246
+ */
247
+ async function localHomeDirectory() {
248
+ const sources = []
249
+ if (typeof localFiles?.sftpHome === 'function') sources.push(() => localFiles.sftpHome())
250
+ if (typeof localfs?.rootReal === 'function') sources.push(() => localfs.rootReal())
251
+ if (typeof localfs?.root === 'function') sources.push(() => localfs.root())
252
+ if (sources.length === 0) throw new Error('local files are unavailable on this host')
253
+ let lastMessage
254
+ for (const source of sources) {
255
+ try {
256
+ const value = await source()
257
+ if (typeof value === 'string' && value.trim()) return value
258
+ lastMessage = 'the local workspace root is empty'
259
+ } catch (error) {
260
+ lastMessage = error instanceof Error ? error.message : String(error)
261
+ }
262
+ }
263
+ throw new Error(`the local workspace root is unavailable${lastMessage ? `: ${lastMessage}` : ''}`)
264
+ }
265
+
266
+ async function remoteHomeDirectory(server) {
267
+ if (typeof runtime.sftpHome !== 'function') {
268
+ throw new Error('home directory lookup is unavailable on this host')
269
+ }
270
+ const home = await runtime.sftpHome(server)
271
+ if (typeof home !== 'string' || !home.trim()) throw new Error('the server returned an empty home directory')
272
+ return home
273
+ }
274
+
275
+ /** GET/POST /sftp/home → `{ ok:true, result:{ home } }` (U5). */
276
+ async function serveSftpHome(res, ref) {
277
+ if (!ref) return send(res, 400, { ok: false, error: 'ref is required' })
278
+ try {
279
+ if (ref === LOCAL_FILE_REF) {
280
+ const home = await localHomeDirectory()
281
+ return send(res, 200, { ok: true, result: { home } })
282
+ }
283
+ const server = findTarget(loadServers(), ref)
284
+ if (!server) return send(res, 404, { ok: false, error: `unknown server "${ref}"` })
285
+ const home = await remoteHomeDirectory(server)
286
+ return send(res, 200, { ok: true, result: { home } })
287
+ } catch (error) {
288
+ const detail = error instanceof Error ? error.message : String(error)
289
+ return send(res, 502, { ok: false, error: `could not resolve the home directory for "${ref}": ${detail}` })
290
+ }
291
+ }
292
+
293
+ async function handle(req, res) {
294
+ if (req.method === 'OPTIONS') return send(res, 204, {})
295
+ const { pathname, searchParams } = parsePath(req.url)
296
+ const rel = pathname.startsWith(PREFIX) ? pathname.slice(PREFIX.length) || '/' : pathname
297
+
298
+ try {
299
+ if (req.method === 'GET' && rel === '/servers') {
300
+ return send(res, 200, { ok: true, servers: loadServers() })
301
+ }
302
+ if (req.method === 'GET' && rel === '/state') {
303
+ return send(res, 200, { ok: true, servers: stateRows(loadServers()), config: publicConfig() })
304
+ }
305
+ if (req.method === 'GET' && rel === '/known-hosts') {
306
+ return send(res, 200, { ok: true, entries: knownHosts.list() })
307
+ }
308
+ if (req.method === 'GET' && rel === '/target') {
309
+ const session = String(searchParams.get('session') ?? '')
310
+ const state = session && typeof targets?.get === 'function' ? targets.get(session) : undefined
311
+ return send(res, 200, {
312
+ ok: true,
313
+ target: state?.active ?? null,
314
+ candidates: state?.candidates ?? [],
315
+ version: state?.version ?? 0,
316
+ })
317
+ }
318
+ if (req.method === 'GET' && rel === '/activity') {
319
+ const entries = typeof activity?.list === 'function' ? activity.list(30) : []
320
+ return send(res, 200, { ok: true, entries })
321
+ }
322
+ if (req.method === 'POST' && rel === '/activity/clear') {
323
+ const cleared = typeof activity?.clear === 'function' ? activity.clear() : false
324
+ return send(res, 200, { ok: true, cleared })
325
+ }
326
+
327
+ if (req.method === 'GET' && rel === '/prefs') {
328
+ return send(res, 200, { ok: true, prefs: prefsApi.load() })
329
+ }
330
+
331
+ if (req.method === 'GET' && rel === '/terminal/read') {
332
+ const session = String(searchParams.get('session') ?? '')
333
+ if (!session) return send(res, 400, { ok: false, error: 'session is required' })
334
+ if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
335
+ const cursor = Number.parseInt(searchParams.get('cursor') ?? '0', 10)
336
+ const waitMs = Number.parseInt(searchParams.get('wait') ?? '0', 10)
337
+ return send(res, 200, { ok: true, result: await terminals.read(session, { cursor, waitMs }) })
338
+ }
339
+
340
+ if (req.method === 'GET' && rel === '/localfs/root') {
341
+ if (!localfs) return send(res, 503, { ok: false, error: 'local files are unavailable on this host' })
342
+ // S3: list()/entry paths are canonicalized through realpath, so the
343
+ // navigation root handed to the client must use the same form.
344
+ const root = typeof localfs.rootReal === 'function'
345
+ ? await localfs.rootReal()
346
+ : await Promise.resolve(localfs.root())
347
+ return send(res, 200, { ok: true, result: { root } })
348
+ }
349
+ if (req.method === 'GET' && rel === '/sftp/home') {
350
+ const ref = resolveRef(searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'))
351
+ return await serveSftpHome(res, ref)
352
+ }
353
+ if (req.method !== 'POST') return send(res, 404, { ok: false, error: 'not found' })
354
+ const body = await readBody(req)
355
+
356
+ if (rel === '/sftp/home') {
357
+ const ref = resolveRef(
358
+ body.ref ?? body.server ?? body.name ?? searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'),
359
+ )
360
+ return await serveSftpHome(res, ref)
361
+ }
362
+
363
+ if (rel === '/prefs') {
364
+ if (typeof body.sidebar !== 'boolean') {
365
+ return send(res, 400, { ok: false, error: 'prefs.sidebar must be a boolean' })
366
+ }
367
+ return send(res, 200, { ok: true, prefs: prefsApi.save({ sidebar: body.sidebar }) })
368
+ }
369
+
370
+ if (rel === '/terminal/open') {
371
+ if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
372
+ const kind = body.kind === 'remote' ? 'remote' : 'local'
373
+ let server
374
+ if (kind === 'remote') {
375
+ server = findTarget(loadServers(), body.ref)
376
+ if (!server) return send(res, 404, { ok: false, error: `unknown resource "${String(body.ref ?? '')}"` })
377
+ if (server.enabled === false) return send(res, 400, { ok: false, error: `resource "${server.name}" is disabled` })
378
+ }
379
+ const opened = await terminals.open({
380
+ kind,
381
+ server,
382
+ cols: body.cols,
383
+ rows: body.rows,
384
+ cwd: typeof body.cwd === 'string'
385
+ ? body.cwd
386
+ : (kind === 'local' && localfs ? await Promise.resolve(localfs.root()) : undefined),
387
+ })
388
+ return send(res, 200, { ok: true, result: opened })
389
+ }
390
+ if (rel === '/terminal/input') {
391
+ if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
392
+ const accepted = terminals.input(String(body.session ?? ''), String(body.data ?? ''))
393
+ return send(res, accepted ? 200 : 404, { ok: accepted, accepted })
394
+ }
395
+ if (rel === '/terminal/resize') {
396
+ if (!terminals) return send(res, 503, { ok: false, error: 'terminals are unavailable on this host' })
397
+ const accepted = terminals.resize(String(body.session ?? ''), body.cols, body.rows)
398
+ return send(res, accepted ? 200 : 404, { ok: accepted, accepted })
399
+ }
400
+ if (rel === '/terminal/close') {
401
+ if (!terminals) return send(res, 200, { ok: true, closed: false })
402
+ return send(res, 200, { ok: true, closed: terminals.close(String(body.session ?? '')) })
403
+ }
404
+ if (rel === '/server/create') {
405
+ const record = body.record ?? body
406
+ const saved = saveServers([...loadServers(), record])
407
+ logActivity({ kind: 'server-create', server: String(record.name ?? ''), outcome: 'saved' })
408
+ return send(res, 200, { ok: true, server: saved.find((server) => server.name === record.name) ?? saved.at(-1) })
409
+ }
410
+
411
+ // Chunked transfer sessions: the client holds a session id, so these
412
+ // routes do not take a server ref. They must run before ref resolution.
413
+ const sessionId = typeof body.session === 'string' ? body.session : ''
414
+ const localTransfer = localFiles && sessionId.startsWith('loc') ? localFiles : null
415
+ if (rel === '/sftp/write-chunk') {
416
+ if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
417
+ // B10: offset used to be silently coerced to 0 by the runtime, which
418
+ // could corrupt a resumed upload; it must be an explicit non-negative
419
+ // integer from the client.
420
+ if (!Number.isInteger(body.offset) || body.offset < 0) {
421
+ return send(res, 400, { ok: false, error: 'offset must be a non-negative integer' })
422
+ }
423
+ if (typeof body.contentBase64 !== 'string' || !body.contentBase64) {
424
+ return send(res, 400, { ok: false, error: 'contentBase64 is required' })
425
+ }
426
+ const buffer = Buffer.from(body.contentBase64, 'base64')
427
+ if (buffer.length > 4 * 1024 * 1024) return send(res, 413, { ok: false, error: 'chunk too large (4 MiB limit)' })
428
+ const api = localTransfer ?? runtime
429
+ const result = await fileOp('write chunk', undefined, () => api.sftpWriteChunk(sessionId, body.offset, buffer))
430
+ return send(res, 200, { ok: true, result })
431
+ }
432
+ if (rel === '/sftp/close-write') {
433
+ if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
434
+ const api = localTransfer ?? runtime
435
+ if (body.abort === true) {
436
+ const result = await fileOp('abort upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: false }))
437
+ return send(res, 200, { ok: true, result })
438
+ }
439
+ const result = await fileOp('finish upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: true }))
440
+ // F1/B5/B8: the local adapter used to return no `server`, so the dir
441
+ // cache was cleared for `undefined` and the activity log said
442
+ // "unknown". Prefer the reported id, fall back to the synthetic local
443
+ // ref for `loc*` sessions (LOCAL_FILE_REF is the intended value once
444
+ // local-file-api reports it).
445
+ const serverId = typeof result?.server === 'string' && result.server
446
+ ? result.server
447
+ : (localTransfer ? LOCAL_FILE_REF : undefined)
448
+ const inventory = loadServers()
449
+ const serverName = localTransfer
450
+ ? LOCAL_FILE_REF
451
+ : (inventory.find((entry) => entry.id === serverId)?.name ?? serverId ?? 'unknown')
452
+ if (serverId) clearDirCache(serverId)
453
+ logActivity({ kind: 'file-write', server: serverName, summary: `${result?.path ?? ''} (${result?.bytes ?? 0} bytes)`, outcome: 'success' })
454
+ return send(res, 200, { ok: true, result })
455
+ }
456
+ if (rel === '/sftp/read-chunk') {
457
+ if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
458
+ if (!Number.isInteger(body.offset) || body.offset < 0) {
459
+ return send(res, 400, { ok: false, error: 'offset must be a non-negative integer' })
460
+ }
461
+ const length = Number.isInteger(body.length) ? body.length : 2 * 1024 * 1024
462
+ if (length <= 0 || length > 4 * 1024 * 1024) return send(res, 400, { ok: false, error: 'length must be 1..4 MiB' })
463
+ const api = localTransfer ?? runtime
464
+ const result = await fileOp('read chunk', undefined, () => api.sftpReadChunk(sessionId, body.offset, length))
465
+ return send(res, 200, { ok: true, result })
466
+ }
467
+ if (rel === '/sftp/close-read') {
468
+ if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
469
+ const api = localTransfer ?? runtime
470
+ const result = await fileOp('close download', undefined, () => api.sftpCloseTransfer(sessionId))
471
+ return send(res, 200, { ok: true, result })
472
+ }
473
+
474
+ const ref = resolveRef(body.ref ?? body.server ?? body.name)
475
+ const servers = loadServers()
476
+ let target
477
+ let fileApi = runtime
478
+ if (ref === LOCAL_FILE_REF && localFiles) {
479
+ target = localFiles.server
480
+ fileApi = localFiles
481
+ } else {
482
+ target = ref ? findTarget(servers, ref) : undefined
483
+ }
484
+ if (!target) return send(res, 404, { ok: false, error: `unknown server "${ref ?? ''}"` })
485
+
486
+ if (rel === '/server/update') {
487
+ const patch = body.patch ?? {}
488
+ const next = servers.map((server) => (server.id === target.id ? { ...server, ...patch, id: target.id } : server))
489
+ const saved = saveServers(next)
490
+ terminals?.closeForServer?.(target.id)
491
+ invalidateServer(target.id)
492
+ logActivity({ kind: 'server-update', server: target.name, outcome: 'saved' })
493
+ return send(res, 200, { ok: true, server: saved.find((server) => server.id === target.id) })
494
+ }
495
+ if (rel === '/server/remove') {
496
+ saveServers(servers.filter((server) => server.id !== target.id))
497
+ terminals?.closeForServer?.(target.id)
498
+ invalidateServer(target.id)
499
+ logActivity({ kind: 'server-remove', server: target.name, outcome: 'removed' })
500
+ return send(res, 200, { ok: true, removed: target.name })
501
+ }
502
+
503
+ if (rel === '/server/test') {
504
+ const cached = testCache.get(target.id)
505
+ if (cached && Date.now() - cached.at < TEST_TTL_MS && body.refresh !== true) {
506
+ return send(res, 200, { ok: true, result: { ...cached.result, cached: true } })
507
+ }
508
+ const result = await runtime.test(target)
509
+ if (result.ok) testCache.set(target.id, { at: Date.now(), result })
510
+ // A successful test is a good moment to refresh host facts for the UI.
511
+ let facts
512
+ if (result.ok && typeof refreshFacts === 'function') {
513
+ try {
514
+ facts = await refreshFacts(target)
515
+ } catch {
516
+ // facts are optional metadata
517
+ }
518
+ }
519
+ logActivity({ kind: 'test', server: target.name, outcome: result.ok ? 'ok' : (result.errorCode ?? 'failed') })
520
+ return send(res, 200, { ok: true, result: facts ? { ...result, facts } : result })
521
+ }
522
+ if (rel === '/server/warm') {
523
+ if (target.id === LOCAL_FILE_REF) return send(res, 200, { ok: true, warmed: false })
524
+ const config = typeof publicConfig === 'function' ? publicConfig() : undefined
525
+ if (config && config.prewarm === false) {
526
+ return send(res, 200, { ok: true, warmed: false, skipped: 'prewarm disabled' })
527
+ }
528
+ try {
529
+ runtime.warm?.(target)
530
+ } catch {
531
+ // warm-up is best effort
532
+ }
533
+ return send(res, 200, { ok: true, warmed: target.name })
534
+ }
535
+
536
+ if (rel === '/server/trust') {
537
+ if (typeof body.fingerprint !== 'string' || !body.fingerprint) {
538
+ return send(res, 400, { ok: false, error: 'fingerprint is required' })
539
+ }
540
+ knownHosts.trust(target, body.fingerprint)
541
+ logActivity({ kind: 'trust', server: target.name, outcome: 'trusted' })
542
+ return send(res, 200, { ok: true })
543
+ }
544
+ if (rel === '/server/credential') {
545
+ if (typeof body.password === 'string' && body.password) {
546
+ secrets.set(`server:${target.id}:password`, body.password)
547
+ // The explicit "set password" action also selects password auth:
548
+ // otherwise the stored secret would be ignored by a key-auth record.
549
+ if (target.authRef.type !== 'password') {
550
+ saveServers(servers.map((server) => (server.id === target.id ? { ...server, authRef: { type: 'password' } } : server)))
551
+ logActivity({ kind: 'server-update', server: target.name, summary: 'auth -> password', outcome: 'saved' })
552
+ }
553
+ invalidateServer(target.id)
554
+ }
555
+ if (typeof body.passphrase === 'string' && body.passphrase) secrets.set(`server:${target.id}:passphrase`, body.passphrase)
556
+ return send(res, 200, { ok: true })
557
+ }
558
+ if (rel === '/sftp/list') {
559
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
560
+ const key = dirKey(target, body.path)
561
+ const cached = dirCache.get(key)
562
+ if (body.refresh !== true && cached && Date.now() - cached.at < DIR_TTL_MS) {
563
+ return send(res, 200, { ok: true, result: cached.entries, cached: true })
564
+ }
565
+ const entries = await fileOp('list', body.path, () => fileApi.sftpList(target, body.path))
566
+ dirCache.set(key, { at: Date.now(), entries })
567
+ return send(res, 200, { ok: true, result: entries })
568
+ }
569
+ if (rel === '/sftp/read') {
570
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
571
+ const wantsBase64 = body.base64 === true
572
+ const result = await fileOp('read', body.path, () => fileApi.sftpRead(target, body.path, {
573
+ maxBytes: Number.isInteger(body.maxBytes) ? body.maxBytes : undefined,
574
+ base64: wantsBase64,
575
+ }))
576
+ if (result.binary && !wantsBase64) return send(res, 200, { ok: true, result: { ...result, content: undefined } })
577
+ return send(res, 200, { ok: true, result })
578
+ }
579
+
580
+ if (rel === '/sftp/open-write') {
581
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
582
+ return send(res, 200, { ok: true, result: await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false)) })
583
+ }
584
+ if (rel === '/sftp/open-read') {
585
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
586
+ return send(res, 200, { ok: true, result: await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path)) })
587
+ }
588
+
589
+
590
+ if (rel === '/sftp/mkdir') {
591
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
592
+ const result = await fileOp('mkdir', body.path, () => fileApi.sftpMkdir(target, body.path))
593
+ clearDirCache(target.id)
594
+ logActivity({ kind: 'file-mkdir', server: target.name, summary: body.path, outcome: 'success' })
595
+ return send(res, 200, { ok: true, result })
596
+ }
597
+ if (rel === '/sftp/remove') {
598
+ if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
599
+ const result = await fileOp('remove', body.path, () => fileApi.sftpRemove(target, body.path, body.isDir === true))
600
+ clearDirCache(target.id)
601
+ logActivity({ kind: 'file-remove', server: target.name, summary: body.path, outcome: 'success' })
602
+ return send(res, 200, { ok: true, result })
603
+ }
604
+ if (rel === '/sftp/rename') {
605
+ if (typeof body.path !== 'string' || typeof body.targetPath !== 'string') {
606
+ return send(res, 400, { ok: false, error: 'path and targetPath are required' })
607
+ }
608
+ const normalize = (value) => String(value).replace(/\/+$/, '') || '/'
609
+ if (normalize(body.path) === normalize(body.targetPath)) {
610
+ return send(res, 400, { ok: false, error: 'source and target path are the same' })
611
+ }
612
+ const result = await fileOp(`rename "${body.path}" to`, body.targetPath, () => fileApi.sftpRename(target, body.path, body.targetPath))
613
+ clearDirCache(target.id)
614
+ logActivity({ kind: 'file-rename', server: target.name, summary: `${body.path} -> ${body.targetPath}`, outcome: 'success' })
615
+ return send(res, 200, { ok: true, result })
616
+ }
617
+ return send(res, 404, { ok: false, error: 'not found' })
618
+ } catch (error) {
619
+ const status = error instanceof HttpStatusError ? error.status : 400
620
+ return send(res, status, errorBody(error))
621
+ }
622
+ }
623
+
624
+ return handle
625
+ }
626
+
627
+ /** Wrap one descriptor as an exact Connection Fetch route (Desktop carrier). */
628
+ export function connectionRoute(descriptor, handler) {
629
+ return {
630
+ path: `/api${descriptor.path}`,
631
+ methods: ['GET', 'POST'],
632
+ requestBody: 'buffered',
633
+ async fetch(request) {
634
+ const url = new URL(request.url)
635
+ const chunks = []
636
+ let size = 0
637
+ const reader = request.body?.getReader?.()
638
+ if (reader) {
639
+ try {
640
+ while (true) {
641
+ const { done, value } = await reader.read()
642
+ if (done) break
643
+ size += value.byteLength
644
+ if (size > MAX_BODY) {
645
+ await reader.cancel()
646
+ return Response.json({ ok: false, error: 'request body too large' }, { status: 413 })
647
+ }
648
+ chunks.push(Buffer.from(value))
649
+ }
650
+ } finally {
651
+ reader.releaseLock()
652
+ }
653
+ }
654
+ const req = Readable.from(chunks.length ? [Buffer.concat(chunks)] : [])
655
+ req.method = request.method
656
+ req.url = descriptor.path + url.search
657
+ req.headers = Object.fromEntries(request.headers)
658
+
659
+ const headers = new Headers()
660
+ let bodyText = ''
661
+ const res = {
662
+ statusCode: 200,
663
+ setHeader(key, value) {
664
+ headers.set(key, value)
665
+ },
666
+ end(text) {
667
+ bodyText = text ?? ''
668
+ },
669
+ }
670
+ await handler(req, res)
671
+ return new Response(bodyText, { status: res.statusCode, headers })
672
+ },
673
+ }
674
+ }
675
+
676
+ /** Register the data plane on whichever carriers the composition provides. */
677
+ export function registerRoutes(ctx, handler, { fetchRoutes = FETCH_ROUTES } = {}) {
678
+ if (typeof ctx.inject !== 'function') return () => {}
679
+
680
+ const fence = (req, res) => {
681
+ const connection = ctx.get?.('connection')
682
+ const rejection =
683
+ connection && typeof connection.requestRejection === 'function'
684
+ ? connection.requestRejection(req)
685
+ : undefined
686
+ if (rejection !== undefined) {
687
+ res.statusCode = rejection
688
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
689
+ return false
690
+ }
691
+ return true
692
+ }
693
+
694
+ ctx.inject(['webServer'], (inner) => {
695
+ const webServer = inner.get('webServer') ?? inner.webServer
696
+ if (!webServer || typeof webServer.register !== 'function') return
697
+ const fenced = (req, res) => (fence(req, res) ? handler(req, res) : undefined)
698
+ inner.effect(
699
+ () => webServer.register({ kind: 'prefix', path: PREFIX, handler: fenced }),
700
+ 'dsh-ssh-workbench: web routes',
701
+ )
702
+ })
703
+
704
+ ctx.inject(['connection'], (inner) => {
705
+ const connection = inner.get('connection')
706
+ if (typeof connection?.fetch?.register !== 'function') return
707
+ const registered = []
708
+ for (const descriptor of fetchRoutes) {
709
+ try {
710
+ registered.push(connection.fetch.register(connectionRoute(descriptor, handler)))
711
+ } catch (error) {
712
+ ctx.logger?.warn?.('dsh-ssh-workbench: fetch route not registered', {
713
+ path: descriptor.path,
714
+ error: error instanceof Error ? error.message : String(error),
715
+ })
716
+ }
717
+ }
718
+ inner.effect(
719
+ () => () => registered.map((dispose) => {
720
+ try {
721
+ return dispose()
722
+ } catch {
723
+ return undefined
724
+ }
725
+ }),
726
+ 'dsh-ssh-workbench: fetch routes',
727
+ )
728
+ })
729
+
730
+ return () => {}
731
+ }
732
+
733
+