dsh-ssh-tunnel 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/session.js CHANGED
@@ -1,525 +1,616 @@
1
- /**
2
- * In-memory SSH session registry: live / tombstone / close.
3
- * Transport connect (host-key prompts) is injected as connectClient.
4
- */
5
- import { randomUUID } from 'node:crypto'
6
- import { chunkFromBuffer } from './shared/shell-buffer.js'
7
- import { normalizeProjectKey } from './shared/path.js'
8
- import {
9
- SSH_EXEC_DEFAULT_TIMEOUT_MS,
10
- SSH_READY_TIMEOUT_MS,
11
- SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
12
- SSH_RECONNECT_DELAYS_MS,
13
- SSH_RECONNECT_MAX_ATTEMPTS,
14
- SSH_SFTP_META_TIMEOUT_MS,
15
- SSH_SHELL_OPEN_TIMEOUT_MS,
16
- SSH_STATUS_CONNECTED,
17
- SSH_STATUS_DISCONNECTED,
18
- SSH_STATUS_RECONNECTING,
19
- clampTimeoutMs,
20
- findReusableLive,
21
- isLiveStatus,
22
- kbiReconnectError,
23
- kbiUnsupportedError,
24
- withTimeout,
25
- } from './shared/session-policy.js'
26
-
27
- const MAX_OUT = 512 * 1024
28
-
29
- async function openSftp(client, opts = {}) {
30
- return withTimeout(
31
- new Promise((resolve, reject) => {
32
- client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
33
- }),
34
- opts.timeoutMs ?? SSH_SFTP_META_TIMEOUT_MS,
35
- { signal: opts.signal },
36
- )
37
- }
38
-
39
- function applyStatus(rec, status) {
40
- rec.status = status
41
- rec.running = status === SSH_STATUS_CONNECTED
42
- rec.updatedAt = Date.now()
43
- }
44
-
45
- function tombstone(rec) {
46
- if (rec.closing) return
47
- applyStatus(rec, SSH_STATUS_DISCONNECTED)
48
- try {
49
- rec.shellStream?.close()
50
- } catch {}
51
- rec.shellStream = null
52
- rec.sftp = null
53
- rec.client = null
54
- }
55
-
56
- function attachTransport(rec, client, onDrop) {
57
- const handle = () => {
58
- if (rec.closing) return
59
- if (rec.client !== client) return
60
- onDrop(rec, client)
61
- }
62
- client.on('close', handle)
63
- client.on('end', handle)
64
- client.on('error', handle)
65
- }
66
-
67
- function sleep(ms, signal) {
68
- return new Promise((resolve, reject) => {
69
- const timer = setTimeout(resolve, ms)
70
- const onAbort = () => {
71
- clearTimeout(timer)
72
- const err = new Error('Cancelled')
73
- err.aborted = true
74
- reject(err)
75
- }
76
- if (!signal) return
77
- if (signal.aborted) {
78
- onAbort()
79
- return
80
- }
81
- signal.addEventListener('abort', onAbort, { once: true })
82
- })
83
- }
84
-
85
- function sessionSummary(s) {
86
- return {
87
- session_id: s.id,
88
- host_id: s.hostId,
89
- projectPathKey: s.projectPathKey,
90
- status: s.status,
91
- sftpEnabled: !!s.sftpEnabled,
92
- title: s.title,
93
- running: !!s.running,
94
- created_at: s.createdAt,
95
- updated_at: s.updatedAt,
96
- endpoint: s.endpoint || '',
97
- reconnect_attempt: s.reconnectAttempt || 0,
98
- reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS,
99
- }
100
- }
101
-
102
- function makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now }) {
103
- const outputBuf = []
104
- let outputBytes = 0
105
- const rec = {
106
- id,
107
- hostId: host.id,
108
- projectPathKey: normalizeProjectKey(projectPathKey),
109
- status: SSH_STATUS_CONNECTED,
110
- sftpEnabled: !!sftp,
111
- title: title || `SSH: ${host.name || host.host}`,
112
- running: true,
113
- createdAt: now,
114
- updatedAt: now,
115
- endpoint,
116
- client,
117
- sftp,
118
- shellStream: null,
119
- closing: false,
120
- reconnectRunner: false,
121
- reconnectAttempt: 0,
122
- connectionGeneration: 0,
123
- outputBuf,
124
- outputSeq: 0,
125
- pushOutput(chunk) {
126
- const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
127
- outputBuf.push(s)
128
- outputBytes += Buffer.byteLength(s)
129
- rec.outputSeq = (rec.outputSeq || 0) + 1
130
- while (outputBytes > MAX_OUT && outputBuf.length > 1) {
131
- outputBytes -= Buffer.byteLength(outputBuf.shift())
132
- }
133
- },
134
- getOutputText() {
135
- return outputBuf.join('')
136
- },
137
- chunkFrom(since) {
138
- const full = outputBuf.join('')
139
- const sliced = chunkFromBuffer(full, since)
140
- return { full, chunk: sliced.chunk, length: sliced.length, since: sliced.since }
141
- },
142
- }
143
- return rec
144
- }
145
-
146
- export function createSessionRegistry({
147
- connectClient,
148
- loadSecrets,
149
- getPrompt,
150
- getHostById,
151
- isHostAuthorized,
152
- }) {
153
- const sessions = new Map()
154
-
155
- function listSessionsForProject(projectPathKey) {
156
- const key = normalizeProjectKey(projectPathKey)
157
- return [...sessions.values()].filter((s) => s.projectPathKey === key).map(sessionSummary)
158
- }
159
-
160
- function findReusableSession(projectPathKey, hostId, needsSftp) {
161
- return findReusableLive([...sessions.values()], {
162
- projectPathKey: normalizeProjectKey(projectPathKey),
163
- hostId,
164
- needsSftp,
165
- })
166
- }
167
-
168
- async function createLiveSession(params) {
169
- const { host, projectPathKey, title, sftpEnabled, trustHostKey } = params
170
- const secrets = loadSecrets()
171
- const id = randomUUID()
172
- const now = Date.now()
173
- const endpoint = `${host.username}@${host.host}:${host.port || 22}`
174
-
175
- if (host.authType === 'keyboardInteractive') {
176
- throw new Error(kbiUnsupportedError())
177
- }
178
-
179
- let client
180
- try {
181
- client = await withTimeout(
182
- connectClient(host, secrets, {
183
- trustHostKey: !!trustHostKey,
184
- }),
185
- SSH_READY_TIMEOUT_MS,
186
- { signal: params.signal },
187
- )
188
- } catch (e) {
189
- if (e && e.message === 'SSH_PROMPT') {
190
- return {
191
- ok: false,
192
- sshPrompt: getPrompt?.(e.promptId),
193
- promptId: e.promptId,
194
- message: '请先在 SSH 隧道 Tab 手动完成连接/信任/MFA 后重试。',
195
- }
196
- }
197
- throw e
198
- }
199
-
200
- let sftp = null
201
- if (sftpEnabled !== false) {
202
- try {
203
- sftp = await openSftp(client)
204
- } catch {
205
- sftp = null
206
- }
207
- }
208
-
209
- const rec = makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now })
210
- rec.connectionGeneration += 1
211
- attachTransport(rec, client, onTransportDrop)
212
- sessions.set(id, rec)
213
- return { ok: true, session: sessionSummary(rec) }
214
- }
215
-
216
- function markReconnecting(rec, attempt) {
217
- rec.reconnectAttempt = attempt
218
- applyStatus(rec, SSH_STATUS_RECONNECTING)
219
- rec.sftp = null
220
- rec.shellStream = null
221
- }
222
-
223
- async function reconnectOnce(rec, { signal } = {}) {
224
- if (rec.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
225
- throw new Error('SSH session host is not authorized for the current project.')
226
- }
227
- const host = getHostById?.(rec.hostId)
228
- if (!host) throw new Error('host not found')
229
- if (host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
230
- const secrets = loadSecrets()
231
- try {
232
- rec.shellStream?.close()
233
- } catch {}
234
- rec.shellStream = null
235
- const oldClient = rec.client
236
- rec.client = null
237
- try {
238
- oldClient?.end()
239
- } catch {}
240
- const client = await withTimeout(
241
- connectClient(host, secrets, { trustHostKey: false }),
242
- SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
243
- { signal },
244
- )
245
- if (rec.closing) {
246
- try { client.end() } catch {}
247
- throw new Error('SSH session is closing')
248
- }
249
- let sftp = null
250
- if (rec.sftpEnabled) {
251
- try {
252
- sftp = await openSftp(client)
253
- } catch {
254
- sftp = null
255
- }
256
- }
257
- if (rec.closing) {
258
- try { client.end() } catch {}
259
- throw new Error('SSH session is closing')
260
- }
261
- rec.client = client
262
- rec.sftp = sftp
263
- rec.connectionGeneration += 1
264
- rec.reconnectAttempt = 0
265
- applyStatus(rec, SSH_STATUS_CONNECTED)
266
- attachTransport(rec, client, onTransportDrop)
267
- return sessionSummary(rec)
268
- }
269
-
270
- async function runAutoReconnect(rec) {
271
- if (rec.closing || rec.reconnectRunner) return
272
- const host = getHostById?.(rec.hostId)
273
- if (host && host.authType === 'keyboardInteractive') {
274
- tombstone(rec)
275
- return
276
- }
277
- rec.reconnectRunner = true
278
- try {
279
- for (let attempt = 1; attempt <= SSH_RECONNECT_MAX_ATTEMPTS; attempt++) {
280
- if (rec.closing) return
281
- if (
282
- rec.projectPathKey &&
283
- typeof isHostAuthorized === 'function' &&
284
- !isHostAuthorized(rec.projectPathKey, rec.hostId)
285
- ) {
286
- // Host was revoked while disconnected: tombstone it, never resurrect.
287
- tombstone(rec)
288
- return
289
- }
290
- markReconnecting(rec, attempt)
291
- const delay = SSH_RECONNECT_DELAYS_MS[attempt - 1] || SSH_RECONNECT_DELAYS_MS[SSH_RECONNECT_DELAYS_MS.length - 1]
292
- try {
293
- await sleep(delay)
294
- } catch {
295
- return
296
- }
297
- if (rec.closing) return
298
- try {
299
- await reconnectOnce(rec)
300
- return
301
- } catch {
302
- // try next attempt
303
- }
304
- }
305
- tombstone(rec)
306
- } finally {
307
- rec.reconnectRunner = false
308
- }
309
- }
310
-
311
- function onTransportDrop(rec) {
312
- if (rec.closing) return
313
- const host = getHostById?.(rec.hostId)
314
- if (host && host.authType === 'keyboardInteractive') {
315
- tombstone(rec)
316
- return
317
- }
318
- void runAutoReconnect(rec)
319
- }
320
-
321
- async function reconnectSession(sessionId, { manual = true, signal, projectPathKey } = {}) {
322
- const rec = sessions.get(String(sessionId || ''))
323
- if (!rec) throw new Error('session not found')
324
- const key = normalizeProjectKey(projectPathKey)
325
- if (!key) throw new Error('projectPathKey required')
326
- if (rec.projectPathKey !== key) throw new Error('session not in this project')
327
- if (rec.closing) throw new Error('SSH session is closing')
328
- if (isLiveStatus(rec.status, rec.running) && rec.client) return { ok: true, session: sessionSummary(rec) }
329
- if (rec.reconnectRunner) throw new Error('SSH reconnect already in progress')
330
- const host = getHostById?.(rec.hostId)
331
- if (host && host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
332
- rec.reconnectRunner = true
333
- try {
334
- markReconnecting(rec, 1)
335
- const session = await reconnectOnce(rec, { signal })
336
- return { ok: true, session }
337
- } catch (e) {
338
- tombstone(rec)
339
- throw e
340
- } finally {
341
- rec.reconnectRunner = false
342
- }
343
- }
344
-
345
- function waitForLive(sessionId, timeoutMs) {
346
- const rec = sessions.get(String(sessionId || ''))
347
- if (!rec) return Promise.reject(new Error('session not found'))
348
- if (isLiveStatus(rec.status, rec.running) && rec.client) return Promise.resolve(rec)
349
- const deadline = Date.now() + (Number(timeoutMs) || SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
350
- return new Promise((resolve, reject) => {
351
- const tick = () => {
352
- const cur = sessions.get(String(sessionId || ''))
353
- if (!cur) {
354
- reject(new Error('session not found'))
355
- return
356
- }
357
- if (isLiveStatus(cur.status, cur.running) && cur.client) {
358
- resolve(cur)
359
- return
360
- }
361
- if (cur.status === SSH_STATUS_DISCONNECTED || Date.now() >= deadline) {
362
- reject(new Error('session not running'))
363
- return
364
- }
365
- setTimeout(tick, 100)
366
- }
367
- tick()
368
- })
369
- }
370
-
371
- function requireLiveSession(sessionId, projectPathKey) {
372
- const s = sessions.get(String(sessionId || ''))
373
- if (!s) throw new Error('session not found')
374
- const key = normalizeProjectKey(projectPathKey)
375
- if (!key) throw new Error('projectPathKey required')
376
- if (s.projectPathKey !== key) throw new Error('session not in this project')
377
- if (!isLiveStatus(s.status, s.running) || !s.client) throw new Error('session not running')
378
- // Grant revocation must take effect on established sessions, not only new
379
- // connections. The session is bound to one project; re-check it on every use.
380
- if (s.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(s.projectPathKey, s.hostId)) {
381
- throw new Error('SSH session host is not authorized for the current project.')
382
- }
383
- return s
384
- }
385
-
386
- function closeSessionsForHost(projectPathKey, hostId) {
387
- const key = normalizeProjectKey(projectPathKey)
388
- const closed = []
389
- for (const s of [...sessions.values()]) {
390
- if (s.projectPathKey === key && s.hostId === String(hostId) && !s.closing) {
391
- closeSession(s.id, s.projectPathKey, { force: true })
392
- closed.push(s.id)
393
- }
394
- }
395
- return { closed: closed.length, sessionIds: closed }
396
- }
397
-
398
- function closeSession(sessionId, projectPathKey, { force = false } = {}) {
399
- const s = sessions.get(sessionId)
400
- if (!s) return { ok: false, error: 'session not found' }
401
- if (!force) {
402
- const key = normalizeProjectKey(projectPathKey)
403
- if (!key) throw new Error('projectPathKey required')
404
- if (s.projectPathKey !== key) throw new Error('session not in this project')
405
- }
406
- s.closing = true
407
- try {
408
- if (s.shellStream) s.shellStream.close()
409
- } catch {}
410
- try {
411
- s.client?.end()
412
- } catch {}
413
- applyStatus(s, SSH_STATUS_DISCONNECTED)
414
- sessions.delete(sessionId)
415
- return { ok: true }
416
- }
417
-
418
- async function execOnSession(session, command, opts = {}) {
419
- const client = session.client
420
- if (!isLiveStatus(session.status, session.running) || !client) {
421
- throw new Error('session not running')
422
- }
423
- const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_EXEC_DEFAULT_TIMEOUT_MS)
424
- const maxBytes = opts.maxBytes || 256 * 1024
425
- const cwd = opts.cwd
426
- const cmd =
427
- cwd && String(cwd).startsWith('/')
428
- ? `cd -- ${JSON.stringify(cwd)} && ${command}`
429
- : command
430
- let stream
431
- return withTimeout(
432
- new Promise((resolve, reject) => {
433
- client.exec(cmd, (err, st) => {
434
- if (err) {
435
- reject(err)
436
- return
437
- }
438
- stream = st
439
- let stdout = ''
440
- let stderr = ''
441
- stream.on('data', (d) => {
442
- stdout += d
443
- if (Buffer.byteLength(stdout) > maxBytes) stdout = stdout.slice(0, maxBytes)
444
- })
445
- stream.stderr.on('data', (d) => {
446
- stderr += d
447
- if (Buffer.byteLength(stderr) > maxBytes) stderr = stderr.slice(0, maxBytes)
448
- })
449
- stream.on('close', (code, sig) => {
450
- resolve({ stdout, stderr, code: code ?? 0, signal: sig || null })
451
- })
452
- stream.on('error', reject)
453
- })
454
- }),
455
- timeoutMs,
456
- {
457
- signal: opts.signal,
458
- onTimeout: () => {
459
- try {
460
- stream?.close()
461
- } catch {}
462
- try {
463
- stream?.destroy?.()
464
- } catch {}
465
- },
466
- },
467
- )
468
- }
469
-
470
- async function ensureShell(session, opts = {}) {
471
- if (session.shellStream) return session.shellStream
472
- if (!session.client) throw new Error('session not running')
473
- const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_SHELL_OPEN_TIMEOUT_MS)
474
- let opened
475
- let cancelled = false
476
- return withTimeout(
477
- new Promise((resolve, reject) => {
478
- session.client.shell({ term: 'xterm-color' }, (err, stream) => {
479
- if (cancelled) {
480
- try { stream?.close() } catch {}
481
- return
482
- }
483
- if (err) return reject(err)
484
- opened = stream
485
- session.shellStream = stream
486
- stream.on('data', (d) => session.pushOutput(d))
487
- stream.stderr?.on?.('data', (d) => session.pushOutput(d))
488
- stream.on('close', () => {
489
- session.shellStream = null
490
- })
491
- resolve(stream)
492
- })
493
- }),
494
- timeoutMs,
495
- {
496
- signal: opts.signal,
497
- onTimeout: () => {
498
- cancelled = true
499
- try { opened?.close() } catch {}
500
- try { opened?.destroy?.() } catch {}
501
- if (session.shellStream === opened) session.shellStream = null
502
- },
503
- },
504
- )
505
- }
506
-
507
- return {
508
- sessions,
509
- sessionSummary,
510
- listSessionsForProject,
511
- findReusableSession,
512
- createLiveSession,
513
- requireLiveSession,
514
- closeSession,
515
- closeSessionsForHost,
516
- execOnSession,
517
- ensureShell,
518
- openSftp,
519
- reconnectSession,
520
- waitForLive,
521
- get(id) {
522
- return sessions.get(id)
523
- },
524
- }
525
- }
1
+ /**
2
+ * In-memory SSH session registry: live / tombstone / close.
3
+ * Transport connect (host-key prompts) is injected as connectClient.
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import { normalizeProjectKey } from './shared/path.js'
7
+ import {
8
+ MAX_BYTES_EXEC_DEFAULT,
9
+ SSH_EXEC_DEFAULT_TIMEOUT_MS,
10
+ SSH_READY_TIMEOUT_MS,
11
+ SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
12
+ SSH_RECONNECT_DELAYS_MS,
13
+ SSH_RECONNECT_MAX_ATTEMPTS,
14
+ SSH_SFTP_META_TIMEOUT_MS,
15
+ SSH_SHELL_OPEN_TIMEOUT_MS,
16
+ SSH_STATUS_CONNECTED,
17
+ SSH_STATUS_DISCONNECTED,
18
+ SSH_STATUS_RECONNECTING,
19
+ clampMaxBytes,
20
+ clampTimeoutMs,
21
+ findReusableLive,
22
+ hostKeyPromptMessage,
23
+ isLiveStatus,
24
+ kbiReconnectError,
25
+ kbiUnsupportedError,
26
+ sshError,
27
+ withTimeout,
28
+ } from './shared/session-policy.js'
29
+
30
+ const MAX_OUT = 512 * 1024
31
+ // Per-shellRead response cap. A client that falls behind reads in bounded
32
+ // slices and keeps advancing its cursor instead of one unbounded dump.
33
+ const SHELL_READ_CHUNK_MAX = 256 * 1024
34
+
35
+ async function openSftp(client, opts = {}) {
36
+ return withTimeout(
37
+ new Promise((resolve, reject) => {
38
+ client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
39
+ }),
40
+ opts.timeoutMs ?? SSH_SFTP_META_TIMEOUT_MS,
41
+ { signal: opts.signal },
42
+ )
43
+ }
44
+
45
+ function applyStatus(rec, status) {
46
+ rec.status = status
47
+ rec.running = status === SSH_STATUS_CONNECTED
48
+ rec.updatedAt = Date.now()
49
+ }
50
+
51
+ function tombstone(rec) {
52
+ if (rec.closing) return
53
+ applyStatus(rec, SSH_STATUS_DISCONNECTED)
54
+ try {
55
+ rec.shellStream?.close()
56
+ } catch {}
57
+ rec.shellStream = null
58
+ rec.sftp = null
59
+ rec.client = null
60
+ }
61
+
62
+ function attachTransport(rec, client, onDrop) {
63
+ const handle = () => {
64
+ if (rec.closing) return
65
+ if (rec.client !== client) return
66
+ onDrop(rec, client)
67
+ }
68
+ client.on('close', handle)
69
+ client.on('end', handle)
70
+ client.on('error', handle)
71
+ }
72
+
73
+ function sleep(ms, signal) {
74
+ return new Promise((resolve, reject) => {
75
+ const timer = setTimeout(resolve, ms)
76
+ const onAbort = () => {
77
+ clearTimeout(timer)
78
+ const err = new Error('Cancelled')
79
+ err.aborted = true
80
+ reject(err)
81
+ }
82
+ if (!signal) return
83
+ if (signal.aborted) {
84
+ onAbort()
85
+ return
86
+ }
87
+ signal.addEventListener('abort', onAbort, { once: true })
88
+ })
89
+ }
90
+
91
+ function sessionSummary(s) {
92
+ return {
93
+ session_id: s.id,
94
+ host_id: s.hostId,
95
+ projectPathKey: s.projectPathKey,
96
+ status: s.status,
97
+ sftpEnabled: !!s.sftpEnabled,
98
+ title: s.title,
99
+ running: !!s.running,
100
+ created_at: s.createdAt,
101
+ updated_at: s.updatedAt,
102
+ endpoint: s.endpoint || '',
103
+ reconnect_attempt: s.reconnectAttempt || 0,
104
+ reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS,
105
+ }
106
+ }
107
+
108
+ function makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now }) {
109
+ const key = normalizeProjectKey(projectPathKey)
110
+ if (!key) throw new Error('projectPathKey required')
111
+ // Each retained chunk carries its push order (seq). Clients cursor by seq,
112
+ // so ring eviction can no longer pin their read position to the buffer tail:
113
+ // after eviction the reader sees baseSeq jump forward and `dropped: true`.
114
+ const outputBuf = []
115
+ let outputBytes = 0
116
+ let outputSeq = 0
117
+ const rec = {
118
+ id,
119
+ hostId: host.id,
120
+ projectPathKey: key,
121
+ status: SSH_STATUS_CONNECTED,
122
+ sftpEnabled: !!sftp,
123
+ title: title || `SSH: ${host.name || host.host}`,
124
+ running: true,
125
+ createdAt: now,
126
+ updatedAt: now,
127
+ endpoint,
128
+ client,
129
+ sftp,
130
+ shellStream: null,
131
+ closing: false,
132
+ reconnectRunner: false,
133
+ reconnectAttempt: 0,
134
+ connectionGeneration: 0,
135
+ outputBuf,
136
+ outputSeq: 0,
137
+ pushOutput(chunk) {
138
+ const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
139
+ outputBuf.push({ text: s, seq: ++outputSeq })
140
+ rec.outputSeq = outputSeq
141
+ outputBytes += Buffer.byteLength(s)
142
+ while (outputBytes > MAX_OUT && outputBuf.length > 1) {
143
+ outputBytes -= Buffer.byteLength(outputBuf.shift().text)
144
+ }
145
+ },
146
+ getOutputText() {
147
+ return outputBuf.map((c) => c.text).join('')
148
+ },
149
+ /**
150
+ * `since` is the last seq the reader has already seen. Returns everything
151
+ * newer, capped at SHELL_READ_CHUNK_MAX bytes per call.
152
+ */
153
+ chunkFrom(since) {
154
+ const last = Number.isFinite(Number(since)) && Number(since) >= 0 ? Math.floor(Number(since)) : 0
155
+ const base = outputBuf.length ? outputBuf[0].seq : outputSeq + 1
156
+ const dropped = outputSeq > 0 && base > last + 1
157
+ const parts = []
158
+ let bytes = 0
159
+ let upto = last
160
+ let chunkTruncated = false
161
+ for (const c of outputBuf) {
162
+ if (c.seq <= last) continue
163
+ const b = Buffer.byteLength(c.text)
164
+ if (parts.length && bytes + b > SHELL_READ_CHUNK_MAX) {
165
+ chunkTruncated = true
166
+ break
167
+ }
168
+ parts.push(c.text)
169
+ bytes += b
170
+ upto = c.seq
171
+ if (bytes > SHELL_READ_CHUNK_MAX) chunkTruncated = true
172
+ }
173
+ return {
174
+ full: parts.join(''),
175
+ chunk: parts.join(''),
176
+ length: bytes,
177
+ since: upto,
178
+ seq: outputSeq,
179
+ baseSeq: base,
180
+ dropped,
181
+ chunkTruncated,
182
+ }
183
+ },
184
+ }
185
+ return rec
186
+ }
187
+
188
+ export function createSessionRegistry({
189
+ connectClient,
190
+ loadSecrets,
191
+ getPrompt,
192
+ getHostById,
193
+ isHostAuthorized,
194
+ }) {
195
+ const sessions = new Map()
196
+
197
+ function listSessionsForProject(projectPathKey) {
198
+ const key = normalizeProjectKey(projectPathKey)
199
+ return [...sessions.values()].filter((s) => s.projectPathKey === key).map(sessionSummary)
200
+ }
201
+
202
+ function findReusableSession(projectPathKey, hostId, needsSftp) {
203
+ return findReusableLive([...sessions.values()], {
204
+ projectPathKey: normalizeProjectKey(projectPathKey),
205
+ hostId,
206
+ needsSftp,
207
+ })
208
+ }
209
+
210
+ async function createLiveSession(params) {
211
+ const { host, projectPathKey, title, sftpEnabled, trustHostKey } = params
212
+ const secrets = loadSecrets()
213
+ const id = randomUUID()
214
+ const now = Date.now()
215
+ const endpoint = `${host.username}@${host.host}:${host.port || 22}`
216
+
217
+ if (host.authType === 'keyboardInteractive') {
218
+ throw sshError('bad_request', kbiUnsupportedError())
219
+ }
220
+
221
+ let client
222
+ try {
223
+ client = await withTimeout(
224
+ connectClient(host, secrets, {
225
+ trustHostKey: !!trustHostKey,
226
+ projectPathKey,
227
+ }),
228
+ SSH_READY_TIMEOUT_MS,
229
+ { signal: params.signal },
230
+ )
231
+ } catch (e) {
232
+ if (e && e.message === 'SSH_PROMPT') {
233
+ return {
234
+ ok: false,
235
+ sshPrompt: getPrompt?.(e.promptId),
236
+ promptId: e.promptId,
237
+ message: hostKeyPromptMessage(),
238
+ }
239
+ }
240
+ throw e
241
+ }
242
+
243
+ let sftp = null
244
+ if (sftpEnabled !== false) {
245
+ try {
246
+ sftp = await openSftp(client)
247
+ } catch {
248
+ sftp = null
249
+ }
250
+ }
251
+
252
+ const rec = makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now })
253
+ rec.connectionGeneration += 1
254
+ attachTransport(rec, client, onTransportDrop)
255
+ sessions.set(id, rec)
256
+ return { ok: true, session: sessionSummary(rec) }
257
+ }
258
+
259
+ function markReconnecting(rec, attempt) {
260
+ rec.reconnectAttempt = attempt
261
+ applyStatus(rec, SSH_STATUS_RECONNECTING)
262
+ rec.sftp = null
263
+ rec.shellStream = null
264
+ }
265
+
266
+ async function reconnectOnce(rec, { signal } = {}) {
267
+ if (typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
268
+ throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
269
+ }
270
+ const host = getHostById?.(rec.hostId)
271
+ if (!host) throw sshError('not_found', 'host not found')
272
+ if (host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
273
+ const secrets = loadSecrets()
274
+ try {
275
+ rec.shellStream?.close()
276
+ } catch {}
277
+ rec.shellStream = null
278
+ const oldClient = rec.client
279
+ rec.client = null
280
+ try {
281
+ oldClient?.end()
282
+ } catch {}
283
+ let client
284
+ try {
285
+ client = await withTimeout(
286
+ connectClient(host, secrets, { trustHostKey: false, projectPathKey: rec.projectPathKey }),
287
+ SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
288
+ { signal },
289
+ )
290
+ } catch (e) {
291
+ if (e && e.message === 'SSH_PROMPT') {
292
+ // Same shape the create path maps for the tab UI; without this the
293
+ // reconnect path loses the promptId in a generic 500.
294
+ const err = sshError('conflict', hostKeyPromptMessage())
295
+ err.isPromptError = true
296
+ err.promptId = e.promptId
297
+ throw err
298
+ }
299
+ throw e
300
+ }
301
+ if (rec.closing) {
302
+ try { client.end() } catch {}
303
+ throw sshError('conflict', 'SSH session is closing')
304
+ }
305
+ let sftp = null
306
+ if (rec.sftpEnabled) {
307
+ try {
308
+ sftp = await openSftp(client)
309
+ } catch {
310
+ sftp = null
311
+ }
312
+ }
313
+ if (rec.closing) {
314
+ try { client.end() } catch {}
315
+ throw sshError('conflict', 'SSH session is closing')
316
+ }
317
+ rec.client = client
318
+ rec.sftp = sftp
319
+ rec.connectionGeneration += 1
320
+ rec.reconnectAttempt = 0
321
+ applyStatus(rec, SSH_STATUS_CONNECTED)
322
+ attachTransport(rec, client, onTransportDrop)
323
+ return sessionSummary(rec)
324
+ }
325
+
326
+ async function runAutoReconnect(rec) {
327
+ if (rec.closing || rec.reconnectRunner) return
328
+ const host = getHostById?.(rec.hostId)
329
+ if (host && host.authType === 'keyboardInteractive') {
330
+ tombstone(rec)
331
+ return
332
+ }
333
+ rec.reconnectRunner = true
334
+ try {
335
+ for (let attempt = 1; attempt <= SSH_RECONNECT_MAX_ATTEMPTS; attempt++) {
336
+ if (rec.closing) return
337
+ if (typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
338
+ // Host was revoked while disconnected: tombstone it, never resurrect.
339
+ tombstone(rec)
340
+ return
341
+ }
342
+ markReconnecting(rec, attempt)
343
+ const delay = SSH_RECONNECT_DELAYS_MS[attempt - 1] || SSH_RECONNECT_DELAYS_MS[SSH_RECONNECT_DELAYS_MS.length - 1]
344
+ try {
345
+ await sleep(delay)
346
+ } catch {
347
+ return
348
+ }
349
+ if (rec.closing) return
350
+ try {
351
+ await reconnectOnce(rec)
352
+ return
353
+ } catch (e) {
354
+ // A host-key/MFA prompt cannot self-resolve by retrying: park the
355
+ // session and wait for the manual reconnect after confirmation.
356
+ if (e && e.isPromptError) {
357
+ tombstone(rec)
358
+ return
359
+ }
360
+ }
361
+ }
362
+ tombstone(rec)
363
+ } finally {
364
+ rec.reconnectRunner = false
365
+ }
366
+ }
367
+
368
+ function onTransportDrop(rec) {
369
+ if (rec.closing) return
370
+ const host = getHostById?.(rec.hostId)
371
+ if (host && host.authType === 'keyboardInteractive') {
372
+ tombstone(rec)
373
+ return
374
+ }
375
+ void runAutoReconnect(rec)
376
+ }
377
+
378
+ async function reconnectSession(sessionId, { manual = true, signal, projectPathKey } = {}) {
379
+ const rec = sessions.get(String(sessionId || ''))
380
+ if (!rec) throw sshError('not_found', 'session not found')
381
+ const key = normalizeProjectKey(projectPathKey)
382
+ if (!key) throw sshError('bad_request', 'projectPathKey required')
383
+ if (rec.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
384
+ if (rec.closing) throw sshError('conflict', 'SSH session is closing')
385
+ if (isLiveStatus(rec.status, rec.running) && rec.client) return { ok: true, session: sessionSummary(rec) }
386
+ if (rec.reconnectRunner) throw sshError('conflict', 'SSH reconnect already in progress')
387
+ const host = getHostById?.(rec.hostId)
388
+ if (host && host.authType === 'keyboardInteractive') throw sshError('bad_request', kbiReconnectError())
389
+ rec.reconnectRunner = true
390
+ try {
391
+ markReconnecting(rec, 1)
392
+ const session = await reconnectOnce(rec, { signal })
393
+ return { ok: true, session }
394
+ } catch (e) {
395
+ tombstone(rec)
396
+ throw e
397
+ } finally {
398
+ rec.reconnectRunner = false
399
+ }
400
+ }
401
+
402
+ function waitForLive(sessionId, timeoutMs) {
403
+ const rec = sessions.get(String(sessionId || ''))
404
+ if (!rec) return Promise.reject(sshError('not_found', 'session not found'))
405
+ if (isLiveStatus(rec.status, rec.running) && rec.client) return Promise.resolve(rec)
406
+ const deadline = Date.now() + (Number(timeoutMs) || SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
407
+ return new Promise((resolve, reject) => {
408
+ const tick = () => {
409
+ const cur = sessions.get(String(sessionId || ''))
410
+ if (!cur) {
411
+ reject(sshError('not_found', 'session not found'))
412
+ return
413
+ }
414
+ if (isLiveStatus(cur.status, cur.running) && cur.client) {
415
+ resolve(cur)
416
+ return
417
+ }
418
+ if (cur.status === SSH_STATUS_DISCONNECTED || Date.now() >= deadline) {
419
+ reject(sshError('conflict', 'session not running'))
420
+ return
421
+ }
422
+ setTimeout(tick, 100)
423
+ }
424
+ tick()
425
+ })
426
+ }
427
+
428
+ function requireLiveSession(sessionId, projectPathKey) {
429
+ const s = sessions.get(String(sessionId || ''))
430
+ if (!s) throw sshError('not_found', 'session not found')
431
+ const key = normalizeProjectKey(projectPathKey)
432
+ if (!key) throw sshError('bad_request', 'projectPathKey required')
433
+ if (s.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
434
+ if (!isLiveStatus(s.status, s.running) || !s.client) throw sshError('conflict', 'session not running')
435
+ // Grant revocation must take effect on established sessions, not only new
436
+ // connections. The session is bound to one project; re-check it on every use.
437
+ if (typeof isHostAuthorized === 'function' && !isHostAuthorized(s.projectPathKey, s.hostId)) {
438
+ throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
439
+ }
440
+ return s
441
+ }
442
+
443
+ function closeSessionsForHost(projectPathKey, hostId) {
444
+ const key = normalizeProjectKey(projectPathKey)
445
+ const closed = []
446
+ for (const s of [...sessions.values()]) {
447
+ if (s.projectPathKey === key && s.hostId === String(hostId) && !s.closing) {
448
+ closeSession(s.id, s.projectPathKey, { force: true })
449
+ closed.push(s.id)
450
+ }
451
+ }
452
+ return { closed: closed.length, sessionIds: closed }
453
+ }
454
+
455
+ function closeSession(sessionId, projectPathKey, { force = false } = {}) {
456
+ const s = sessions.get(sessionId)
457
+ if (!s) throw sshError('not_found', 'session not found')
458
+ if (!force) {
459
+ const key = normalizeProjectKey(projectPathKey)
460
+ if (!key) throw sshError('bad_request', 'projectPathKey required')
461
+ if (s.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
462
+ }
463
+ s.closing = true
464
+ try {
465
+ if (s.shellStream) s.shellStream.close()
466
+ } catch {}
467
+ try {
468
+ s.client?.end()
469
+ } catch {}
470
+ applyStatus(s, SSH_STATUS_DISCONNECTED)
471
+ sessions.delete(sessionId)
472
+ return { ok: true }
473
+ }
474
+
475
+ async function execOnSession(session, command, opts = {}) {
476
+ // Check before dialing, not only when the result comes back: the remote
477
+ // side must not see a single byte of work from a revoked host.
478
+ if (typeof isHostAuthorized === 'function' && !isHostAuthorized(session.projectPathKey, session.hostId)) {
479
+ throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
480
+ }
481
+ const client = session.client
482
+ if (!isLiveStatus(session.status, session.running) || !client) {
483
+ throw sshError('conflict', 'session not running')
484
+ }
485
+ const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_EXEC_DEFAULT_TIMEOUT_MS)
486
+ const maxBytes = clampMaxBytes(opts.maxBytes, MAX_BYTES_EXEC_DEFAULT)
487
+ const cwd = opts.cwd ? String(opts.cwd).trim() : ''
488
+ if (cwd && !cwd.startsWith('/')) {
489
+ throw sshError('bad_request', 'cwd must be an absolute POSIX path on the remote host')
490
+ }
491
+ const cmd = cwd ? `cd -- '${cwd.replace(/'/g, `'\\''`)}' && ${command}` : command
492
+ let stream
493
+ return withTimeout(
494
+ new Promise((resolve, reject) => {
495
+ client.exec(cmd, (err, st) => {
496
+ if (err) {
497
+ reject(err)
498
+ return
499
+ }
500
+ stream = st
501
+ // Aggregate raw bytes and decode once at the end: a multi-byte UTF-8
502
+ // sequence split across data events must not become U+FFFD.
503
+ const outParts = []
504
+ const errParts = []
505
+ let outTotal = 0
506
+ let errTotal = 0
507
+ let outKept = 0
508
+ let errKept = 0
509
+ stream.on('data', (d) => {
510
+ outTotal += d.length
511
+ if (outKept < maxBytes) {
512
+ outParts.push(d)
513
+ outKept += d.length
514
+ }
515
+ })
516
+ stream.stderr.on('data', (d) => {
517
+ errTotal += d.length
518
+ if (errKept < maxBytes) {
519
+ errParts.push(d)
520
+ errKept += d.length
521
+ }
522
+ })
523
+ stream.on('close', (code, sig) => {
524
+ const toText = (parts) => {
525
+ let buf = Buffer.concat(parts)
526
+ if (buf.length > maxBytes) buf = buf.subarray(0, maxBytes)
527
+ return buf.toString('utf8')
528
+ }
529
+ const exitKnown = typeof code === 'number'
530
+ resolve({
531
+ stdout: toText(outParts),
532
+ stderr: toText(errParts),
533
+ exitKnown,
534
+ code: exitKnown ? code : null,
535
+ signal: exitKnown ? null : sig || null,
536
+ // ssh2 sends exit-status on normal exit and exit-signal on signal
537
+ // death. Neither arrives when the connection itself was cut.
538
+ connectionDropped: !exitKnown && !sig,
539
+ truncated: outTotal > maxBytes || errTotal > maxBytes,
540
+ cwd: cwd || null,
541
+ })
542
+ })
543
+ stream.on('error', reject)
544
+ })
545
+ }),
546
+ timeoutMs,
547
+ {
548
+ signal: opts.signal,
549
+ onTimeout: () => {
550
+ try {
551
+ stream?.close()
552
+ } catch {}
553
+ try {
554
+ stream?.destroy?.()
555
+ } catch {}
556
+ },
557
+ },
558
+ )
559
+ }
560
+
561
+ async function ensureShell(session, opts = {}) {
562
+ if (session.shellStream) return session.shellStream
563
+ if (!session.client) throw sshError('conflict', 'session not running')
564
+ const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_SHELL_OPEN_TIMEOUT_MS)
565
+ let opened
566
+ let cancelled = false
567
+ return withTimeout(
568
+ new Promise((resolve, reject) => {
569
+ session.client.shell({ term: 'xterm-color' }, (err, stream) => {
570
+ if (cancelled) {
571
+ try { stream?.close() } catch {}
572
+ return
573
+ }
574
+ if (err) return reject(err)
575
+ opened = stream
576
+ session.shellStream = stream
577
+ stream.on('data', (d) => session.pushOutput(d))
578
+ stream.stderr?.on?.('data', (d) => session.pushOutput(d))
579
+ stream.on('close', () => {
580
+ session.shellStream = null
581
+ })
582
+ resolve(stream)
583
+ })
584
+ }),
585
+ timeoutMs,
586
+ {
587
+ signal: opts.signal,
588
+ onTimeout: () => {
589
+ cancelled = true
590
+ try { opened?.close() } catch {}
591
+ try { opened?.destroy?.() } catch {}
592
+ if (session.shellStream === opened) session.shellStream = null
593
+ },
594
+ },
595
+ )
596
+ }
597
+
598
+ return {
599
+ sessions,
600
+ sessionSummary,
601
+ listSessionsForProject,
602
+ findReusableSession,
603
+ createLiveSession,
604
+ requireLiveSession,
605
+ closeSession,
606
+ closeSessionsForHost,
607
+ execOnSession,
608
+ ensureShell,
609
+ openSftp,
610
+ reconnectSession,
611
+ waitForLive,
612
+ get(id) {
613
+ return sessions.get(id)
614
+ },
615
+ }
616
+ }