dsh-plugin-ssh-console 0.0.0-stage → 1.0.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.
package/index.js ADDED
@@ -0,0 +1,1495 @@
1
+ /**
2
+ * SSH Console bundle, Host half.
3
+ *
4
+ * A read-mostly remote-console surface for the Harness Web UI: one saved list of
5
+ * SSH targets, one live connection, and four operations over it — a read-only
6
+ * directory browser, a file preview, an nvitop-style resource sampler, and a
7
+ * command runner.
8
+ *
9
+ * Everything here is a plain Node module. Like every workspace-linked bundle it
10
+ * resolves bare specifiers from its own directory, so `ssh2` is a real dependency
11
+ * of this package rather than a Harness package; the Remote binding and the method
12
+ * marker are assembled structurally, matching what `@deepseek-ai/dsh-typert-protocol`
13
+ * produces for generated code.
14
+ *
15
+ * @module dsh-plugin-ssh-console
16
+ */
17
+
18
+ import { readFileSync, existsSync, statSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import { Client } from 'ssh2'
22
+
23
+ /** Prototype key `remoteMethods()` reads for one Service's Remote markers. */
24
+ const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-methods'
25
+ /** Cordis service key; also the wire namespace, like `workspaceController` → `workspace`. */
26
+ const SERVICE_KEY = 'sshConsole'
27
+ /** Every Remote endpoint this Service publishes. */
28
+ const REMOTE_METHODS = Object.freeze([
29
+ 'servers',
30
+ 'connect',
31
+ 'disconnect',
32
+ 'status',
33
+ 'listDir',
34
+ 'readText',
35
+ 'metrics',
36
+ 'exec',
37
+ 'termOpen',
38
+ 'termList',
39
+ 'termRead',
40
+ 'termWrite',
41
+ 'termResize',
42
+ 'termClose',
43
+ ])
44
+ /** Default cadence the Client is told to poll at, in milliseconds. */
45
+ const DEFAULT_REFRESH_MS = 2000
46
+ /** Default ceiling for one command run from the console. */
47
+ const DEFAULT_EXEC_TIMEOUT_MS = 60000
48
+ /** Hard ceiling for `head -c` on a file preview. */
49
+ const MAX_PREVIEW_BYTES = 256 * 1024
50
+ /** Ceiling for one command's captured output, keeping the tail. */
51
+ const MAX_CAPTURED_BYTES = 512 * 1024
52
+ /** Entries returned for one directory listing. */
53
+ const MAX_DIR_ENTRIES = 400
54
+ /**
55
+ * Ceiling for the GPU process list.
56
+ *
57
+ * A busy eight-GPU host with containerised workloads can run a few hundred
58
+ * compute processes, so this is set well above any realistic count: it exists to
59
+ * bound the payload, not to trim the view. Every row survives on this host.
60
+ */
61
+ const MAX_GPU_PROCESSES = 512
62
+ /** Retained terminal scrollback per session, in bytes. */
63
+ const MAX_TERM_SCROLLBACK_BYTES = 512 * 1024
64
+ /**
65
+ * Terminals one server may hold at once.
66
+ *
67
+ * Each is a full login with its own scrollback, so this bounds both the remote
68
+ * process count and Host memory. Six is enough for a monitoring shell plus a few
69
+ * working ones, and keeps a runaway loop of "new terminal" from exhausting the
70
+ * connection.
71
+ */
72
+ const MAX_TERMINALS_PER_SERVER = 6
73
+ /** Fallback identity files, in the order OpenSSH tries them. */
74
+ const DEFAULT_IDENTITIES = ['id_ed25519', 'id_rsa', 'id_ecdsa']
75
+
76
+ /**
77
+ * Separator between a server id and a terminal id in the terminal registry.
78
+ *
79
+ * A control character cannot occur in a host alias or a derived server id, so a
80
+ * plain `startsWith` splits the key without ambiguity.
81
+ */
82
+ const TERM_KEY_SEPARATOR = '\u0000'
83
+
84
+ /**
85
+ * Compose the registry key for one terminal.
86
+ * @param serverId - the server the terminal belongs to.
87
+ * @param termId - the terminal's own id.
88
+ * @returns the map key.
89
+ */
90
+ function terminalKey(serverId, termId) {
91
+ return `${serverId}${TERM_KEY_SEPARATOR}${termId}`
92
+ }
93
+
94
+ /**
95
+ * Pick the lowest free `t<n>` id.
96
+ * @param taken - the ids already in use on this server.
97
+ * @returns a fresh id.
98
+ */
99
+ function nextTerminalId(taken) {
100
+ const used = new Set(taken)
101
+ for (let index = 1; index <= MAX_TERMINALS_PER_SERVER + 1; index += 1) {
102
+ const candidate = `t${index}`
103
+ if (!used.has(candidate)) return candidate
104
+ }
105
+ return `t${used.size + 1}`
106
+ }
107
+
108
+ /**
109
+ * Build a failure the Gateway forwards verbatim. The Gateway identifies Remote
110
+ * failures structurally (`isDSHRemoteError` + `code`), never by class identity.
111
+ * @param code - stable Remote failure code.
112
+ * @param message - user-facing diagnostic.
113
+ * @param details - structured payload.
114
+ * @returns the marked error.
115
+ */
116
+ function remoteFailure(code, message, details = {}) {
117
+ return Object.assign(new Error(message), {
118
+ name: 'SshConsoleError',
119
+ code,
120
+ details,
121
+ isDSHRemoteError: true,
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Quote one value for a POSIX shell.
127
+ * @param value - raw text.
128
+ * @returns the single-quoted form.
129
+ */
130
+ function shq(value) {
131
+ return `'${String(value).replaceAll("'", `'\\''`)}'`
132
+ }
133
+
134
+ /**
135
+ * Build a shell expression for a remote path, keeping `~` expandable.
136
+ *
137
+ * Quoting `~` would ask the shell for a directory literally named `~`, so the
138
+ * home directory is passed to the shell as `$HOME` instead.
139
+ * @param path - the path as typed by a person.
140
+ * @returns a shell expression evaluating to that path.
141
+ */
142
+ function remotePathExpr(path) {
143
+ const text = String(path)
144
+ if (text === '~') return '"$HOME"'
145
+ if (text.startsWith('~/')) return `"$HOME"${shq(text.slice(1))}`
146
+ return shq(text)
147
+ }
148
+
149
+ /**
150
+ * Clamp a number into an interval, falling back when it is not a number.
151
+ * @param value - candidate.
152
+ * @param fallback - value to use when `value` is not finite.
153
+ * @param min - lower bound.
154
+ * @param max - upper bound.
155
+ * @returns the clamped number.
156
+ */
157
+ function clampNumber(value, fallback, min, max) {
158
+ const number = typeof value === 'number' && Number.isFinite(value) ? value : fallback
159
+ return Math.min(max, Math.max(min, number))
160
+ }
161
+
162
+ /**
163
+ * Expand a leading `~` and resolve an identity path.
164
+ * @param path - configured path, possibly relative to the home directory.
165
+ * @returns an absolute path.
166
+ */
167
+ function expandHome(path) {
168
+ if (typeof path !== 'string' || path.length === 0) return undefined
169
+ const trimmed = path.trim()
170
+ if (trimmed === '~') return homedir()
171
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2))
172
+ return trimmed
173
+ }
174
+
175
+ /* ------------------------------------------------------------- ssh config */
176
+
177
+ /**
178
+ * Read `~/.ssh/config` into connect targets.
179
+ *
180
+ * Only the fields this plugin needs are understood; `Include`, `Match` and every
181
+ * other directive are ignored, and wildcard patterns are skipped because they
182
+ * describe defaults rather than a server a person would pick from a list.
183
+ * @returns the declared hosts, in file order.
184
+ */
185
+ function readSshConfigHosts() {
186
+ const path = join(homedir(), '.ssh', 'config')
187
+ let text
188
+ try {
189
+ if (!existsSync(path)) return []
190
+ text = readFileSync(path, 'utf8')
191
+ } catch {
192
+ return []
193
+ }
194
+ const hosts = []
195
+ let current
196
+ const flush = () => {
197
+ if (current === undefined) return
198
+ const host = current.host ?? current.label
199
+ if (host.length > 0) {
200
+ const port = clampNumber(current.port, 22, 1, 65535)
201
+ hosts.push({
202
+ id: `${current.user ?? ''}@${host}:${port}`,
203
+ label: current.label,
204
+ host,
205
+ port,
206
+ user: current.user,
207
+ identityFile: current.identityFile,
208
+ source: 'ssh-config',
209
+ })
210
+ }
211
+ current = undefined
212
+ }
213
+ for (const rawLine of text.split(/\r?\n/)) {
214
+ const line = rawLine.trim()
215
+ if (line.length === 0 || line.startsWith('#')) continue
216
+ const match = /^(\w+)\s+(.*)$/.exec(line)
217
+ if (match === null) continue
218
+ const keyword = match[1].toLowerCase()
219
+ const value = match[2].trim()
220
+ if (keyword === 'host') {
221
+ flush()
222
+ const patterns = value.split(/\s+/)
223
+ const literal = patterns.filter((pattern) => !/[*?!]/.test(pattern))
224
+ // A block made only of wildcards (`Host *`) carries defaults, not a target.
225
+ if (literal.length === 0) continue
226
+ current = { label: literal[0], host: undefined, port: 22, user: undefined, identityFile: undefined }
227
+ continue
228
+ }
229
+ if (current === undefined) continue
230
+ if (keyword === 'hostname') current.host = value
231
+ else if (keyword === 'port') current.port = Number(value)
232
+ else if (keyword === 'user') current.user = value
233
+ else if (keyword === 'identityfile') current.identityFile = value
234
+ }
235
+ flush()
236
+ return hosts
237
+ }
238
+
239
+ /* --------------------------------------------------------------- sampling */
240
+
241
+ /**
242
+ * Split a `###SECTION` framed response into its sections.
243
+ * @param text - the remote script's combined output.
244
+ * @returns one array of lines per section name.
245
+ */
246
+ function splitSections(text) {
247
+ const sections = new Map()
248
+ let name
249
+ let buffer = []
250
+ const commit = () => {
251
+ if (name === undefined) return
252
+ sections.set(name, buffer)
253
+ buffer = []
254
+ }
255
+ for (const line of String(text).split(/\r?\n/)) {
256
+ const marker = /^###([A-Z]+)\s*$/.exec(line)
257
+ if (marker !== null) {
258
+ commit()
259
+ name = marker[1]
260
+ continue
261
+ }
262
+ if (name !== undefined) buffer.push(line)
263
+ }
264
+ commit()
265
+ return sections
266
+ }
267
+
268
+ /**
269
+ * Read one section as trimmed lines with trailing blanks dropped.
270
+ * @param sections - framed sections.
271
+ * @param name - section name.
272
+ * @returns its lines.
273
+ */
274
+ function sectionLines(sections, name) {
275
+ const lines = sections.get(name) ?? []
276
+ const out = []
277
+ for (const line of lines) out.push(line)
278
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
279
+ return out
280
+ }
281
+
282
+ /**
283
+ * Read the first line of a section.
284
+ * @param sections - framed sections.
285
+ * @param name - section name.
286
+ * @returns the trimmed line, or an empty string.
287
+ */
288
+ function sectionLine(sections, name) {
289
+ const lines = sectionLines(sections, name)
290
+ return lines.length > 0 ? lines[0].trim() : ''
291
+ }
292
+
293
+ /**
294
+ * Parse `/proc/stat`'s aggregate CPU line into busy/idle totals.
295
+ * @param line - the `cpu …` line.
296
+ * @returns totals, or undefined when the line does not match.
297
+ */
298
+ function parseCpuStat(line) {
299
+ const fields = String(line).trim().split(/\s+/)
300
+ if (fields[0] !== 'cpu' || fields.length < 8) return undefined
301
+ const numbers = fields.slice(1, 9).map((value) => Number(value))
302
+ if (numbers.some((value) => !Number.isFinite(value))) return undefined
303
+ const [user, nice, system, idle, iowait, irq, softirq, steal] = numbers
304
+ const total = user + nice + system + idle + iowait + irq + softirq + steal
305
+ return { total, idle: idle + iowait }
306
+ }
307
+
308
+ /**
309
+ * Turn two `/proc/stat` samples into a utilization percentage.
310
+ * @param previous - the earlier sample.
311
+ * @param current - the later sample.
312
+ * @returns busy percentage, or undefined without a usable delta.
313
+ */
314
+ function cpuPercent(previous, current) {
315
+ if (previous === undefined || current === undefined) return undefined
316
+ const total = current.total - previous.total
317
+ const idle = current.idle - previous.idle
318
+ if (!Number.isFinite(total) || total <= 0) return undefined
319
+ return Math.max(0, Math.min(100, (1 - idle / total) * 100))
320
+ }
321
+
322
+ /**
323
+ * Parse a `nvidia-smi --query-gpu` CSV row.
324
+ *
325
+ * The device name is free text and may itself contain commas, so the fixed-width
326
+ * numeric tail is taken from the end and everything between is the name.
327
+ * @param line - one CSV row of `index,name,util,memused,memtotal,temp,power`.
328
+ * @returns the parsed device, or undefined for a line that does not match.
329
+ */
330
+ function parseGpuRow(line) {
331
+ const parts = String(line).split(',').map((part) => part.trim())
332
+ if (parts.length < 7) return undefined
333
+ const index = Number(parts[0])
334
+ if (!Number.isFinite(index)) return undefined
335
+ const tail = parts.slice(-5)
336
+ const numeric = (value) => {
337
+ const number = Number(value)
338
+ return Number.isFinite(number) ? number : undefined
339
+ }
340
+ return {
341
+ index,
342
+ name: parts.slice(1, parts.length - 5).join(', '),
343
+ utilization: numeric(tail[0]),
344
+ memoryUsedMiB: numeric(tail[1]),
345
+ memoryTotalMiB: numeric(tail[2]),
346
+ temperatureC: numeric(tail[3]),
347
+ powerW: numeric(tail[4]),
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Parse one `index,uuid` row from `--query-gpu`.
353
+ * @param line - one CSV row.
354
+ * @returns the mapping, or undefined when the row does not match.
355
+ */
356
+ function parseGpuUuidRow(line) {
357
+ const parts = String(line).split(',').map((part) => part.trim())
358
+ if (parts.length < 2) return undefined
359
+ const index = Number(parts[0])
360
+ if (!Number.isFinite(index) || parts[1].length === 0) return undefined
361
+ return { index, uuid: parts[1] }
362
+ }
363
+
364
+ /**
365
+ * Parse one `pid,gpu_uuid,used_memory` row from `--query-compute-apps`.
366
+ * @param line - one CSV row.
367
+ * @returns the row, or undefined when it does not match.
368
+ */
369
+ function parseGpuAppRow(line) {
370
+ const parts = String(line).split(',').map((part) => part.trim())
371
+ if (parts.length < 3) return undefined
372
+ const pid = Number(parts[0])
373
+ if (!Number.isFinite(pid)) return undefined
374
+ const usedMiB = Number(parts[parts.length - 1])
375
+ return {
376
+ pid,
377
+ uuid: parts.slice(1, parts.length - 1).join(','),
378
+ usedMiB: Number.isFinite(usedMiB) ? usedMiB : undefined,
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Parse one `pid,user,args` row used to identify GPU processes.
384
+ * @param line - one row, header excluded.
385
+ * @returns the identity, or undefined for a blank line.
386
+ */
387
+ function parseGpuAppOwnerRow(line) {
388
+ const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line)
389
+ if (match === null) return undefined
390
+ return { pid: Number(match[1]), user: match[2], command: match[3].trim() }
391
+ }
392
+
393
+ /**
394
+ * Parse one full `ps -eo` row: pid, ppid, user, %cpu, %mem, elapsed, stat,
395
+ * threads, rss(KiB) and the full command line.
396
+ * @param line - one row, header excluded.
397
+ * @returns the process, or undefined for a blank or header line.
398
+ */
399
+ function parseProcessRow(line) {
400
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line)
401
+ if (match === null) return undefined
402
+ const rssKib = Number(match[9])
403
+ return {
404
+ pid: Number(match[1]),
405
+ ppid: Number(match[2]),
406
+ user: match[3],
407
+ cpuPercent: Number(match[4]),
408
+ memoryPercent: Number(match[5]),
409
+ elapsed: match[6],
410
+ state: match[7],
411
+ threads: Number(match[8]),
412
+ rssBytes: Number.isFinite(rssKib) ? rssKib * 1024 : 0,
413
+ command: match[10].trim(),
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Parse `/proc/meminfo` lines into a byte-accurate memory summary.
419
+ * @param lines - the `MemTotal:`-style lines.
420
+ * @returns memory in bytes.
421
+ */
422
+ function parseMemory(lines) {
423
+ const values = new Map()
424
+ for (const line of lines) {
425
+ const match = /^(\w+):\s+(\d+)\s*kB$/.exec(line.trim())
426
+ if (match === null) continue
427
+ values.set(match[1], Number(match[2]) * 1024)
428
+ }
429
+ const total = values.get('MemTotal') ?? 0
430
+ const available = values.get('MemAvailable') ?? values.get('MemFree') ?? 0
431
+ return {
432
+ totalBytes: total,
433
+ availableBytes: available,
434
+ usedBytes: Math.max(0, total - available),
435
+ swapTotalBytes: values.get('SwapTotal') ?? 0,
436
+ swapFreeBytes: values.get('SwapFree') ?? 0,
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Parse one `mode|size|mtime|path` row produced by `stat -c`.
442
+ * @param line - one row.
443
+ * @returns the entry, or undefined for a line that does not match.
444
+ */
445
+ function parseStatRow(line) {
446
+ const first = line.indexOf('|')
447
+ if (first < 0) return undefined
448
+ const second = line.indexOf('|', first + 1)
449
+ if (second < 0) return undefined
450
+ const third = line.indexOf('|', second + 1)
451
+ if (third < 0) return undefined
452
+ const modeText = line.slice(0, first).trim()
453
+ const size = Number(line.slice(first + 1, second))
454
+ const mtime = Number(line.slice(second + 1, third))
455
+ let path = line.slice(third + 1)
456
+ if (path.startsWith('./')) path = path.slice(2)
457
+ const hex = Number.parseInt(modeText, 16)
458
+ const typeBits = Number.isFinite(hex) ? hex & 0o170000 : undefined
459
+ const kind = typeBits === 0o040000
460
+ ? 'directory'
461
+ : typeBits === 0o120000
462
+ ? 'symlink'
463
+ : typeBits === 0o100000
464
+ ? 'file'
465
+ : 'other'
466
+ return {
467
+ name: path,
468
+ kind,
469
+ sizeBytes: Number.isFinite(size) ? size : 0,
470
+ modifiedMs: Number.isFinite(mtime) ? mtime * 1000 : 0,
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Sampling script. Sections are framed by `###NAME` markers on their own line so
476
+ * one round trip carries everything the page shows.
477
+ *
478
+ * The GPU process section resolves each pid's owner and command line as well as
479
+ * its device, because `nvidia-smi` alone reports a bare pid and memory figure.
480
+ */
481
+ const METRICS_SCRIPT = [
482
+ 'echo "###HOST"',
483
+ 'hostname 2>/dev/null',
484
+ 'echo "###UNAME"',
485
+ 'uname -sr 2>/dev/null',
486
+ 'echo "###UPTIME"',
487
+ 'cut -d. -f1 /proc/uptime 2>/dev/null',
488
+ 'echo "###LOAD"',
489
+ 'cat /proc/loadavg 2>/dev/null',
490
+ 'echo "###NPROC"',
491
+ 'nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null',
492
+ 'echo "###STAT"',
493
+ 'head -1 /proc/stat 2>/dev/null',
494
+ 'echo "###MEM"',
495
+ "grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):' /proc/meminfo 2>/dev/null",
496
+ 'echo "###GPU"',
497
+ 'if command -v nvidia-smi >/dev/null 2>&1; then '
498
+ + 'nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw '
499
+ + '--format=csv,noheader,nounits 2>/dev/null; else echo NOGPU; fi',
500
+ 'echo "###GPUUUID"',
501
+ 'if command -v nvidia-smi >/dev/null 2>&1; then '
502
+ + 'nvidia-smi --query-gpu=index,uuid --format=csv,noheader 2>/dev/null; fi',
503
+ 'echo "###GPUAPPS"',
504
+ 'if command -v nvidia-smi >/dev/null 2>&1; then '
505
+ + 'nvidia-smi --query-compute-apps=pid,gpu_uuid,used_memory '
506
+ + '--format=csv,noheader,nounits 2>/dev/null; fi',
507
+ 'echo "###GPUAPPOWNERS"',
508
+ 'apppids=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null '
509
+ + "| tr -d ' ' | sed '/^$/d' | paste -sd, -); "
510
+ + '[ -n "$apppids" ] && ps -o pid=,user=,args= -p "$apppids" 2>/dev/null',
511
+ 'echo "###DISK"',
512
+ 'df -P -h / 2>/dev/null | tail -n 1',
513
+ 'echo "###TOP"',
514
+ 'ps -eo pid,ppid,user,pcpu,pmem,etime,stat,nlwp,rss,args --sort=-pcpu 2>/dev/null | head -n 21',
515
+ 'echo "###END"',
516
+ ].join('\n')
517
+
518
+ /* ------------------------------------------------------------------ service */
519
+
520
+ /**
521
+ * One SSH session plus the surfaces the console page reads.
522
+ *
523
+ * The connection is deliberately owned here rather than per call: a console page
524
+ * polls once every couple of seconds, and re-authenticating each time would cost a
525
+ * handshake per sample. Loss of the connection marks the state closed so the page
526
+ * reports it instead of hanging.
527
+ */
528
+ class SshConsoleService {
529
+ /**
530
+ * @param ctx - Host context owning this Service.
531
+ * @param config - the Loader row's `config` object, already merged with defaults.
532
+ */
533
+ constructor(ctx, config) {
534
+ this.ctx = ctx
535
+ this.settings = {
536
+ refreshMs: clampNumber(config?.refreshMs, DEFAULT_REFRESH_MS, 1000, 60000),
537
+ execTimeoutMs: clampNumber(config?.execTimeoutMs, DEFAULT_EXEC_TIMEOUT_MS, 2000, 600000),
538
+ includeSshConfig: config?.includeSshConfig !== false,
539
+ servers: Array.isArray(config?.servers) ? config.servers : [],
540
+ }
541
+ /**
542
+ * Live connections keyed by server id. One page may watch several machines,
543
+ * and each keeps its own CPU baseline for the utilization delta.
544
+ */
545
+ this.sessions = new Map()
546
+ /** Interactive PTYs, keyed by server id. Independent of the SSH connection. */
547
+ this.terminals = new Map()
548
+ /** Binding `readBinding()` validates: service identity, Cordis key, wire namespace. */
549
+ this.typertRemote = Object.freeze({
550
+ service: this,
551
+ serviceKey: SERVICE_KEY,
552
+ namespace: SERVICE_KEY,
553
+ })
554
+ }
555
+
556
+ /**
557
+ * Every configured target: the row's own entries first, then `~/.ssh/config`.
558
+ * @param input - unused; the wire requires one field per declared parameter.
559
+ * @returns the de-duplicated target list plus the polling cadence.
560
+ */
561
+ async servers(input) {
562
+ void input
563
+ const entries = []
564
+ const seen = new Set()
565
+ const push = (entry) => {
566
+ if (seen.has(entry.id)) return
567
+ seen.add(entry.id)
568
+ entries.push(entry)
569
+ }
570
+ for (const raw of this.settings.servers) {
571
+ if (raw === null || typeof raw !== 'object') continue
572
+ const host = typeof raw.host === 'string' ? raw.host.trim() : ''
573
+ if (host.length === 0) continue
574
+ const port = clampNumber(raw.port, 22, 1, 65535)
575
+ const user = typeof raw.user === 'string' ? raw.user.trim() : ''
576
+ push({
577
+ id: `${user}@${host}:${port}`,
578
+ label: typeof raw.label === 'string' && raw.label.trim().length > 0
579
+ ? raw.label.trim()
580
+ : `${user === '' ? '' : `${user}@`}${host}`,
581
+ host,
582
+ port,
583
+ user,
584
+ identityFile: expandHome(raw.identityFile),
585
+ source: 'config',
586
+ })
587
+ }
588
+ if (this.settings.includeSshConfig) {
589
+ for (const entry of readSshConfigHosts()) {
590
+ push({ ...entry, identityFile: expandHome(entry.identityFile) })
591
+ }
592
+ }
593
+ return {
594
+ servers: entries,
595
+ refreshMs: this.settings.refreshMs,
596
+ connectedIds: [...this.sessions.keys()],
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Open a connection to one target, replacing any previous one to the same host.
602
+ * @param input - `{ serverId, password?, passphrase?, identityFile? }`.
603
+ * @returns the connected identity.
604
+ */
605
+ async connect(input) {
606
+ const request = input !== null && typeof input === 'object' ? input : {}
607
+ const serverId = typeof request.serverId === 'string' ? request.serverId : ''
608
+ if (serverId.length === 0) throw remoteFailure('ssh/bad-request', 'A server id is required.')
609
+ const { servers } = await this.servers({})
610
+ const server = servers.find((entry) => entry.id === serverId)
611
+ if (server === undefined) {
612
+ throw remoteFailure('ssh/unknown-server', `Unknown server "${serverId}".`, { serverId })
613
+ }
614
+ // Only this host's previous session is replaced; other hosts stay connected.
615
+ await this.disconnect({ serverId })
616
+
617
+ const options = {
618
+ host: server.host,
619
+ port: server.port,
620
+ readyTimeout: 20000,
621
+ keepaliveInterval: 15000,
622
+ keepaliveCountMax: 4,
623
+ }
624
+ if (server.user !== undefined && server.user.length > 0) options.username = server.user
625
+ const identityFile = expandHome(request.identityFile) ?? server.identityFile
626
+ const passphrase = typeof request.passphrase === 'string' && request.passphrase.length > 0
627
+ ? request.passphrase
628
+ : undefined
629
+ const password = typeof request.password === 'string' && request.password.length > 0
630
+ ? request.password
631
+ : undefined
632
+
633
+ if (password !== undefined) {
634
+ options.password = password
635
+ } else {
636
+ const loaded = this.loadIdentity(identityFile, passphrase)
637
+ if (loaded !== undefined) options.privateKey = loaded
638
+ }
639
+ if (options.privateKey === undefined && options.password === undefined) {
640
+ options.agent = process.platform === 'win32'
641
+ ? '\\\\.\\pipe\\openssh-ssh-agent'
642
+ : (process.env.SSH_AUTH_SOCK ?? undefined)
643
+ if (options.agent === undefined) {
644
+ throw remoteFailure(
645
+ 'ssh/no-credentials',
646
+ `No usable credential for ${server.host}: no password given, no identity file found, and no ssh-agent socket.`,
647
+ { serverId },
648
+ )
649
+ }
650
+ }
651
+
652
+ const client = new Client()
653
+ const session = { client, server, connected: false, cpuSample: undefined, since: Date.now() }
654
+ this.sessions.set(serverId, session)
655
+
656
+ await new Promise((resolvePromise, rejectPromise) => {
657
+ let settled = false
658
+ const settle = (error) => {
659
+ if (settled) return
660
+ settled = true
661
+ if (error === undefined) resolvePromise()
662
+ else rejectPromise(error)
663
+ }
664
+ const markClosed = () => {
665
+ if (this.sessions.get(serverId) !== session) return
666
+ session.connected = false
667
+ session.client = undefined
668
+ }
669
+ client.on('ready', () => settle())
670
+ client.on('error', (error) => {
671
+ markClosed()
672
+ settle(error)
673
+ })
674
+ client.on('close', () => {
675
+ markClosed()
676
+ settle(remoteFailure('ssh/closed', 'The SSH connection closed before it was ready.', { serverId }))
677
+ })
678
+ try {
679
+ client.connect(options)
680
+ } catch (error) {
681
+ settle(error)
682
+ }
683
+ }).catch((error) => {
684
+ discardSession(this, serverId, session)
685
+ throw this.describe(error, server)
686
+ })
687
+
688
+ session.connected = true
689
+ let probe
690
+ try {
691
+ probe = await this.run(
692
+ session,
693
+ 'echo "###HOST"; hostname 2>/dev/null; echo "###UNAME"; uname -sr 2>/dev/null; '
694
+ + 'echo "###STAT"; head -1 /proc/stat 2>/dev/null; echo "###END"',
695
+ 15000,
696
+ )
697
+ } catch (error) {
698
+ discardSession(this, serverId, session)
699
+ throw this.describe(error, server)
700
+ }
701
+ const sections = splitSections(probe.stdout)
702
+ session.hostname = sectionLine(sections, 'HOST')
703
+ session.os = sectionLine(sections, 'UNAME')
704
+ // Seed the CPU baseline here so the very first metrics poll already carries a
705
+ // percentage instead of waiting one interval for a second sample.
706
+ session.cpuSample = parseCpuStat(sectionLine(sections, 'STAT'))
707
+ return { server: this.publicServer(server), hostname: session.hostname, os: session.os }
708
+ }
709
+
710
+ /**
711
+ * Load an identity file, preferring the requested one and falling back to the
712
+ * usual defaults.
713
+ * @param identityFile - explicit path, or undefined.
714
+ * @param passphrase - optional key passphrase.
715
+ * @returns the private key buffer, or undefined when none could be read.
716
+ */
717
+ loadIdentity(identityFile, passphrase) {
718
+ const candidates = []
719
+ if (identityFile !== undefined) candidates.push(identityFile)
720
+ for (const name of DEFAULT_IDENTITIES) candidates.push(join(homedir(), '.ssh', name))
721
+ for (const candidate of candidates) {
722
+ try {
723
+ if (!existsSync(candidate)) continue
724
+ const stat = statSync(candidate)
725
+ if (!stat.isFile() || stat.size === 0) continue
726
+ const key = readFileSync(candidate)
727
+ return passphrase === undefined ? key : { key, passphrase }
728
+ } catch {
729
+ continue
730
+ }
731
+ }
732
+ return undefined
733
+ }
734
+
735
+ /**
736
+ * Report every live session without touching the network.
737
+ * @param input - unused; the wire requires one field per declared parameter.
738
+ * @returns the connection states, in connect order.
739
+ */
740
+ async status(input) {
741
+ void input
742
+ const sessions = []
743
+ for (const [serverId, session] of this.sessions) {
744
+ sessions.push({
745
+ serverId,
746
+ connected: session.connected === true,
747
+ server: session.server === undefined ? undefined : this.publicServer(session.server),
748
+ hostname: session.hostname,
749
+ os: session.os,
750
+ since: session.since,
751
+ })
752
+ }
753
+ return { sessions }
754
+ }
755
+
756
+ /**
757
+ * Close one session, or every session when no id is given.
758
+ * @param input - `{ serverId? }`.
759
+ * @returns the ids that were closed.
760
+ */
761
+ async disconnect(input) {
762
+ const request = input !== null && typeof input === 'object' ? input : {}
763
+ const requested = typeof request.serverId === 'string' ? request.serverId : ''
764
+ const targets = requested.length > 0
765
+ ? (this.sessions.has(requested) ? [[requested, this.sessions.get(requested)]] : [])
766
+ : [...this.sessions]
767
+ const closed = []
768
+ for (const [serverId, session] of targets) {
769
+ if (this.sessions.get(serverId) === session) this.sessions.delete(serverId)
770
+ closed.push(serverId)
771
+ // Every terminal on this server goes with it; a PTY cannot outlive the
772
+ // connection that carries it.
773
+ for (const [termId, terminal] of this.terminalsOf(serverId)) {
774
+ this.terminals.delete(terminalKey(serverId, termId))
775
+ terminal.exited = true
776
+ try {
777
+ terminal.stream.end()
778
+ } catch {
779
+ // A PTY whose channel is gone needs no teardown.
780
+ }
781
+ }
782
+ if (session.client !== undefined) {
783
+ try {
784
+ session.client.end()
785
+ } catch {
786
+ // A connection that is already gone needs no further teardown.
787
+ }
788
+ }
789
+ }
790
+ return { closedIds: closed }
791
+ }
792
+
793
+ /**
794
+ * Resolve one session by the request's `serverId`, falling back to the only
795
+ * live session so a single-target page needs no id.
796
+ * @param request - the Remote input object.
797
+ * @returns the live session.
798
+ */
799
+ sessionFor(request) {
800
+ const requested = typeof request?.serverId === 'string' ? request.serverId : ''
801
+ if (requested.length > 0) {
802
+ const session = this.sessions.get(requested)
803
+ if (session === undefined || session.connected !== true || session.client === undefined) {
804
+ throw remoteFailure('ssh/not-connected', `Not connected to "${requested}".`, { serverId: requested })
805
+ }
806
+ return session
807
+ }
808
+ if (this.sessions.size === 1) {
809
+ const only = [...this.sessions.values()][0]
810
+ if (only.connected === true && only.client !== undefined) return only
811
+ }
812
+ throw remoteFailure(
813
+ 'ssh/not-connected',
814
+ this.sessions.size === 0
815
+ ? 'Not connected to any server.'
816
+ : 'This request must name a server: more than one is connected.',
817
+ )
818
+ }
819
+
820
+ /**
821
+ * List one remote directory. Read-only: only `stat` and globbing run remotely.
822
+ * @param input - `{ path }`.
823
+ * @returns the resolved path and its entries.
824
+ */
825
+ async listDir(input) {
826
+ const request = input !== null && typeof input === 'object' ? input : {}
827
+ const requested = typeof request.path === 'string' && request.path.trim().length > 0
828
+ ? request.path.trim()
829
+ : '~'
830
+ const session = this.sessionFor(request)
831
+ const target = remotePathExpr(requested)
832
+ const script = [
833
+ `cd -- ${target} 2>/dev/null || { echo "###ERR"; echo "no such directory"; echo "###END"; exit 0; }`,
834
+ 'echo "###PWD"',
835
+ 'pwd -P',
836
+ 'echo "###ENTRIES"',
837
+ 'if ! stat -c "%f|%s|%Y|%n" -- . >/dev/null 2>&1; then',
838
+ ' echo "###ERR"',
839
+ ' echo "this server has no GNU stat; the read-only browser needs it"',
840
+ ' echo "###END"',
841
+ ' exit 0',
842
+ 'fi',
843
+ 'for p in ./* ./.[!.]* ./..?*; do',
844
+ ' [ -e "$p" ] || [ -L "$p" ] || continue',
845
+ ` stat -c '%f|%s|%Y|%n' -- "$p" 2>/dev/null || printf '?|0|0|%s\\n' "$p"`,
846
+ 'done',
847
+ 'echo "###END"',
848
+ ].join('\n')
849
+ const result = await this.run(session, script, 20000)
850
+ const sections = splitSections(result.stdout)
851
+ const error = sectionLine(sections, 'ERR')
852
+ if (error.length > 0) {
853
+ throw remoteFailure('ssh/no-such-directory', `Cannot open ${requested}: ${error}`, { path: requested })
854
+ }
855
+ const path = sectionLine(sections, 'PWD')
856
+ const entries = []
857
+ for (const line of sectionLines(sections, 'ENTRIES')) {
858
+ const entry = parseStatRow(line)
859
+ if (entry === undefined) continue
860
+ entries.push(entry)
861
+ }
862
+ entries.sort((left, right) => {
863
+ const leftDir = left.kind === 'directory' ? 0 : 1
864
+ const rightDir = right.kind === 'directory' ? 0 : 1
865
+ if (leftDir !== rightDir) return leftDir - rightDir
866
+ return left.name.localeCompare(right.name)
867
+ })
868
+ return {
869
+ path: path.length > 0 ? path : requested,
870
+ entries: entries.slice(0, MAX_DIR_ENTRIES),
871
+ truncated: entries.length > MAX_DIR_ENTRIES,
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Read the head of one text file. Read-only.
877
+ * @param input - `{ path, maxBytes? }`.
878
+ * @returns the preview and whether it was clipped or is binary.
879
+ */
880
+ async readText(input) {
881
+ const request = input !== null && typeof input === 'object' ? input : {}
882
+ const requested = typeof request.path === 'string' ? request.path.trim() : ''
883
+ if (requested.length === 0) throw remoteFailure('ssh/bad-request', 'A file path is required.')
884
+ const maxBytes = clampNumber(request.maxBytes, MAX_PREVIEW_BYTES, 1024, MAX_PREVIEW_BYTES)
885
+ const session = this.sessionFor(request)
886
+ const target = remotePathExpr(requested)
887
+ const script = [
888
+ `[ -f ${target} ] || { echo "###ERR"; echo "not a regular file"; echo "###END"; exit 0; }`,
889
+ 'echo "###SIZE"',
890
+ `stat -c '%s' -- ${target} 2>/dev/null || echo 0`,
891
+ 'echo "###DATA"',
892
+ `head -c ${maxBytes} -- ${target} 2>/dev/null`,
893
+ 'echo',
894
+ 'echo "###END"',
895
+ ].join('\n')
896
+ const result = await this.run(session, script, 25000)
897
+ const sections = splitSections(result.stdout)
898
+ const error = sectionLine(sections, 'ERR')
899
+ if (error.length > 0) {
900
+ throw remoteFailure('ssh/not-a-file', `Cannot read ${requested}: ${error}`, { path: requested })
901
+ }
902
+ const sizeBytes = Number(sectionLine(sections, 'SIZE')) || 0
903
+ const text = sectionLines(sections, 'DATA').join('\n')
904
+ return {
905
+ path: requested,
906
+ text: text.includes('\u0000') ? '' : text,
907
+ binary: text.includes('\u0000'),
908
+ truncated: sizeBytes > maxBytes || result.truncated,
909
+ sizeBytes,
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Sample one remote machine once: CPU, memory, load, GPU devices, disk, top
915
+ * processes. CPU utilization needs two samples, so the previous `/proc/stat`
916
+ * reading is kept per session and the figure appears from the second poll on.
917
+ * @param input - `{ serverId? }`.
918
+ * @returns the snapshot.
919
+ */
920
+ async metrics(input) {
921
+ const request = input !== null && typeof input === 'object' ? input : {}
922
+ const session = this.sessionFor(request)
923
+ const result = await this.run(session, METRICS_SCRIPT, 25000)
924
+ const sections = splitSections(result.stdout)
925
+ const stat = parseCpuStat(sectionLine(sections, 'STAT'))
926
+ const percent = cpuPercent(session.cpuSample, stat)
927
+ if (stat !== undefined) session.cpuSample = stat
928
+
929
+ const gpuLines = sectionLines(sections, 'GPU')
930
+ const gpuSupported = !(gpuLines.length === 1 && gpuLines[0].trim() === 'NOGPU')
931
+ const gpus = gpuSupported ? gpuLines.map(parseGpuRow).filter(Boolean) : []
932
+
933
+ const loadText = sectionLine(sections, 'LOAD')
934
+ const loadParts = loadText.split(/\s+/)
935
+ const load = [0, 1, 2].map((index) => {
936
+ const value = Number(loadParts[index])
937
+ return Number.isFinite(value) ? value : 0
938
+ })
939
+
940
+ const processes = sectionLines(sections, 'TOP')
941
+ .map(parseProcessRow)
942
+ .filter(Boolean)
943
+ .slice(0, 16)
944
+
945
+ // GPU processes: join the device uuid back to its index, and the pid back to
946
+ // its owner and command line, so a row reads like nvitop's process list.
947
+ //
948
+ // nvidia-smi lists compute processes in an arbitrary order, not by device,
949
+ // so the rows are sorted by card and then by reserved memory. Capping the
950
+ // list here would otherwise drop whichever cards happened to land last.
951
+ const uuidToIndex = new Map()
952
+ for (const row of sectionLines(sections, 'GPUUUID').map(parseGpuUuidRow)) {
953
+ if (row !== undefined) uuidToIndex.set(row.uuid, row.index)
954
+ }
955
+ const owners = new Map()
956
+ for (const row of sectionLines(sections, 'GPUAPPOWNERS').map(parseGpuAppOwnerRow)) {
957
+ if (row !== undefined) owners.set(row.pid, row)
958
+ }
959
+ const gpuProcesses = sectionLines(sections, 'GPUAPPS')
960
+ .map(parseGpuAppRow)
961
+ .filter((row) => row !== undefined)
962
+ .map((row) => {
963
+ const owner = owners.get(row.pid)
964
+ return {
965
+ pid: row.pid,
966
+ gpuIndex: uuidToIndex.get(row.uuid),
967
+ usedMiB: row.usedMiB,
968
+ user: owner?.user,
969
+ command: owner?.command,
970
+ }
971
+ })
972
+ .sort((left, right) => (
973
+ (left.gpuIndex ?? Number.MAX_SAFE_INTEGER) - (right.gpuIndex ?? Number.MAX_SAFE_INTEGER)
974
+ || (right.usedMiB ?? 0) - (left.usedMiB ?? 0)
975
+ || left.pid - right.pid
976
+ ))
977
+ .slice(0, MAX_GPU_PROCESSES)
978
+
979
+ // Per-device rollup, which is what makes a card answer "who is on this GPU".
980
+ for (const device of gpus) {
981
+ const mine = gpuProcesses.filter((process) => process.gpuIndex === device.index)
982
+ device.processCount = mine.length
983
+ device.processMemoryMiB = mine.reduce((sum, process) => sum + (process.usedMiB ?? 0), 0)
984
+ }
985
+
986
+ const diskText = sectionLine(sections, 'DISK')
987
+ const diskFields = diskText.split(/\s+/)
988
+
989
+ return {
990
+ sampledAt: Date.now(),
991
+ hostname: sectionLine(sections, 'HOST'),
992
+ os: sectionLine(sections, 'UNAME'),
993
+ uptimeSeconds: Number(sectionLine(sections, 'UPTIME')) || undefined,
994
+ cpu: {
995
+ percent,
996
+ cores: Number(sectionLine(sections, 'NPROC')) || undefined,
997
+ load,
998
+ },
999
+ memory: parseMemory(sectionLines(sections, 'MEM')),
1000
+ gpu: { supported: gpuSupported, devices: gpus },
1001
+ gpuProcesses,
1002
+ disk: diskFields.length >= 6
1003
+ ? {
1004
+ filesystem: diskFields[0],
1005
+ size: diskFields[1],
1006
+ used: diskFields[2],
1007
+ available: diskFields[3],
1008
+ usedPercent: Number.parseInt(diskFields[4], 10) || 0,
1009
+ mount: diskFields[5],
1010
+ }
1011
+ : undefined,
1012
+ processes,
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Run one command on the remote host and capture its output.
1018
+ * @param input - `{ command, timeoutMs? }`.
1019
+ * @returns stdout, stderr, exit code and timing.
1020
+ */
1021
+ async exec(input) {
1022
+ const request = input !== null && typeof input === 'object' ? input : {}
1023
+ const command = typeof request.command === 'string' ? request.command : ''
1024
+ if (command.trim().length === 0) throw remoteFailure('ssh/bad-request', 'A command is required.')
1025
+ const timeoutMs = clampNumber(request.timeoutMs, this.settings.execTimeoutMs, 1000, 600000)
1026
+ const session = this.sessionFor(request)
1027
+ const started = Date.now()
1028
+ const result = await this.run(session, command, timeoutMs)
1029
+ return {
1030
+ command,
1031
+ stdout: result.stdout,
1032
+ stderr: result.stderr,
1033
+ code: result.code,
1034
+ timedOut: result.timedOut,
1035
+ durationMs: Date.now() - started,
1036
+ truncated: result.truncated,
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * The terminals open on one server, in creation order.
1042
+ * @param serverId - the server id.
1043
+ * @returns `[termId, terminal]` pairs.
1044
+ */
1045
+ terminalsOf(serverId) {
1046
+ const prefix = `${serverId}${TERM_KEY_SEPARATOR}`
1047
+ return [...this.terminals]
1048
+ .filter(([key]) => key.startsWith(prefix))
1049
+ .map(([key, terminal]) => [key.slice(prefix.length), terminal])
1050
+ }
1051
+
1052
+ /**
1053
+ * Resolve one terminal on the request's server.
1054
+ *
1055
+ * A request may name its terminal. When it does not, a server holding exactly
1056
+ * one answers for it; anything else is refused rather than guessed, because a
1057
+ * guess types into the wrong shell.
1058
+ * @param request - the Remote input.
1059
+ * @returns `{ session, termId, terminal }`.
1060
+ */
1061
+ terminalFor(request) {
1062
+ const session = this.sessionFor(request)
1063
+ const serverId = session.server.id
1064
+ const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
1065
+ if (requested.length > 0) {
1066
+ const terminal = this.terminals.get(terminalKey(serverId, requested))
1067
+ if (terminal === undefined) {
1068
+ throw remoteFailure('ssh/no-terminal', 'That terminal is not open on this server.', {
1069
+ serverId,
1070
+ termId: requested,
1071
+ })
1072
+ }
1073
+ return { session, termId: requested, terminal }
1074
+ }
1075
+ const mine = this.terminalsOf(serverId)
1076
+ if (mine.length === 1) return { session, termId: mine[0][0], terminal: mine[0][1] }
1077
+ if (mine.length === 0) {
1078
+ throw remoteFailure('ssh/no-terminal', 'No terminal is open on this server.', { serverId })
1079
+ }
1080
+ throw remoteFailure('ssh/ambiguous-terminal', 'Several terminals are open on this server; name one.', {
1081
+ serverId,
1082
+ termIds: mine.map(([termId]) => termId),
1083
+ })
1084
+ }
1085
+
1086
+ /**
1087
+ * Open an interactive PTY on the session's connection, or re-attach to one
1088
+ * that is already open.
1089
+ *
1090
+ * The remote side gets a real terminal — the same allocation `ssh` gives an
1091
+ * interactive login — so prompts, colours, line editing and full-screen
1092
+ * programs all behave as they do in a native terminal.
1093
+ *
1094
+ * A server can hold several terminals at once (bounded by
1095
+ * `MAX_TERMINALS_PER_SERVER`), each an independent login with its own scrollback
1096
+ * and its own window size. A terminal outlives the view that opened it: leaving
1097
+ * the tab or switching host only detaches the Client, so coming back replays the
1098
+ * retained output instead of starting a fresh login. `termClose` is the explicit
1099
+ * teardown.
1100
+ * @param input - `{ serverId?, termId?, cols?, rows? }`.
1101
+ * @returns the terminal id, cursor and whether this re-attached.
1102
+ */
1103
+ async termOpen(input) {
1104
+ const request = input !== null && typeof input === 'object' ? input : {}
1105
+ const session = this.sessionFor(request)
1106
+ const serverId = session.server.id
1107
+ const client = session.client
1108
+ if (client === undefined) throw remoteFailure('ssh/not-connected', 'Not connected to any server.')
1109
+ const cols = clampNumber(request.cols, 100, 20, 500)
1110
+ const rows = clampNumber(request.rows, 30, 5, 200)
1111
+
1112
+ const mine = this.terminalsOf(serverId)
1113
+ const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
1114
+ const termId = requested.length > 0 ? requested : nextTerminalId(mine.map(([id]) => id))
1115
+ const key = terminalKey(serverId, termId)
1116
+
1117
+ const existing = this.terminals.get(key)
1118
+ if (existing !== undefined && !existing.exited) {
1119
+ // The Client owns the geometry: it is the one with a window to fit.
1120
+ if (cols !== existing.cols || rows !== existing.rows) {
1121
+ existing.cols = cols
1122
+ existing.rows = rows
1123
+ try {
1124
+ existing.stream.setWindow(rows, cols, 0, 0)
1125
+ } catch {
1126
+ // A channel that died between the read and the resize needs no error.
1127
+ }
1128
+ }
1129
+ return {
1130
+ termId,
1131
+ cols,
1132
+ rows,
1133
+ seq: existing.seq,
1134
+ start: existing.start,
1135
+ attached: true,
1136
+ exited: false,
1137
+ }
1138
+ }
1139
+ if (existing === undefined && mine.length >= MAX_TERMINALS_PER_SERVER) {
1140
+ throw remoteFailure(
1141
+ 'ssh/too-many-terminals',
1142
+ `This server already has ${mine.length} terminals; close one first.`,
1143
+ { serverId, limit: MAX_TERMINALS_PER_SERVER },
1144
+ )
1145
+ }
1146
+ // A terminal that has exited is replaced under the same id, so the Client's
1147
+ // layout keeps its slot.
1148
+ if (existing !== undefined) this.terminals.delete(key)
1149
+
1150
+ const stream = await new Promise((resolvePromise, rejectPromise) => {
1151
+ try {
1152
+ client.shell({ term: 'xterm-256color', cols, rows }, (error, channel) => {
1153
+ if (error !== undefined && error !== null) rejectPromise(error)
1154
+ else resolvePromise(channel)
1155
+ })
1156
+ } catch (error) {
1157
+ rejectPromise(error)
1158
+ }
1159
+ }).catch((error) => {
1160
+ throw remoteFailure(
1161
+ 'ssh/terminal-unavailable',
1162
+ `Cannot open a terminal on this server — ${error instanceof Error ? error.message : String(error)}.`,
1163
+ )
1164
+ })
1165
+
1166
+ const terminal = {
1167
+ stream,
1168
+ cols,
1169
+ rows,
1170
+ /** Bytes appended so far; the Client's cursor into the scrollback. */
1171
+ seq: 0,
1172
+ /** First byte still retained, so a stale cursor is detectable. */
1173
+ start: 0,
1174
+ chunks: [],
1175
+ retained: 0,
1176
+ exited: false,
1177
+ exitCode: undefined,
1178
+ }
1179
+ const capture = (data) => {
1180
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8')
1181
+ terminal.chunks.push(buffer)
1182
+ terminal.retained += buffer.length
1183
+ terminal.seq += buffer.length
1184
+ // Keep the tail bounded; a Chatty command must not grow Host memory without
1185
+ // limit. The Client is told to reset when its cursor falls off the front.
1186
+ while (terminal.retained > MAX_TERM_SCROLLBACK_BYTES && terminal.chunks.length > 1) {
1187
+ const dropped = terminal.chunks.shift()
1188
+ terminal.retained -= dropped.length
1189
+ terminal.start += dropped.length
1190
+ }
1191
+ }
1192
+ stream.on('data', capture)
1193
+ stream.stderr.on('data', capture)
1194
+ stream.on('close', (code) => {
1195
+ terminal.exited = true
1196
+ terminal.exitCode = code
1197
+ })
1198
+ stream.on('error', () => {
1199
+ terminal.exited = true
1200
+ })
1201
+ this.terminals.set(key, terminal)
1202
+ return { termId, cols, rows, seq: 0, start: 0, attached: false, exited: false }
1203
+ }
1204
+
1205
+ /**
1206
+ * List the terminals open on the request's server.
1207
+ *
1208
+ * The Client calls this after a reload: its own state is gone, but the Host
1209
+ * still holds the shells, so the layout can be rebuilt instead of replaced.
1210
+ * @param input - `{ serverId? }`.
1211
+ * @returns one entry per terminal.
1212
+ */
1213
+ async termList(input) {
1214
+ const request = input !== null && typeof input === 'object' ? input : {}
1215
+ const session = this.sessionFor(request)
1216
+ return {
1217
+ terminals: this.terminalsOf(session.server.id).map(([termId, terminal]) => ({
1218
+ termId,
1219
+ cols: terminal.cols,
1220
+ rows: terminal.rows,
1221
+ seq: terminal.seq,
1222
+ start: terminal.start,
1223
+ exited: terminal.exited,
1224
+ exitCode: terminal.exitCode,
1225
+ })),
1226
+ }
1227
+ }
1228
+
1229
+ /**
1230
+ * Read terminal output after a cursor.
1231
+ *
1232
+ * Polling keeps the transport request/response only; a shell shows the echo of
1233
+ * a keystroke within one poll interval, which is what a remote console needs.
1234
+ * @param input - `{ serverId?, termId?, fromSeq? }`.
1235
+ * @returns the new bytes as base64, plus whether the cursor must reset.
1236
+ */
1237
+ async termRead(input) {
1238
+ const request = input !== null && typeof input === 'object' ? input : {}
1239
+ const { termId, terminal } = this.terminalFor(request)
1240
+ const fromSeq = Number.isFinite(request.fromSeq) ? Math.max(0, Math.trunc(request.fromSeq)) : 0
1241
+ // A cursor older than the retained window can no longer be resumed: resend
1242
+ // everything and let the Client clear its screen first.
1243
+ const reset = fromSeq < terminal.start
1244
+ const startAt = reset ? terminal.start : fromSeq
1245
+ let skip = startAt - terminal.start
1246
+ const parts = []
1247
+ for (const chunk of terminal.chunks) {
1248
+ if (skip >= chunk.length) {
1249
+ skip -= chunk.length
1250
+ continue
1251
+ }
1252
+ parts.push(skip > 0 ? chunk.subarray(skip) : chunk)
1253
+ skip = 0
1254
+ }
1255
+ return {
1256
+ seq: terminal.seq,
1257
+ start: terminal.start,
1258
+ reset,
1259
+ data: Buffer.concat(parts).toString('base64'),
1260
+ exited: terminal.exited,
1261
+ exitCode: terminal.exitCode,
1262
+ cols: terminal.cols,
1263
+ rows: terminal.rows,
1264
+ }
1265
+ }
1266
+
1267
+ /**
1268
+ * Send keystrokes to one terminal.
1269
+ * @param input - `{ serverId?, termId?, data }` where `data` is base64.
1270
+ * @returns the accepted byte count.
1271
+ */
1272
+ async termWrite(input) {
1273
+ const request = input !== null && typeof input === 'object' ? input : {}
1274
+ const { terminal } = this.terminalFor(request)
1275
+ if (terminal.exited) {
1276
+ throw remoteFailure('ssh/no-terminal', 'That terminal has exited.', { termId: request.termId })
1277
+ }
1278
+ const payload = typeof request.data === 'string' ? Buffer.from(request.data, 'base64') : Buffer.alloc(0)
1279
+ if (payload.length > 0) terminal.stream.write(payload)
1280
+ return { written: payload.length }
1281
+ }
1282
+
1283
+ /**
1284
+ * Resize one remote PTY.
1285
+ * @param input - `{ serverId?, termId?, cols, rows }`.
1286
+ * @returns the applied size.
1287
+ */
1288
+ async termResize(input) {
1289
+ const request = input !== null && typeof input === 'object' ? input : {}
1290
+ const { terminal } = this.terminalFor(request)
1291
+ if (terminal.exited) {
1292
+ throw remoteFailure('ssh/no-terminal', 'That terminal has exited.', { termId: request.termId })
1293
+ }
1294
+ const cols = clampNumber(request.cols, terminal.cols, 20, 500)
1295
+ const rows = clampNumber(request.rows, terminal.rows, 5, 200)
1296
+ if (cols === terminal.cols && rows === terminal.rows) return { cols, rows }
1297
+ terminal.cols = cols
1298
+ terminal.rows = rows
1299
+ try {
1300
+ terminal.stream.setWindow(rows, cols, 0, 0)
1301
+ } catch {
1302
+ // A channel that closed between the read and the resize needs no error.
1303
+ }
1304
+ return { cols, rows }
1305
+ }
1306
+
1307
+ /**
1308
+ * Close one terminal, or every terminal on the server, without touching the
1309
+ * SSH connection.
1310
+ * @param input - `{ serverId?, termId? }`.
1311
+ * @returns the ids that were closed.
1312
+ */
1313
+ async termClose(input) {
1314
+ const request = input !== null && typeof input === 'object' ? input : {}
1315
+ const requestedServer = typeof request.serverId === 'string' ? request.serverId : ''
1316
+ const serverId = requestedServer.length > 0
1317
+ ? requestedServer
1318
+ : (this.sessions.size === 1 ? [...this.sessions.keys()][0] : '')
1319
+ if (serverId.length === 0) return { closedIds: [], closed: false }
1320
+ const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
1321
+ const targets = requested.length > 0
1322
+ ? this.terminalsOf(serverId).filter(([termId]) => termId === requested)
1323
+ : this.terminalsOf(serverId)
1324
+ const closedIds = []
1325
+ for (const [termId, terminal] of targets) {
1326
+ this.terminals.delete(terminalKey(serverId, termId))
1327
+ terminal.exited = true
1328
+ try {
1329
+ terminal.stream.end()
1330
+ } catch {
1331
+ // Already gone.
1332
+ }
1333
+ closedIds.push(termId)
1334
+ }
1335
+ return { closedIds, closed: closedIds.length > 0 }
1336
+ }
1337
+
1338
+ /**
1339
+ * Run one command over the session's connection.
1340
+ * @param session - the live session.
1341
+ * @param command - shell command text.
1342
+ * @param timeoutMs - ceiling before the channel is closed.
1343
+ * @returns captured output.
1344
+ */
1345
+ async run(session, command, timeoutMs) {
1346
+ const client = session.client
1347
+ if (client === undefined) throw remoteFailure('ssh/not-connected', 'Not connected to any server.')
1348
+ return await new Promise((resolvePromise, rejectPromise) => {
1349
+ let settled = false
1350
+ let stdout = ''
1351
+ let stderr = ''
1352
+ let timedOut = false
1353
+ let truncated = false
1354
+ const timer = setTimeout(() => {
1355
+ timedOut = true
1356
+ try {
1357
+ stream?.signal('TERM')
1358
+ } catch {
1359
+ // The channel may already be gone.
1360
+ }
1361
+ try {
1362
+ stream?.close()
1363
+ } catch {
1364
+ // Closing a finished channel is not an error.
1365
+ }
1366
+ finish(undefined, 124)
1367
+ }, timeoutMs)
1368
+ let stream
1369
+ const cleanup = () => {
1370
+ clearTimeout(timer)
1371
+ }
1372
+ const finish = (error, code) => {
1373
+ if (settled) return
1374
+ settled = true
1375
+ cleanup()
1376
+ if (error !== undefined) {
1377
+ rejectPromise(error)
1378
+ return
1379
+ }
1380
+ resolvePromise({ stdout, stderr, code: code ?? 0, timedOut, truncated })
1381
+ }
1382
+ const append = (chunk, target) => {
1383
+ const text = chunk.toString('utf8')
1384
+ if (target === 'out') {
1385
+ if (stdout.length + text.length > MAX_CAPTURED_BYTES) {
1386
+ truncated = true
1387
+ stdout = `${stdout}${text}`.slice(-MAX_CAPTURED_BYTES)
1388
+ return
1389
+ }
1390
+ stdout += text
1391
+ return
1392
+ }
1393
+ if (stderr.length + text.length > MAX_CAPTURED_BYTES) {
1394
+ truncated = true
1395
+ stderr = `${stderr}${text}`.slice(-MAX_CAPTURED_BYTES)
1396
+ return
1397
+ }
1398
+ stderr += text
1399
+ }
1400
+ try {
1401
+ client.exec(command, (error, channel) => {
1402
+ if (error !== undefined && error !== null) {
1403
+ finish(error)
1404
+ return
1405
+ }
1406
+ stream = channel
1407
+ channel.on('data', (chunk) => append(chunk, 'out'))
1408
+ channel.stderr.on('data', (chunk) => append(chunk, 'err'))
1409
+ channel.on('close', (code) => finish(undefined, code))
1410
+ channel.on('error', (channelError) => finish(channelError))
1411
+ })
1412
+ } catch (error) {
1413
+ finish(error)
1414
+ }
1415
+ })
1416
+ }
1417
+
1418
+ /**
1419
+ * Strip the fields the Client does not need from a target.
1420
+ * @param server - one internal target.
1421
+ * @returns its public shape.
1422
+ */
1423
+ publicServer(server) {
1424
+ return {
1425
+ id: server.id,
1426
+ label: server.label,
1427
+ host: server.host,
1428
+ port: server.port,
1429
+ user: server.user,
1430
+ source: server.source,
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Turn a technical failure into a message a person can act on.
1436
+ * @param error - the underlying failure.
1437
+ * @param server - the target it happened against.
1438
+ * @returns the marked Remote failure.
1439
+ */
1440
+ describe(error, server) {
1441
+ if (error !== null && typeof error === 'object' && error.isDSHRemoteError === true) return error
1442
+ const raw = error instanceof Error ? error.message : String(error)
1443
+ const hint = /authentication|permission denied/i.test(raw)
1444
+ ? ' Authentication was rejected: this target needs a password or a different key.'
1445
+ : /timed? ?out|ETIMEDOUT/i.test(raw)
1446
+ ? ' The host did not answer in time.'
1447
+ : ''
1448
+ return remoteFailure('ssh/connect-failed', `Cannot connect to ${server.host}:${server.port} — ${raw}.${hint}`, {
1449
+ host: server.host,
1450
+ port: server.port,
1451
+ })
1452
+ }
1453
+ }
1454
+
1455
+ /**
1456
+ * Drop a half-open session without touching a newer one.
1457
+ * @param service - the owning service.
1458
+ * @param serverId - the target the session belongs to.
1459
+ * @param session - the session that failed.
1460
+ */
1461
+ function discardSession(service, serverId, session) {
1462
+ if (service.sessions.get(serverId) !== session) return
1463
+ service.sessions.delete(serverId)
1464
+ try {
1465
+ session.client?.end()
1466
+ } catch {
1467
+ // A connection that never came up needs no teardown.
1468
+ }
1469
+ }
1470
+
1471
+ Object.defineProperty(SshConsoleService.prototype, REMOTE_METHOD_DESCRIPTOR, {
1472
+ configurable: true,
1473
+ value: Object.freeze({
1474
+ version: 1,
1475
+ methods: Object.freeze(REMOTE_METHODS.map((method) => Object.freeze({
1476
+ method,
1477
+ invocation: Object.freeze({ kind: 'direct' }),
1478
+ }))),
1479
+ }),
1480
+ })
1481
+
1482
+ /**
1483
+ * Register the Remote Service and close the connection when the plugin unloads.
1484
+ * @param ctx - Host plugin context.
1485
+ * @param config - the Loader row's `config` object.
1486
+ */
1487
+ export function apply(ctx, config) {
1488
+ const service = new SshConsoleService(ctx, config)
1489
+ ctx.provide(SERVICE_KEY, service)
1490
+ ctx.effect(() => () => {
1491
+ void service.disconnect({})
1492
+ }, 'ssh-console: connections')
1493
+ }
1494
+
1495
+ export { SshConsoleService }