dsh-server-connector 0.2.3 → 0.3.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/lib/pool.js CHANGED
@@ -1,1464 +1,1496 @@
1
- // dsh-server-connector — SSH connection pool and execution (design doc §7).
2
- //
3
- // One long-lived ssh2 connection per server (§7.2), commands on the same server
4
- // serialised, different servers in parallel. The pool knows nothing about
5
- // policy, tools, credentials storage or the audit log: it turns a server record
6
- // into a connection or a typed error, and an execution request into
7
- // `{ exitCode, stdout, stderr, timedOut, truncated }`.
8
- //
9
- // Design points that are easy to get wrong, and how they are handled here:
10
- //
11
- // - **No PTY** (§7.3): `pty: false` keeps `\r\n` and colour escapes out of the
12
- // output.
13
- // - **cwd** (§7.3): `cd '<cwd>' && …` is assembled on the remote side; nothing
14
- // is ever passed through a local shell.
15
- // - **Timeout must kill the remote process group** (§7.3). `stream.signal()`
16
- // only reaches the channel's own process, so the command runs under a remote
17
- // watchdog that `kill -KILL`s the child's process group. The watchdog is
18
- // started with `setsid`, because on a non-interactive SSH channel the remote
19
- // shell has job control off: a background command then *stays in the shell's
20
- // own process group*, and `kill -- -$child` would address a pid that is not a
21
- // process-group leader (it silently fails, leaving the process alive). With
22
- // `setsid` the child really is its own group leader, so the kill lands.
23
- // The watchdog subshell's own stdout/stderr are redirected to /dev/null:
24
- // otherwise its `sleep` inherits the channel's stdout pipe, sshd waits for
25
- // that pipe to close, and *every* command returns only when its timeout
26
- // expires (measured: 5000 ms instead of 48 ms with the redirect).
27
- // - **Reconnect once** (§7.2): a connection that fails while idle is rebuilt on
28
- // the next call; if that also fails, the caller gets `CONNECT_FAILED`.
29
- // - **Host keys** (§7.4): verification is delegated to the `hostVerifier`
30
- // callback, which consults {@link KnownHosts} through an injected gateway, so
31
- // this module stays free of file IO and is testable without a file system.
32
- // - **The output ceiling is per stream, not shared** (§5.4). stdout and stderr
33
- // are two independent fields of the result, so `maxOutputChars` caps each of
34
- // them; one shared counter would let a chatty stdout close stderr entirely,
35
- // dropping its bytes and leaving the truncation marker on a stream that was
36
- // never cut.
37
- // - **Error normalisation** (§7.5): every transport failure surfaces as a
38
- // {@link PoolError} with one of the documented codes and an actionable hint.
39
-
40
- import { readFile } from 'node:fs/promises'
41
-
42
- import ssh2 from 'ssh2'
43
-
44
- import { fingerprintHostKey } from './known-hosts.js'
45
-
46
- /** The ssh2 client class; pulled off the default export so both ESM and CJS consumers work. */
47
- const { Client } = ssh2
48
-
49
- /**
50
- * The transport-layer error codes of §5.5/§7.5. `EXIT_NONZERO` is listed because
51
- * the tools layer must return it, but the pool never raises it: a nonzero exit is
52
- * a *result* (`exitCode`), not a transport failure — srv_exec decides whether the
53
- * model sees an error. `POLICY_*` and `UNKNOWN_SERVER`/`AMBIGUOUS_SERVER` belong
54
- * to the policy engine and the registry, not to this module.
55
- */
56
- export const POOL_ERROR_CODES = Object.freeze([
57
- 'CONNECT_FAILED',
58
- 'HOST_KEY_UNKNOWN',
59
- 'AUTH_FAILED',
60
- 'TIMEOUT',
61
- 'EXIT_NONZERO',
62
- 'NO_CREDENTIAL',
63
- 'CONNECTION_LOST',
64
- 'INVALID_SERVER',
65
- ])
66
-
67
- /** Default idle timeout of one connection (§7.2, §10). */
68
- export const DEFAULT_IDLE_TIMEOUT_MS = 600000
69
-
70
- /** Default timeout of one command (§10). */
71
- export const DEFAULT_COMMAND_TIMEOUT_MS = 60000
72
-
73
- /** Default connect timeout (§10). */
74
- export const DEFAULT_CONNECT_TIMEOUT_MS = 15000
75
-
76
- /**
77
- * Appended to a stream that was cut at `maxOutputChars` (§5.4). The boolean
78
- * `truncated` says *that* output was lost; the marker shows *where*.
79
- *
80
- * The ceiling applies to each stream on its own: stdout and stderr are separate
81
- * result fields, so neither can consume the other's room and the marker lands
82
- * only in the stream that was actually cut.
83
- */
84
- export const TRUNCATED_MARKER = '\n…[TRUNCATED]'
85
-
86
- /**
87
- * Machine-readable pool failure. `code` is the stable, documented identifier
88
- * (§5.5); `message` is written for the model and the panel to act on in one
89
- * turn, which is why every code carries a next step.
90
- */
91
- export class PoolError extends Error {
92
- /**
93
- * @param {typeof POOL_ERROR_CODES[number]} code
94
- * @param {string} message
95
- * @param {{serverId?: string, host?: string, port?: number, username?: string, cause?: unknown, producedOutput?: boolean}} [details]
96
- */
97
- constructor(code, message, details = {}) {
98
- super(message, details.cause === undefined ? undefined : { cause: details.cause })
99
- this.name = 'PoolError'
100
- this.code = code
101
- this.serverId = details.serverId
102
- this.host = details.host
103
- this.port = details.port
104
- this.username = details.username
105
- /** Whether a channel had already delivered output when it failed (§7.2 retry safety). */
106
- this.producedOutput = details.producedOutput
107
- }
108
- }
109
-
110
- /**
111
- * One command execution request.
112
- * @typedef {object} ExecRequest
113
- * @property {string} command - the command line, passed to `sh -c` on the remote side.
114
- * @property {string|null} [cwd] - working directory; defaults to the server's `defaultWorkDir`.
115
- * @property {number} [timeoutMs] - wall-clock budget of the whole command.
116
- * @property {number} [maxOutputChars] - per-stream ceiling for stdout and for stderr; beyond it that stream is marked truncated.
117
- */
118
-
119
- /**
120
- * One command result.
121
- * @typedef {object} ExecResult
122
- * @property {string} stdout
123
- * @property {string} stderr
124
- * @property {number} exitCode - 0 on success; a negated signal number when the process was killed (137 = SIGKILL).
125
- * @property {number} durationMs
126
- * @property {boolean} timedOut - whether the remote watchdog killed the process group.
127
- * @property {boolean} truncated - whether stdout or stderr crossed `maxOutputChars`.
128
- * @property {boolean} reconnected - whether the command needed a fresh connection.
129
- * @property {string|null} timeoutError - `TIMEOUT` when `timedOut` is true, so the
130
- * tools layer can return the §5.5 code without re-deriving it from exit 137.
131
- */
132
-
133
- /**
134
- * Normalise one ssh2/transport error (§7.5). Exported so the tools layer can map
135
- * errors it catches outside a pool call (e.g. from `ctx.credentials`) the same way.
136
- * @param {unknown} error
137
- * @param {{serverId?: string, host?: string, port?: number, username?: string, phase?: 'connect'|'handshake'|'exec'}} [context]
138
- * @returns {PoolError}
139
- */
140
- export function normalizeError(error, context = {}) {
141
- const details = {
142
- serverId: context.serverId,
143
- host: context.host,
144
- port: context.port,
145
- username: context.username,
146
- cause: error,
147
- }
148
- const where = describeTarget(context)
149
- const message = error instanceof Error ? error.message : String(error)
150
- const errno = isPlainObject(error) ? error.code : undefined
151
- const level = isPlainObject(error) ? error.level : undefined
152
-
153
- // §7.5 row 1: the socket never came up.
154
- if (errno === 'ECONNREFUSED' || errno === 'EHOSTUNREACH' || errno === 'ENETUNREACH' || errno === 'ENOTFOUND') {
155
- return new PoolError(
156
- 'CONNECT_FAILED',
157
- `Cannot reach ${where}: ${message}. Check the host/port and that sshd is running on the target.`,
158
- details,
159
- )
160
- }
161
- if (errno === 'ETIMEDOUT' || level === 'client-timeout') {
162
- return new PoolError(
163
- 'CONNECT_FAILED',
164
- `Connecting to ${where} timed out. Check the host/port, the network path, and that sshd is running on the target.`,
165
- details,
166
- )
167
- }
168
- // §7.5 rows 3 and 4: hostVerifier refused the key.
169
- if (error instanceof HostKeyRejectedError) {
170
- return new PoolError('HOST_KEY_UNKNOWN', error.message, details)
171
- }
172
- // §7.5 row 2: SSH asked for credentials and every method was refused.
173
- if (level === 'client-authentication' || /all configured authentication methods failed/i.test(message)) {
174
- return new PoolError(
175
- 'AUTH_FAILED',
176
- `Authentication failed for ${context.username ? `${context.username}@` : ''}${where}: ${message}. ` +
177
- 'Check the username, the private key or stored password.',
178
- details,
179
- )
180
- }
181
- // A missing/unreadable key file is a local configuration problem, not a
182
- // network one; surfacing it as AUTH_FAILED keeps one actionable bucket.
183
- // (`lib/pool.js#authFor` reads the file before dialling, so this also covers
184
- // an unreadable path reported by ssh2 itself.)
185
- if (errno === 'ENOENT' && /privateKey|private key|key/i.test(message)) {
186
- return new PoolError('AUTH_FAILED', `Cannot read the configured private key file: ${message}.`, details)
187
- }
188
- if (error instanceof PoolError) return error
189
- // Anything left over is either a dead socket mid-command or a dial that got
190
- // further than the errno cases above; the phase decides which bucket it is.
191
- const code = context.phase === 'exec' ? 'CONNECTION_LOST' : 'CONNECT_FAILED'
192
- return new PoolError(
193
- code,
194
- context.phase === 'exec'
195
- ? `The SSH connection to ${where} failed while the command was running: ${message}. Retry the command; if it keeps failing, check that sshd is still running.`
196
- : `SSH connection to ${where} failed: ${message}. Retry; if it persists, check that sshd is running and reachable.`,
197
- details,
198
- )
199
- }
200
-
201
- /**
202
- * A host-key rejection raised inside the `hostVerifier` callback. ssh2 only
203
- * reports the generic "Host denied (verification failed)" handshake error, so
204
- * the real reason travels on this field and is unwrapped by the connect path.
205
- */
206
- export class HostKeyRejectedError extends Error {
207
- /**
208
- * @param {string} serverId
209
- * @param {string} message
210
- */
211
- constructor(serverId, message) {
212
- super(message)
213
- this.name = 'HostKeyRejectedError'
214
- this.serverId = serverId
215
- }
216
- }
217
-
218
- /**
219
- * @param {unknown} value
220
- * @returns {value is Record<string, any>}
221
- */
222
- function isPlainObject(value) {
223
- return typeof value === 'object' && value !== null && !Array.isArray(value)
224
- }
225
-
226
- /**
227
- * @param {{host?: string, port?: number, serverId?: string}} context
228
- * @returns {string}
229
- */
230
- function describeTarget(context) {
231
- if (context.host === undefined) return context.serverId === undefined ? 'the server' : `server ${context.serverId}`
232
- const port = context.port === undefined ? '' : `:${context.port}`
233
- return `${context.host}${port}`
234
- }
235
-
236
- /**
237
- * The remote watchdog of §7.3, extended by the two corrections this module
238
- * documents at the top of the file: `setsid` so the child leads its own process
239
- * group, and stdout/stderr redirection on the watcher so it cannot hold the
240
- * channel open.
241
- *
242
- * Layout, with `command` substituted through {@link shellQuote}:
243
- *
244
- * trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT
245
- * setsid sh -c '<command>' & child=$!
246
- * ( sleep <seconds>; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!
247
- * wait $child; rc=$?
248
- * kill $watchdog 2>/dev/null
249
- * exit $rc
250
- *
251
- * The `trap` is a third correction, found by cancelling a long command and then
252
- * looking for it with `ps`: without it, closing the channel kills only this
253
- * outer shell, and the `setsid` child keeps running until its watchdog fires.
254
- * So a cancelled or disconnected command would keep burning CPU and holding its
255
- * locks for up to `timeoutMs` — the very bug §7.3 exists to prevent, just moved
256
- * from the timeout path to the cancellation path. With the trap, SIGHUP/SIGTERM
257
- * from a closed channel takes the process group down immediately.
258
- *
259
- * The script also omits the spec's `exec 2>&1`, which folds stderr into stdout:
260
- * §5.4 returns them as two distinct fields, so the command runs under a single
261
- * `sh -c` and its two streams stay separate all the way to the channel.
262
- * @param {string} command
263
- * @param {number} timeoutMs
264
- * @returns {string}
265
- */
266
- export function buildWatchdogScript(command, timeoutMs) {
267
- // Sub-second budgets are honoured with a fractional `sleep`, which both GNU
268
- // coreutils and busybox accept; rounding up to whole seconds would let a
269
- // 1.5 s budget run to 2 s.
270
- const seconds = Math.max(0.1, timeoutMs / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
271
- return [
272
- // 129 = 128 + SIGHUP, so a killed command is reported as signalled rather
273
- // than as a clean exit.
274
- `trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT`,
275
- 'setsid sh -c ' + shellQuote(command) + ' & child=$!',
276
- '( sleep ' + seconds + '; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!',
277
- 'wait $child; rc=$?',
278
- 'kill $watchdog 2>/dev/null',
279
- 'exit $rc',
280
- ].join('\n')
281
- }
282
-
283
- /**
284
- * The full remote script: the watchdog, optionally wrapped in `cd '<cwd>' && …`
285
- * (§7.3). A failed `cd` therefore fails the command with the shell's own
286
- * message, which is what the caller wants to read.
287
- * @param {string} command
288
- * @param {number} timeoutMs
289
- * @param {string|null} cwd
290
- * @returns {string}
291
- */
292
- export function buildRemoteScript(command, timeoutMs, cwd) {
293
- const body = buildWatchdogScript(command, timeoutMs)
294
- return cwd === null || cwd === undefined || cwd === '' ? body : 'cd ' + shellQuote(cwd) + ' && {\n' + body + '\n}'
295
- }
296
-
297
- /**
298
- * Quote one string for a POSIX shell, single-quote style: everything inside
299
- * `'…'` is literal, and an embedded quote becomes `'\''`. This is the only
300
- * transformation the pool applies to caller text, and it happens for the remote
301
- * shell only — never for a local one (§7.1's whole point).
302
- * @param {string} value
303
- * @returns {string}
304
- */
305
- export function shellQuote(value) {
306
- return "'" + String(value).replace(/'/g, "'\\''") + "'"
307
- }
308
-
309
- /**
310
- * The `hostVerifier` decision of §7.4, factored out so both the client wiring
311
- * and the unit tests run the same code. The two outcomes are a discriminated
312
- * union, so an accepted key cannot be passed where a refusal reason is needed.
313
- * @param {{mode: 'accept-new'|'verify'|'off', known: {hash: string}|null}} state
314
- * @param {string} keyHash
315
- * @returns {{accept: true, store: boolean, reason: 'accept-new'|'match'|'disabled'} | {accept: false, store: false, reason: 'changed'|'unknown'}}
316
- */
317
- export function decideHostKey(state, keyHash) {
318
- if (state.mode === 'off') return { accept: true, store: false, reason: 'disabled' }
319
- if (state.known === null || state.known === undefined) {
320
- if (state.mode === 'accept-new') return { accept: true, store: true, reason: 'accept-new' }
321
- return { accept: false, store: false, reason: 'unknown' }
322
- }
323
- return state.known.hash === keyHash
324
- ? { accept: true, store: false, reason: 'match' }
325
- : { accept: false, store: false, reason: 'changed' }
326
- }
327
-
328
- /**
329
- * The pool. One instance per plugin process; `dispose()` closes every
330
- * connection (the plugin's cordis dispose hook calls it).
331
- */
332
- export class Pool {
333
- /** @type {number} */
334
- #idleTimeoutMs
335
- /** @type {number} */
336
- #connectTimeoutMs
337
- /** @type {number} */
338
- #commandTimeoutMs
339
- /** @type {number} */
340
- #maxOutputChars
341
- /** @type {number} */
342
- #keepaliveIntervalMs
343
- /** @type {import('./known-hosts.js').KnownHosts} */
344
- #knownHosts
345
- /** @type {(server: any) => Promise<string|null>} */
346
- #resolvePassword
347
- /** @type {{info:Function, warn:Function, error:Function}|null} */
348
- #logger
349
- /**
350
- * Live state per server id. `queue` is the serial tail of §7.2; `connection`
351
- * is `null` while no socket is open.
352
- * @type {Map<string, {connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}>}
353
- */
354
- #servers = new Map()
355
- /** @type {boolean} */
356
- #disposed = false
357
-
358
- /**
359
- * @param {{
360
- * knownHosts?: import('./known-hosts.js').KnownHosts,
361
- * idleTimeoutMs?: number,
362
- * connectTimeoutMs?: number,
363
- * commandTimeoutMs?: number,
364
- * maxOutputChars?: number,
365
- * keepaliveIntervalMs?: number,
366
- * resolvePassword?: (server: any) => Promise<string|null>,
367
- * logger?: {info:Function, warn:Function, error:Function} | null,
368
- * }} [options]
369
- */
370
- constructor(options = {}) {
371
- if (options.knownHosts === undefined) {
372
- throw new TypeError('Pool requires a knownHosts store (see lib/known-hosts.js).')
373
- }
374
- this.#knownHosts = options.knownHosts
375
- this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
376
- this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
377
- this.#commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
378
- this.#maxOutputChars = options.maxOutputChars ?? 200000
379
- // Slightly under a minute: a NAT/firewall idle drop is detected by ssh2
380
- // before the pool hands the next command a dead socket.
381
- this.#keepaliveIntervalMs = options.keepaliveIntervalMs ?? 30000
382
- this.#resolvePassword = options.resolvePassword ?? (async () => null)
383
- this.#logger = options.logger ?? null
384
- }
385
-
386
- /** Effective idle timeout of this pool. */
387
- get idleTimeoutMs() {
388
- return this.#idleTimeoutMs
389
- }
390
-
391
- /**
392
- * Whether a live connection exists for one server, for the panel's green/grey
393
- * dot (doc §8.2). Never opens a connection.
394
- * @param {string} serverId
395
- * @returns {boolean}
396
- */
397
- isConnected(serverId) {
398
- const entry = this.#servers.get(serverId)
399
- return entry !== undefined && entry.connection !== null && entry.connection.alive === true
400
- }
401
-
402
- /**
403
- * Server ids with a live connection (doc §8.2).
404
- * @returns {string[]}
405
- */
406
- connectedServerIds() {
407
- /** @type {string[]} */
408
- const ids = []
409
- for (const [serverId, entry] of this.#servers) {
410
- if (entry.connection !== null && entry.connection.alive === true) ids.push(serverId)
411
- }
412
- return ids.sort()
413
- }
414
-
415
- /**
416
- * Ensure a reusable connection for one server and return it (§7.2). Two
417
- * concurrent calls share one dial, so the "one long connection per server"
418
- * rule survives a burst.
419
- * @param {import('./registry.js').ServerRecord} server
420
- * @returns {Promise<{connection: any, reconnected: boolean}>}
421
- */
422
- async connect(server) {
423
- this.#assertUsable()
424
- const id = requireServer(server).id
425
- const entry = this.#entry(id)
426
- const live = entry.connection
427
- const reused = live !== null && live.alive === true
428
- const connection = await this.#ensureConnection(server, entry)
429
- this.#touch(entry)
430
- // "Reconnected" describes this call: a live socket was reused, or a new one
431
- // had to be built because the previous one was gone.
432
- return { connection, reconnected: !reused }
433
- }
434
-
435
- /**
436
- * Run one command on one server (§7.2, §7.3).
437
- *
438
- * Commands for the same server are serialised through the entry's promise
439
- * queue; different servers run concurrently because each has its own socket
440
- * and channel. A connection that turns out to be dead is rebuilt once, and
441
- * the command is replayed on the new connection exactly once (§7.2).
442
- * @param {import('./registry.js').ServerRecord} server
443
- * @param {ExecRequest} request
444
- * @returns {Promise<ExecResult>}
445
- */
446
- async exec(server, request) {
447
- this.#assertUsable()
448
- const record = requireServer(server)
449
- const command = normalizeCommand(request)
450
- const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
451
- const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
452
- const cwd = normalizeCwd(request.cwd, record)
453
- const entry = this.#entry(record.id)
454
- // A queued command must not race the idle timer armed by the previous one:
455
- // otherwise a command that outlives idleTimeoutMs has its own socket closed
456
- // and its serial queue reset under it.
457
- this.#clearIdle(entry)
458
-
459
- return this.#enqueue(entry, async () => {
460
- const script = buildRemoteScript(command, timeoutMs, cwd)
461
- let reconnected = false
462
- let connection = await this.#ensureConnection(record, entry)
463
- let outcome
464
- try {
465
- outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
466
- } catch (error) {
467
- if (!(error instanceof PoolError) || error.code !== 'CONNECTION_LOST') throw error
468
- // §7.2 allows exactly one automatic retry. It is only safe when the
469
- // command cannot have run: a channel that already delivered output may
470
- // have changed the server, and replaying it would repeat those side
471
- // effects. In that case the caller gets CONNECTION_LOST instead.
472
- if (error.producedOutput === true) throw error
473
- reconnected = true
474
- this.#discard(record.id, entry)
475
- connection = await this.#ensureConnection(record, entry)
476
- outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
477
- }
478
- this.#touch(entry)
479
- return {
480
- ...outcome,
481
- durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
482
- reconnected,
483
- timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
484
- }
485
- })
486
- }
487
-
488
- /**
489
- * Run one command and stream its output while it runs (doc §5.4 async jobs).
490
- *
491
- * Same transport, watchdog, timeout and error normalisation as {@link exec};
492
- * the difference is what the caller gets: `onChunk` is called for every
493
- * stdout/stderr chunk as it arrives, and `onCancel` receives a function that
494
- * signals the remote script to kill the command's process group and then
495
- * releases the channel. The watchdog's SIGKILL at the budget stays as the
496
- * fallback for a process that ignores the signal.
497
- *
498
- * No replay: a streaming caller may already have handed bytes to its
499
- * consumer, so a connection lost mid-command is reported as
500
- * `CONNECTION_LOST` instead of being retried (§7.2's one-retry rule only
501
- * holds for a call that produced nothing).
502
- * @param {import('./registry.js').ServerRecord} server
503
- * @param {ExecRequest & {
504
- * onChunk?: (channel: 'stdout'|'stderr', text: string) => void,
505
- * onCancel?: (cancel: () => void) => void,
506
- * }} request
507
- * @param {AbortSignal} [signal] - caller cancellation (the tool call's signal).
508
- * @returns {Promise<ExecResult & {cancelled: boolean}>}
509
- */
510
- async execStream(server, request, signal) {
511
- this.#assertUsable()
512
- const record = requireServer(server)
513
- const command = normalizeCommand(request)
514
- const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
515
- const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
516
- const cwd = normalizeCwd(request.cwd, record)
517
- const entry = this.#entry(record.id)
518
- const onChunk = typeof request.onChunk === 'function' ? request.onChunk : () => {}
519
- const onCancel = typeof request.onCancel === 'function' ? request.onCancel : () => {}
520
- this.#clearIdle(entry)
521
-
522
- return this.#enqueue(entry, async () => {
523
- const script = buildRemoteScript(command, timeoutMs, cwd)
524
- const connection = await this.#ensureConnection(record, entry)
525
- const outcome = await this.#runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal)
526
- this.#touch(entry)
527
- return {
528
- ...outcome,
529
- durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
530
- reconnected: false,
531
- timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
532
- }
533
- })
534
- }
535
-
536
- /**
537
- * Close one server's connection and forget its queue (§8.3: deleting a server
538
- * disconnects the pool). A running command finishes first.
539
- * @param {string} serverId
540
- * @returns {Promise<void>}
541
- */
542
- async disconnect(serverId) {
543
- const entry = this.#servers.get(serverId)
544
- if (entry === undefined) return
545
- // Stop accepting new work for this server, then let whatever is already
546
- // running finish before the socket goes away.
547
- this.#servers.delete(serverId)
548
- this.#clearIdle(entry)
549
- await entry.queue.catch(() => {})
550
- const connection = entry.connection
551
- entry.connection = null
552
- if (connection !== null && connection.ready === 'ready') await closeConnection(connection).catch(() => {})
553
- }
554
-
555
- /**
556
- * Close every connection. Idempotent; later calls to exec()/connect() report
557
- * `CONNECTION_LOST` rather than dialling a disposed pool.
558
- * @returns {Promise<void>}
559
- */
560
- async dispose() {
561
- if (this.#disposed) return
562
- this.#disposed = true
563
- /** @type {Promise<void>[]} */
564
- const closing = []
565
- for (const [serverId, entry] of this.#servers) {
566
- this.#clearIdle(entry)
567
- const connection = entry.connection
568
- entry.connection = null
569
- if (connection !== null && connection.ready === 'ready') {
570
- closing.push(closeConnection(connection).catch(() => {}))
571
- }
572
- this.#servers.delete(serverId)
573
- }
574
- await Promise.all(closing)
575
- }
576
-
577
- /**
578
- * @param {import('./registry.js').ServerRecord} server
579
- * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
580
- * @returns {Promise<any>}
581
- */
582
- #dial(server, entry) {
583
- const id = server.id
584
- // A dial that is already in flight is shared instead of duplicated: two
585
- // concurrent callers await this same promise, so neither can leave it
586
- // rejected-but-unobserved.
587
- if (entry.connection !== null && entry.connection.ready === 'pending') {
588
- return entry.connection.promise
589
- }
590
- this.#clearIdle(entry)
591
- /** @type {Promise<any>} */
592
- const promise = this.#openConnection(server).then(
593
- (connection) => {
594
- entry.connection = connection
595
- return connection
596
- },
597
- (error) => {
598
- entry.connection = null
599
- const normalized =
600
- error instanceof PoolError
601
- ? error
602
- : normalizeError(error, {
603
- serverId: id,
604
- host: server.host,
605
- port: server.port,
606
- username: server.username,
607
- phase: 'connect',
608
- })
609
- this.#log('warn', 'dsh-server-connector: ssh connect to %s failed: %s', id, normalized.message)
610
- throw normalized
611
- },
612
- )
613
- /** @type {{ready: 'pending', promise: Promise<any>, alive: boolean, reconnected: boolean}} */
614
- entry.connection = { ready: 'pending', promise, alive: false, reconnected: false }
615
- return promise
616
- }
617
-
618
- /**
619
- * Open one ssh2 client, apply the §7.4 host-key decision, and resolve once the
620
- * handshake and authentication completed.
621
- * @param {import('./registry.js').ServerRecord} server
622
- * @returns {Promise<any>}
623
- */
624
- async #openConnection(server) {
625
- const auth = await this.#authFor(server)
626
- const known = await this.#knownHosts.get(server.id)
627
- /** @type {HostKeyRejectedError|null} */
628
- let rejection = null
629
-
630
- const client = new Client()
631
- /** @type {{value: {hash: string, algorithm: string}|null}} */
632
- const accepted = { value: null }
633
- // One listener for the whole life of the client, installed *before*
634
- // connect(): ssh2 emits 'error' both while the handshake is running and
635
- // long after `ready`. An EventEmitter 'error' with no listener throws, so a
636
- // socket that dies during a later command must always have somewhere to go.
637
- /** @type {{settled: boolean, resolve: (value: any) => void, reject: (reason: unknown) => void}} */
638
- const handshake = { settled: false, resolve: () => {}, reject: () => {} }
639
- /** @type {Promise<any>} */
640
- const ready = new Promise((resolve, reject) => {
641
- handshake.resolve = resolve
642
- handshake.reject = reject
643
- })
644
- /** @param {Error & {level?: string, code?: string}} error */
645
- const onClientError = (error) => {
646
- if (!handshake.settled) {
647
- handshake.settled = true
648
- handshake.reject(
649
- rejection ??
650
- normalizeError(error, {
651
- serverId: server.id,
652
- host: server.host,
653
- port: server.port,
654
- username: server.username,
655
- phase: 'connect',
656
- }),
657
- )
658
- return
659
- }
660
- this.#log('warn', 'dsh-server-connector: ssh connection to %s dropped: %s', server.id, error.message)
661
- }
662
- client.on('error', onClientError)
663
- client.on('close', () => {
664
- if (handshake.settled) return
665
- handshake.settled = true
666
- handshake.reject(
667
- normalizeError(new Error('the connection closed before the SSH handshake finished'), {
668
- serverId: server.id,
669
- host: server.host,
670
- port: server.port,
671
- username: server.username,
672
- phase: 'connect',
673
- }),
674
- )
675
- })
676
- client.once('ready', () => {
677
- if (handshake.settled) return
678
- handshake.settled = true
679
- handshake.resolve(client)
680
- })
681
-
682
- const options = {
683
- host: server.host,
684
- port: server.port,
685
- username: server.username,
686
- readyTimeout: this.#connectTimeoutMs,
687
- keepaliveInterval: this.#keepaliveIntervalMs,
688
- keepaliveCountMax: 3,
689
- // §7.4: the callback decides; the store only persists the outcome.
690
- /** @type {(keyBlob: Buffer|string, callback: (accept: boolean) => void) => void} */
691
- hostVerifier: (keyBlob, callback) => {
692
- const fingerprint =
693
- typeof keyBlob === 'string'
694
- ? { hash: keyBlob, algorithm: 'unknown' }
695
- : fingerprintOrNull(keyBlob)
696
- if (fingerprint === null) {
697
- rejection = new HostKeyRejectedError(
698
- server.id,
699
- `The host key of ${server.host}:${server.port} could not be read; refusing to connect.`,
700
- )
701
- callback(false)
702
- return
703
- }
704
- const decision = decideHostKey({ mode: server.hostKeyMode, known }, fingerprint.hash)
705
- if (!decision.accept) {
706
- rejection = hostKeyMessage(server, decision.reason)
707
- callback(false)
708
- return
709
- }
710
- if (decision.store) accepted.value = fingerprint
711
- callback(true)
712
- },
713
- ...auth,
714
- }
715
-
716
- try {
717
- client.connect(options)
718
- await ready
719
- } catch (error) {
720
- safeDestroy(client)
721
- if (accepted.value !== null) {
722
- // Record first, then report: a fingerprint accepted for an
723
- // `accept-new` host must survive the failure of a later auth attempt,
724
- // otherwise the next call would silently re-trust whatever answers.
725
- await this.#knownHosts
726
- .set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
727
- .catch((storeError) => {
728
- this.#log('warn', 'dsh-server-connector: cannot store host key for %s: %s', server.id, String(storeError))
729
- })
730
- }
731
- throw error
732
- }
733
-
734
- if (accepted.value !== null) {
735
- await this.#knownHosts.set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
736
- }
737
-
738
- const connection = { client, alive: true, ready: 'ready', openedAt: Date.now(), reconnected: false }
739
- // The single 'error' listener installed above already logs a post-handshake
740
- // drop; this only flips the liveness flag the pool checks before reuse.
741
- client.on('error', () => {
742
- connection.alive = false
743
- })
744
- client.on('close', () => {
745
- connection.alive = false
746
- })
747
- return connection
748
- }
749
-
750
- /**
751
- * Resolve the `ssh2` authentication fields for one server (§6.3). Password
752
- * authentication goes through the injected resolver, so this module never
753
- * reads `ctx.credentials` itself.
754
- * @param {import('./registry.js').ServerRecord} server
755
- * @returns {Promise<Record<string, unknown>>}
756
- */
757
- async #authFor(server) {
758
- const auth = server.auth
759
- if (auth.kind === 'agent') return {}
760
- if (auth.kind === 'key') {
761
- /** @type {Buffer} */
762
- let privateKey
763
- try {
764
- privateKey = await readFile(auth.privateKeyPath)
765
- } catch (error) {
766
- throw normalizeError(error, {
767
- serverId: server.id,
768
- host: server.host,
769
- port: server.port,
770
- username: server.username,
771
- })
772
- }
773
- return { privateKey }
774
- }
775
- if (auth.kind === 'password') {
776
- const password = await this.#resolvePassword(server)
777
- if (password === null || password === undefined || password === '') {
778
- throw new PoolError(
779
- 'NO_CREDENTIAL',
780
- `No password is stored for server ${server.id} (credentialRef ${auth.credentialRef}). ` +
781
- 'Set it in the server panel, or switch the server to key authentication.',
782
- { serverId: server.id, host: server.host, port: server.port, username: server.username },
783
- )
784
- }
785
- return { password }
786
- }
787
- throw new PoolError('INVALID_SERVER', `Server ${server.id} has an unsupported auth configuration.`, {
788
- serverId: server.id,
789
- host: server.host,
790
- port: server.port,
791
- username: server.username,
792
- })
793
- }
794
-
795
- /**
796
- * Run one already-assembled script over one connection.
797
- * @param {any} connection
798
- * @param {string} script
799
- * @param {number} timeoutMs
800
- * @param {number} maxOutputChars
801
- * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, startedAt: number, endedAt: number}>}
802
- */
803
- async #run(connection, script, timeoutMs, maxOutputChars) {
804
- if (connection.alive === false) {
805
- throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
806
- }
807
- const startedAt = Date.now()
808
- return new Promise((resolve, reject) => {
809
- let settled = false
810
- /** @type {NodeJS.Timeout|null} */
811
- let killTimer = null
812
- const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
813
- if (settled) return
814
- settled = true
815
- if (killTimer !== null) clearTimeout(killTimer)
816
- if (error !== undefined) reject(error)
817
- else resolve(value)
818
- }
819
-
820
- let producedOutput = false
821
- /** @param {Error & {level?: string, code?: string}} error */
822
- const onExecError = (error) => {
823
- connection.alive = false
824
- const normalized = normalizeError(error, { phase: 'exec' })
825
- // Attach, rather than rebuild: the code and message stay the ones the
826
- // caller expects, and exec() reads this flag to decide on a replay.
827
- if (normalized instanceof PoolError) normalized.producedOutput = producedOutput
828
- finish(undefined, normalized)
829
- }
830
-
831
- /** @type {any} */
832
- let channel = null
833
- try {
834
- connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
835
- if (error) {
836
- onExecError(error)
837
- return
838
- }
839
- channel = stream
840
- stream.on('data', () => {
841
- producedOutput = true
842
- })
843
- stream.stderr.on('data', () => {
844
- producedOutput = true
845
- })
846
- this.#consumeChannel(
847
- stream,
848
- maxOutputChars,
849
- (result) => {
850
- finish(
851
- {
852
- stdout: result.stdout,
853
- stderr: result.stderr,
854
- exitCode: result.exitCode,
855
- timedOut: result.timedOut,
856
- truncated: result.truncated,
857
- startedAt,
858
- endedAt: Date.now(),
859
- },
860
- undefined,
861
- )
862
- },
863
- (deadError) => {
864
- onExecError(deadError)
865
- },
866
- )
867
- })
868
- } catch (error) {
869
- // `client.exec` throws synchronously when the socket is already gone.
870
- onExecError(/** @type {Error} */ (error))
871
- return
872
- }
873
-
874
- // The remote watchdog is the primary timeout; this is the safety net for
875
- // a channel that never reports back (say, a hung sshd). It closes the
876
- // channel, which the remote kernel turns into a SIGHUP for the command.
877
- killTimer = setTimeout(() => {
878
- if (settled || channel === null) return
879
- try {
880
- channel.signal('KILL')
881
- } catch {
882
- // The channel may already be closed; closing it below is harmless.
883
- }
884
- try {
885
- channel.close()
886
- } catch {
887
- // Same: a closed channel needs no second close.
888
- }
889
- finish(
890
- undefined,
891
- new PoolError(
892
- 'TIMEOUT',
893
- `Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
894
- {},
895
- ),
896
- )
897
- }, timeoutMs + 5000)
898
- killTimer.unref?.()
899
- })
900
- }
901
-
902
- /**
903
- * The streaming twin of {@link #run}: same watchdog, same ceiling, but every
904
- * chunk is forwarded to `onChunk` as it arrives, and the caller can cancel
905
- * through the returned `onCancel` handle or its abort signal.
906
- * @param {any} connection
907
- * @param {string} script
908
- * @param {number} timeoutMs
909
- * @param {number} maxOutputChars
910
- * @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
911
- * @param {(cancel: () => void) => void} onCancel
912
- * @param {AbortSignal} [signal]
913
- * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, cancelled: boolean, startedAt: number, endedAt: number}>}
914
- */
915
- async #runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal) {
916
- if (connection.alive === false) {
917
- throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
918
- }
919
- const startedAt = Date.now()
920
- return new Promise((resolve, reject) => {
921
- let settled = false
922
- let cancelled = false
923
- /** @type {NodeJS.Timeout|null} */
924
- let killTimer = null
925
- /** @type {any} */
926
- let channel = null
927
- /** @type {(() => void)|null} */
928
- let detachAbort = null
929
-
930
- const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
931
- if (settled) return
932
- settled = true
933
- if (killTimer !== null) clearTimeout(killTimer)
934
- if (detachAbort !== null) detachAbort()
935
- if (error !== undefined) reject(error)
936
- else resolve(value)
937
- }
938
-
939
- /**
940
- * Abort the remote command.
941
- *
942
- * `TERM`, not `KILL`: the remote script traps TERM (and HUP/INT) and uses
943
- * the trap to kill the command's own process group. A KILL would reach the
944
- * outer shell only and leave the `setsid` child — the actual command —
945
- * running until its watchdog fired, which is exactly the leak this path
946
- * must not have. Closing the channel afterwards releases our side.
947
- */
948
- const abortChannel = () => {
949
- const open = channel
950
- if (open === null) return
951
- try {
952
- open.signal('TERM')
953
- } catch {
954
- // The channel may already be closed.
955
- }
956
- // The signal is delivered on the same channel, so closing it in the
957
- // same tick can drop the request before it is sent. One macrotask later
958
- // the packet is on the wire, and closing releases our side.
959
- const closer = setTimeout(() => {
960
- try {
961
- open.close()
962
- } catch {
963
- // Already closed.
964
- }
965
- }, 50)
966
- closer.unref?.()
967
- }
968
-
969
- /** @param {Error & {level?: string, code?: string}} error */
970
- const onExecError = (error) => {
971
- connection.alive = false
972
- finish(undefined, normalizeError(error, { phase: 'exec' }))
973
- }
974
-
975
- onCancel(() => {
976
- if (settled) return
977
- cancelled = true
978
- abortChannel()
979
- finish(
980
- {
981
- stdout: '',
982
- stderr: '',
983
- exitCode: null,
984
- timedOut: false,
985
- truncated: false,
986
- cancelled: true,
987
- startedAt,
988
- endedAt: Date.now(),
989
- },
990
- undefined,
991
- )
992
- })
993
-
994
- try {
995
- connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
996
- if (error) {
997
- onExecError(error)
998
- return
999
- }
1000
- channel = stream
1001
- this.#consumeStreamChannel(
1002
- stream,
1003
- maxOutputChars,
1004
- onChunk,
1005
- (result) => {
1006
- finish({ ...result, cancelled, startedAt, endedAt: Date.now() }, undefined)
1007
- },
1008
- onExecError,
1009
- )
1010
- })
1011
- } catch (error) {
1012
- onExecError(/** @type {Error} */ (error))
1013
- return
1014
- }
1015
-
1016
- if (signal !== undefined) {
1017
- const onAbort = () => {
1018
- cancelled = true
1019
- abortChannel()
1020
- finish(
1021
- {
1022
- stdout: '',
1023
- stderr: '',
1024
- exitCode: null,
1025
- timedOut: false,
1026
- truncated: false,
1027
- cancelled: true,
1028
- startedAt,
1029
- endedAt: Date.now(),
1030
- },
1031
- undefined,
1032
- )
1033
- }
1034
- if (signal.aborted) {
1035
- onAbort()
1036
- return
1037
- }
1038
- signal.addEventListener('abort', onAbort, { once: true })
1039
- detachAbort = () => signal.removeEventListener('abort', onAbort)
1040
- }
1041
-
1042
- killTimer = setTimeout(() => {
1043
- if (settled) return
1044
- abortChannel()
1045
- finish(
1046
- undefined,
1047
- new PoolError(
1048
- 'TIMEOUT',
1049
- `Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
1050
- {},
1051
- ),
1052
- )
1053
- }, timeoutMs + 5000)
1054
- killTimer.unref?.()
1055
- })
1056
- }
1057
-
1058
- /**
1059
- * Read one channel to completion while forwarding each chunk. The same
1060
- * `maxOutputChars` ceiling bounds each stream's assembled result separately;
1061
- * a streaming consumer (a job ring) applies its own retention.
1062
- * @param {any} stream
1063
- * @param {number} maxOutputChars
1064
- * @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
1065
- * @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
1066
- * @param {(error: Error) => void} failed
1067
- * @returns {void}
1068
- */
1069
- #consumeStreamChannel(stream, maxOutputChars, onChunk, done, failed) {
1070
- // One counter per stream: the ceiling is a property of each field of the
1071
- // result, not of their sum, so stderr still arrives after stdout filled up.
1072
- /** @type {Record<'stdout'|'stderr', number>} */
1073
- const size = { stdout: 0, stderr: 0 }
1074
- let truncated = false
1075
- let closed = false
1076
- let stdout = ''
1077
- let stderr = ''
1078
- /** @type {{stdout: boolean, stderr: boolean}} */
1079
- const cut = { stdout: false, stderr: false }
1080
-
1081
- const forward = (/** @type {'stdout'|'stderr'} */ channel, /** @type {unknown} */ chunk) => {
1082
- const used = size[channel]
1083
- if (used >= maxOutputChars) {
1084
- cut[channel] = true
1085
- truncated = true
1086
- return
1087
- }
1088
- const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
1089
- const remaining = maxOutputChars - used
1090
- const kept = text.length > remaining ? text.slice(0, remaining) : text
1091
- size[channel] = used + kept.length
1092
- if (kept.length < text.length) {
1093
- cut[channel] = true
1094
- truncated = true
1095
- }
1096
- if (kept === '') return
1097
- if (channel === 'stdout') stdout += kept
1098
- else stderr += kept
1099
- onChunk(channel, kept)
1100
- }
1101
-
1102
- stream.on('data', (/** @type {unknown} */ chunk) => forward('stdout', chunk))
1103
- stream.stderr.on('data', (/** @type {unknown} */ chunk) => forward('stderr', chunk))
1104
-
1105
- let exitSignal = /** @type {string|null} */ (null)
1106
- stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1107
- exitSignal = signal
1108
- stream.exitCode = code
1109
- })
1110
- stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1111
- if (closed) return
1112
- closed = true
1113
- const finalSignal = signal ?? exitSignal
1114
- const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
1115
- done({
1116
- stdout: stdout + (cut.stdout ? TRUNCATED_MARKER : ''),
1117
- stderr: stderr + (cut.stderr ? TRUNCATED_MARKER : ''),
1118
- exitCode,
1119
- timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
1120
- truncated,
1121
- })
1122
- })
1123
- stream.on('error', (/** @type {Error} */ error) => {
1124
- if (closed) return
1125
- closed = true
1126
- failed(error)
1127
- })
1128
- }
1129
-
1130
- /**
1131
- * Read one channel to completion. Output beyond `maxOutputChars` is dropped
1132
- * (and flagged) instead of growing without bound; each stream is capped
1133
- * separately, because the two are returned as two fields.
1134
- * @param {any} stream
1135
- * @param {number} maxOutputChars
1136
- * @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
1137
- * @param {(error: Error) => void} failed
1138
- * @returns {void}
1139
- */
1140
- #consumeChannel(stream, maxOutputChars, done, failed) {
1141
- /** @type {string[]} */
1142
- const stdoutParts = []
1143
- /** @type {string[]} */
1144
- const stderrParts = []
1145
- // One counter per stream, for the same reason as #consumeStreamChannel:
1146
- // a full stdout must not swallow stderr.
1147
- /** @type {Record<'stdout'|'stderr', number>} */
1148
- const size = { stdout: 0, stderr: 0 }
1149
- let truncated = false
1150
- let closed = false
1151
-
1152
- // The cut is tracked per stream so the marker lands in the stream that was
1153
- // actually truncated (and only there).
1154
- /** @type {{stdout: boolean, stderr: boolean}} */
1155
- const cut = { stdout: false, stderr: false }
1156
- const append = (
1157
- /** @type {string[]} */ parts,
1158
- /** @type {unknown} */ chunk,
1159
- /** @type {'stdout'|'stderr'} */ streamName,
1160
- ) => {
1161
- const used = size[streamName]
1162
- if (used >= maxOutputChars) {
1163
- cut[streamName] = true
1164
- truncated = true
1165
- return
1166
- }
1167
- const remaining = maxOutputChars - used
1168
- const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
1169
- if (text.length > remaining) {
1170
- parts.push(text.slice(0, remaining))
1171
- size[streamName] = maxOutputChars
1172
- cut[streamName] = true
1173
- truncated = true
1174
- return
1175
- }
1176
- parts.push(text)
1177
- size[streamName] = used + text.length
1178
- }
1179
-
1180
- stream.on('data', (/** @type {unknown} */ chunk) => append(stdoutParts, chunk, 'stdout'))
1181
- stream.stderr.on('data', (/** @type {unknown} */ chunk) => append(stderrParts, chunk, 'stderr'))
1182
-
1183
- let exitSignal = /** @type {string|null} */ (null)
1184
- stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1185
- exitSignal = signal
1186
- stream.exitCode = code
1187
- })
1188
- stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1189
- if (closed) return
1190
- closed = true
1191
- const finalSignal = signal ?? exitSignal
1192
- const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
1193
- // The watchdog KILLs the process group, so a timed-out command reports
1194
- // 137 (128 + SIGKILL) on the channel. Any non-zero code when the process
1195
- // was signalled is treated as a timeout only when the signal is SIGKILL;
1196
- // a `kill -9` from inside the command is indistinguishable, which is
1197
- // acceptable: both mean "the command did not finish on its own".
1198
- done({
1199
- stdout: stdoutParts.join('') + (cut.stdout ? TRUNCATED_MARKER : ''),
1200
- stderr: stderrParts.join('') + (cut.stderr ? TRUNCATED_MARKER : ''),
1201
- exitCode,
1202
- timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
1203
- truncated,
1204
- })
1205
- })
1206
- stream.on('error', (/** @type {Error} */ error) => {
1207
- if (closed) return
1208
- closed = true
1209
- failed(error)
1210
- })
1211
- }
1212
-
1213
- /**
1214
- * Record a used connection and arm its idle timer (§7.2).
1215
- * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1216
- * @returns {void}
1217
- */
1218
- #touch(entry) {
1219
- this.#clearIdle(entry)
1220
- entry.lastUsed = Date.now()
1221
- if (this.#idleTimeoutMs <= 0) return
1222
- entry.idleTimer = setTimeout(() => {
1223
- // Only a connection nobody is using may be closed. `queue` is *not*
1224
- // touched here: it is the serialisation tail of the server, and clearing
1225
- // it would let two commands run at once.
1226
- const connection = entry.connection
1227
- entry.connection = null
1228
- entry.idleTimer = null
1229
- if (connection !== null && connection.alive === true) {
1230
- this.#log('info', 'dsh-server-connector: closing idle ssh connection')
1231
- closeConnection(connection).catch(() => {})
1232
- }
1233
- }, this.#idleTimeoutMs)
1234
- entry.idleTimer.unref?.()
1235
- }
1236
-
1237
- /**
1238
- * @param {{idleTimer: NodeJS.Timeout|null}} entry
1239
- * @returns {void}
1240
- */
1241
- #clearIdle(entry) {
1242
- if (entry.idleTimer !== null) {
1243
- clearTimeout(entry.idleTimer)
1244
- entry.idleTimer = null
1245
- }
1246
- }
1247
-
1248
- /**
1249
- * @param {string} serverId
1250
- * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1251
- * @returns {void}
1252
- */
1253
- #discard(serverId, entry) {
1254
- this.#clearIdle(entry)
1255
- const connection = entry.connection
1256
- entry.connection = null
1257
- if (connection !== null && connection.ready === 'ready') closeConnection(connection).catch(() => {})
1258
- this.#log('info', 'dsh-server-connector: discarded dead connection for %s', serverId)
1259
- }
1260
-
1261
- /**
1262
- * Get a live connection, or dial one. A dead-but-remembered connection is
1263
- * rebuilt exactly once (§7.2); if the rebuild fails, CONNECT_FAILED (or a
1264
- * more specific code) travels to the caller unchanged.
1265
- * @param {import('./registry.js').ServerRecord} server
1266
- * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1267
- * @returns {Promise<any>}
1268
- */
1269
- async #ensureConnection(server, entry) {
1270
- if (entry.connection !== null && entry.connection.alive === true) {
1271
- return entry.connection
1272
- }
1273
- if (entry.connection !== null) {
1274
- // One retry only: the previous socket is gone, dial a fresh one.
1275
- closeConnection(entry.connection).catch(() => {})
1276
- entry.connection = null
1277
- }
1278
- return this.#dial(server, entry)
1279
- }
1280
-
1281
- /**
1282
- * Serialise one operation behind the server's previous one (§7.2). A rejected
1283
- * operation must not poison the queue for the next caller.
1284
- * @param {{queue: Promise<void>}} entry
1285
- * @param {() => Promise<any>} operation
1286
- * @returns {Promise<any>}
1287
- */
1288
- async #enqueue(entry, operation) {
1289
- const previous = entry.queue
1290
- /** @type {Promise<any>} */
1291
- const result = previous.then(operation, operation)
1292
- entry.queue = result.then(
1293
- () => {},
1294
- () => {},
1295
- )
1296
- return result
1297
- }
1298
-
1299
- /**
1300
- * @param {string} id
1301
- * @returns {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}}
1302
- */
1303
- #entry(id) {
1304
- let entry = this.#servers.get(id)
1305
- if (entry === undefined) {
1306
- entry = { connection: null, queue: Promise.resolve(), lastUsed: Date.now(), idleTimer: null }
1307
- this.#servers.set(id, entry)
1308
- }
1309
- return entry
1310
- }
1311
-
1312
- /** @returns {void} */
1313
- #assertUsable() {
1314
- if (this.#disposed) {
1315
- throw new PoolError('CONNECTION_LOST', 'The SSH pool has been disposed; the plugin is shutting down.', {})
1316
- }
1317
- }
1318
-
1319
- /**
1320
- * @param {'info'|'warn'|'error'} level
1321
- * @param {string} message
1322
- * @param {...unknown} args
1323
- * @returns {void}
1324
- */
1325
- #log(level, message, ...args) {
1326
- const logger = this.#logger
1327
- if (logger === null || logger === undefined) return
1328
- const write = logger[level] ?? logger.info
1329
- if (typeof write === 'function') write.call(logger, message, ...args)
1330
- }
1331
- }
1332
-
1333
- /**
1334
- * Turn a socket-level signal into the exit code a shell would report.
1335
- * @param {string|null} signal
1336
- * @returns {number}
1337
- */
1338
- function signalExitCode(signal) {
1339
- if (signal === null) return 0
1340
- /** @type {Record<string, number>} */
1341
- const numbers = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
1342
- const number = numbers[signal]
1343
- return number === undefined ? 1 : 128 + number
1344
- }
1345
-
1346
- /**
1347
- * Fingerprint one host key blob through the store's own implementation.
1348
- * Returns `null` instead of throwing, because the ssh2 callback that calls this
1349
- * has no channel to report an error through: the pool then refuses the host.
1350
- * @param {Buffer|Uint8Array|string} keyBlob
1351
- * @returns {{hash: string, algorithm: string}|null}
1352
- */
1353
- function fingerprintOrNull(keyBlob) {
1354
- try {
1355
- return fingerprintHostKey(typeof keyBlob === 'string' ? Buffer.from(keyBlob, 'latin1') : keyBlob)
1356
- } catch {
1357
- return null
1358
- }
1359
- }
1360
-
1361
- /**
1362
- * The §7.5 messages for a refused host key.
1363
- * @param {import('./registry.js').ServerRecord} server
1364
- * @param {'changed'|'unknown'} reason
1365
- * @returns {HostKeyRejectedError}
1366
- */
1367
- function hostKeyMessage(server, reason) {
1368
- const target = `${server.host}:${server.port}`
1369
- if (reason === 'changed') {
1370
- return new HostKeyRejectedError(
1371
- server.id,
1372
- `The host key of ${target} changed; refusing to connect. If the change is expected, ` +
1373
- `remove the entry for server ${server.id} from known_hosts.json (or trust the new key in the panel) and retry.`,
1374
- )
1375
- }
1376
- return new HostKeyRejectedError(
1377
- server.id,
1378
- `The host key of ${target} is not trusted yet and server ${server.id} uses hostKeyMode "verify". ` +
1379
- 'Switch it to "accept-new" (or add the fingerprint to known_hosts.json) and retry.',
1380
- )
1381
- }
1382
-
1383
- /**
1384
- * @param {unknown} value
1385
- * @returns {import('./registry.js').ServerRecord}
1386
- */
1387
- function requireServer(value) {
1388
- if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.trim() === '') {
1389
- throw new PoolError('INVALID_SERVER', 'A complete server record is required (it must carry an id).', {})
1390
- }
1391
- if (typeof value.host !== 'string' || value.host.trim() === '') {
1392
- throw new PoolError('INVALID_SERVER', `Server ${value.id} has no host.`, { serverId: value.id })
1393
- }
1394
- return /** @type {import('./registry.js').ServerRecord} */ (value)
1395
- }
1396
-
1397
- /**
1398
- * @param {ExecRequest} request
1399
- * @returns {string}
1400
- */
1401
- function normalizeCommand(request) {
1402
- if (!isPlainObject(request) || typeof request.command !== 'string' || request.command.trim() === '') {
1403
- throw new PoolError('INVALID_SERVER', 'exec() needs a non-empty command.', {})
1404
- }
1405
- return request.command
1406
- }
1407
-
1408
- /**
1409
- * @param {unknown} value
1410
- * @param {number} fallback
1411
- * @returns {number}
1412
- */
1413
- function normalizePositive(value, fallback) {
1414
- if (value === undefined || value === null) return fallback
1415
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1416
- throw new PoolError('INVALID_SERVER', 'timeoutMs and maxOutputChars must be positive numbers.', {})
1417
- }
1418
- return Math.round(value)
1419
- }
1420
-
1421
- /**
1422
- * @param {unknown} value
1423
- * @param {import('./registry.js').ServerRecord} server
1424
- * @returns {string|null}
1425
- */
1426
- function normalizeCwd(value, server) {
1427
- if (value === undefined) return server.defaultWorkDir ?? null
1428
- if (value === null) return server.defaultWorkDir ?? null
1429
- if (typeof value !== 'string') {
1430
- throw new PoolError('INVALID_SERVER', 'cwd must be a string or null.', { serverId: server.id })
1431
- }
1432
- const trimmed = value.trim()
1433
- return trimmed === '' ? server.defaultWorkDir ?? null : trimmed
1434
- }
1435
-
1436
- /**
1437
- * Close one connection without letting a half-open socket throw on the way out.
1438
- * @param {any} connection
1439
- * @returns {Promise<void>}
1440
- */
1441
- async function closeConnection(connection) {
1442
- connection.alive = false
1443
- const client = connection.client
1444
- if (client === undefined) return
1445
- try {
1446
- client.end()
1447
- } catch {
1448
- // Already destroyed.
1449
- }
1450
- }
1451
-
1452
- /**
1453
- * @param {any} client
1454
- * @returns {void}
1455
- */
1456
- function safeDestroy(client) {
1457
- try {
1458
- client.destroy()
1459
- } catch {
1460
- // Never connected.
1461
- }
1462
- }
1463
-
1464
- export default Pool
1
+ // dsh-server-connector — SSH connection pool and execution (design doc §7).
2
+ //
3
+ // One long-lived ssh2 connection per server (§7.2), commands on the same server
4
+ // serialised, different servers in parallel. The pool knows nothing about
5
+ // policy, tools, credentials storage or the audit log: it turns a server record
6
+ // into a connection or a typed error, and an execution request into
7
+ // `{ exitCode, stdout, stderr, timedOut, truncated }`.
8
+ //
9
+ // Design points that are easy to get wrong, and how they are handled here:
10
+ //
11
+ // - **No PTY** (§7.3): `pty: false` keeps `\r\n` and colour escapes out of the
12
+ // output.
13
+ // - **cwd** (§7.3): `cd '<cwd>' && …` is assembled on the remote side; nothing
14
+ // is ever passed through a local shell.
15
+ // - **Timeout must kill the remote process group** (§7.3). `stream.signal()`
16
+ // only reaches the channel's own process, so the command runs under a remote
17
+ // watchdog that `kill -KILL`s the child's process group. The watchdog is
18
+ // started with `setsid`, because on a non-interactive SSH channel the remote
19
+ // shell has job control off: a background command then *stays in the shell's
20
+ // own process group*, and `kill -- -$child` would address a pid that is not a
21
+ // process-group leader (it silently fails, leaving the process alive). With
22
+ // `setsid` the child really is its own group leader, so the kill lands.
23
+ // The watchdog subshell's own stdout/stderr are redirected to /dev/null:
24
+ // otherwise its `sleep` inherits the channel's stdout pipe, sshd waits for
25
+ // that pipe to close, and *every* command returns only when its timeout
26
+ // expires (measured: 5000 ms instead of 48 ms with the redirect).
27
+ // - **Reconnect once** (§7.2): a connection that fails while idle is rebuilt on
28
+ // the next call; if that also fails, the caller gets `CONNECT_FAILED`.
29
+ // - **Host keys** (§7.4): verification is delegated to the `hostVerifier`
30
+ // callback, which consults {@link KnownHosts} through an injected gateway, so
31
+ // this module stays free of file IO and is testable without a file system.
32
+ // - **The output ceiling is per stream, not shared** (§5.4). stdout and stderr
33
+ // are two independent fields of the result, so `maxOutputChars` caps each of
34
+ // them; one shared counter would let a chatty stdout close stderr entirely,
35
+ // dropping its bytes and leaving the truncation marker on a stream that was
36
+ // never cut.
37
+ // - **Error normalisation** (§7.5): every transport failure surfaces as a
38
+ // {@link PoolError} with one of the documented codes and an actionable hint.
39
+
40
+ import { readFile } from 'node:fs/promises'
41
+
42
+ import ssh2 from 'ssh2'
43
+
44
+ import { fingerprintHostKey } from './known-hosts.js'
45
+
46
+ /** The ssh2 client class; pulled off the default export so both ESM and CJS consumers work. */
47
+ const { Client } = ssh2
48
+
49
+ /**
50
+ * The transport-layer error codes of §5.5/§7.5. `EXIT_NONZERO` is listed because
51
+ * the tools layer must return it, but the pool never raises it: a nonzero exit is
52
+ * a *result* (`exitCode`), not a transport failure — srv_exec decides whether the
53
+ * model sees an error. `POLICY_*` and `UNKNOWN_SERVER`/`AMBIGUOUS_SERVER` belong
54
+ * to the policy engine and the registry, not to this module.
55
+ */
56
+ export const POOL_ERROR_CODES = Object.freeze([
57
+ 'CONNECT_FAILED',
58
+ 'HOST_KEY_UNKNOWN',
59
+ 'AUTH_FAILED',
60
+ 'TIMEOUT',
61
+ 'EXIT_NONZERO',
62
+ 'NO_CREDENTIAL',
63
+ 'CONNECTION_LOST',
64
+ 'INVALID_SERVER',
65
+ ])
66
+
67
+ /** Default idle timeout of one connection (§7.2, §10). */
68
+ export const DEFAULT_IDLE_TIMEOUT_MS = 600000
69
+
70
+ /** Default timeout of one command (§10). */
71
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 60000
72
+
73
+ /** Default connect timeout (§10). */
74
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 15000
75
+
76
+ /**
77
+ * Appended to a stream that was cut at `maxOutputChars` (§5.4). The boolean
78
+ * `truncated` says *that* output was lost; the marker shows *where*.
79
+ *
80
+ * The ceiling applies to each stream on its own: stdout and stderr are separate
81
+ * result fields, so neither can consume the other's room and the marker lands
82
+ * only in the stream that was actually cut.
83
+ */
84
+ export const TRUNCATED_MARKER = '\n…[TRUNCATED]'
85
+
86
+ /**
87
+ * Machine-readable pool failure. `code` is the stable, documented identifier
88
+ * (§5.5); `message` is written for the model and the panel to act on in one
89
+ * turn, which is why every code carries a next step.
90
+ */
91
+ export class PoolError extends Error {
92
+ /**
93
+ * @param {typeof POOL_ERROR_CODES[number]} code
94
+ * @param {string} message
95
+ * @param {{serverId?: string, host?: string, port?: number, username?: string, cause?: unknown, producedOutput?: boolean}} [details]
96
+ */
97
+ constructor(code, message, details = {}) {
98
+ super(message, details.cause === undefined ? undefined : { cause: details.cause })
99
+ this.name = 'PoolError'
100
+ this.code = code
101
+ this.serverId = details.serverId
102
+ this.host = details.host
103
+ this.port = details.port
104
+ this.username = details.username
105
+ /** Whether a channel had already delivered output when it failed (§7.2 retry safety). */
106
+ this.producedOutput = details.producedOutput
107
+ }
108
+ }
109
+
110
+ /**
111
+ * One command execution request.
112
+ * @typedef {object} ExecRequest
113
+ * @property {string} command - the command line, passed to `sh -c` on the remote side.
114
+ * @property {string|null} [cwd] - working directory; defaults to the server's `defaultWorkDir`.
115
+ * @property {number} [timeoutMs] - wall-clock budget of the whole command.
116
+ * @property {number} [maxOutputChars] - per-stream ceiling for stdout and for stderr; beyond it that stream is marked truncated.
117
+ */
118
+
119
+ /**
120
+ * One command result.
121
+ * @typedef {object} ExecResult
122
+ * @property {string} stdout
123
+ * @property {string} stderr
124
+ * @property {number} exitCode - 0 on success; a negated signal number when the process was killed (137 = SIGKILL).
125
+ * @property {number} durationMs
126
+ * @property {boolean} timedOut - whether the remote watchdog killed the process group.
127
+ * @property {boolean} truncated - whether stdout or stderr crossed `maxOutputChars`.
128
+ * @property {boolean} reconnected - whether the command needed a fresh connection.
129
+ * @property {string|null} timeoutError - `TIMEOUT` when `timedOut` is true, so the
130
+ * tools layer can return the §5.5 code without re-deriving it from exit 137.
131
+ */
132
+
133
+ /**
134
+ * Normalise one ssh2/transport error (§7.5). Exported so the tools layer can map
135
+ * errors it catches outside a pool call (e.g. from `ctx.credentials`) the same way.
136
+ * @param {unknown} error
137
+ * @param {{serverId?: string, host?: string, port?: number, username?: string, phase?: 'connect'|'handshake'|'exec'}} [context]
138
+ * @returns {PoolError}
139
+ */
140
+ export function normalizeError(error, context = {}) {
141
+ const details = {
142
+ serverId: context.serverId,
143
+ host: context.host,
144
+ port: context.port,
145
+ username: context.username,
146
+ cause: error,
147
+ }
148
+ const where = describeTarget(context)
149
+ const message = error instanceof Error ? error.message : String(error)
150
+ const errno = isPlainObject(error) ? error.code : undefined
151
+ const level = isPlainObject(error) ? error.level : undefined
152
+
153
+ // §7.5 row 1: the socket never came up.
154
+ if (errno === 'ECONNREFUSED' || errno === 'EHOSTUNREACH' || errno === 'ENETUNREACH' || errno === 'ENOTFOUND') {
155
+ return new PoolError(
156
+ 'CONNECT_FAILED',
157
+ `Cannot reach ${where}: ${message}. Check the host/port and that sshd is running on the target.`,
158
+ details,
159
+ )
160
+ }
161
+ if (errno === 'ETIMEDOUT' || level === 'client-timeout') {
162
+ return new PoolError(
163
+ 'CONNECT_FAILED',
164
+ `Connecting to ${where} timed out. Check the host/port, the network path, and that sshd is running on the target.`,
165
+ details,
166
+ )
167
+ }
168
+ // §7.5 rows 3 and 4: hostVerifier refused the key.
169
+ if (error instanceof HostKeyRejectedError) {
170
+ return new PoolError('HOST_KEY_UNKNOWN', error.message, details)
171
+ }
172
+ // §7.5 row 2: SSH asked for credentials and every method was refused.
173
+ if (level === 'client-authentication' || /all configured authentication methods failed/i.test(message)) {
174
+ return new PoolError(
175
+ 'AUTH_FAILED',
176
+ `Authentication failed for ${context.username ? `${context.username}@` : ''}${where}: ${message}. ` +
177
+ 'Check the username, the private key or stored password.',
178
+ details,
179
+ )
180
+ }
181
+ // A missing/unreadable key file is a local configuration problem, not a
182
+ // network one; surfacing it as AUTH_FAILED keeps one actionable bucket.
183
+ // (`lib/pool.js#authFor` reads the file before dialling, so this also covers
184
+ // an unreadable path reported by ssh2 itself.)
185
+ if (errno === 'ENOENT' && /privateKey|private key|key/i.test(message)) {
186
+ return new PoolError('AUTH_FAILED', `Cannot read the configured private key file: ${message}.`, details)
187
+ }
188
+ if (error instanceof PoolError) return error
189
+ // Anything left over is either a dead socket mid-command or a dial that got
190
+ // further than the errno cases above; the phase decides which bucket it is.
191
+ const code = context.phase === 'exec' ? 'CONNECTION_LOST' : 'CONNECT_FAILED'
192
+ return new PoolError(
193
+ code,
194
+ context.phase === 'exec'
195
+ ? `The SSH connection to ${where} failed while the command was running: ${message}. Retry the command; if it keeps failing, check that sshd is still running.`
196
+ : `SSH connection to ${where} failed: ${message}. Retry; if it persists, check that sshd is running and reachable.`,
197
+ details,
198
+ )
199
+ }
200
+
201
+ /**
202
+ * A host-key rejection raised inside the `hostVerifier` callback. ssh2 only
203
+ * reports the generic "Host denied (verification failed)" handshake error, so
204
+ * the real reason travels on this field and is unwrapped by the connect path.
205
+ */
206
+ export class HostKeyRejectedError extends Error {
207
+ /**
208
+ * @param {string} serverId
209
+ * @param {string} message
210
+ */
211
+ constructor(serverId, message) {
212
+ super(message)
213
+ this.name = 'HostKeyRejectedError'
214
+ this.serverId = serverId
215
+ }
216
+ }
217
+
218
+ /**
219
+ * @param {unknown} value
220
+ * @returns {value is Record<string, any>}
221
+ */
222
+ function isPlainObject(value) {
223
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
224
+ }
225
+
226
+ /**
227
+ * @param {{host?: string, port?: number, serverId?: string}} context
228
+ * @returns {string}
229
+ */
230
+ function describeTarget(context) {
231
+ if (context.host === undefined) return context.serverId === undefined ? 'the server' : `server ${context.serverId}`
232
+ const port = context.port === undefined ? '' : `:${context.port}`
233
+ return `${context.host}${port}`
234
+ }
235
+
236
+ /**
237
+ * The remote watchdog of §7.3, extended by the two corrections this module
238
+ * documents at the top of the file: `setsid` so the child leads its own process
239
+ * group, and stdout/stderr redirection on the watcher so it cannot hold the
240
+ * channel open.
241
+ *
242
+ * Layout, with `command` substituted through {@link shellQuote}:
243
+ *
244
+ * trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT
245
+ * setsid sh -c '<command>' & child=$!
246
+ * ( sleep <seconds>; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!
247
+ * wait $child; rc=$?
248
+ * kill $watchdog 2>/dev/null
249
+ * exit $rc
250
+ *
251
+ * The `trap` is a third correction, found by cancelling a long command and then
252
+ * looking for it with `ps`: without it, closing the channel kills only this
253
+ * outer shell, and the `setsid` child keeps running until its watchdog fires.
254
+ * So a cancelled or disconnected command would keep burning CPU and holding its
255
+ * locks for up to `timeoutMs` — the very bug §7.3 exists to prevent, just moved
256
+ * from the timeout path to the cancellation path. With the trap, SIGHUP/SIGTERM
257
+ * from a closed channel takes the process group down immediately.
258
+ *
259
+ * The script also omits the spec's `exec 2>&1`, which folds stderr into stdout:
260
+ * §5.4 returns them as two distinct fields, so the command runs under a single
261
+ * `sh -c` and its two streams stay separate all the way to the channel.
262
+ * @param {string} command
263
+ * @param {number} timeoutMs
264
+ * @returns {string}
265
+ */
266
+ export function buildWatchdogScript(command, timeoutMs) {
267
+ // Sub-second budgets are honoured with a fractional `sleep`, which both GNU
268
+ // coreutils and busybox accept; rounding up to whole seconds would let a
269
+ // 1.5 s budget run to 2 s.
270
+ const seconds = Math.max(0.1, timeoutMs / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
271
+ return [
272
+ // 129 = 128 + SIGHUP, so a killed command is reported as signalled rather
273
+ // than as a clean exit.
274
+ `trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT`,
275
+ 'setsid sh -c ' + shellQuote(command) + ' & child=$!',
276
+ '( sleep ' + seconds + '; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!',
277
+ 'wait $child; rc=$?',
278
+ 'kill $watchdog 2>/dev/null',
279
+ 'exit $rc',
280
+ ].join('\n')
281
+ }
282
+
283
+ /**
284
+ * The full remote script: the watchdog, optionally wrapped in `cd '<cwd>' && …`
285
+ * (§7.3). A failed `cd` therefore fails the command with the shell's own
286
+ * message, which is what the caller wants to read.
287
+ * @param {string} command
288
+ * @param {number} timeoutMs
289
+ * @param {string|null} cwd
290
+ * @returns {string}
291
+ */
292
+ export function buildRemoteScript(command, timeoutMs, cwd) {
293
+ const body = buildWatchdogScript(command, timeoutMs)
294
+ return cwd === null || cwd === undefined || cwd === '' ? body : 'cd ' + shellQuote(cwd) + ' && {\n' + body + '\n}'
295
+ }
296
+
297
+ /**
298
+ * Quote one string for a POSIX shell, single-quote style: everything inside
299
+ * `'…'` is literal, and an embedded quote becomes `'\''`. This is the only
300
+ * transformation the pool applies to caller text, and it happens for the remote
301
+ * shell only — never for a local one (§7.1's whole point).
302
+ * @param {string} value
303
+ * @returns {string}
304
+ */
305
+ export function shellQuote(value) {
306
+ return "'" + String(value).replace(/'/g, "'\\''") + "'"
307
+ }
308
+
309
+ /**
310
+ * The `hostVerifier` decision of §7.4, factored out so both the client wiring
311
+ * and the unit tests run the same code. The two outcomes are a discriminated
312
+ * union, so an accepted key cannot be passed where a refusal reason is needed.
313
+ * @param {{mode: 'accept-new'|'verify'|'off', known: {hash: string}|null}} state
314
+ * @param {string} keyHash
315
+ * @returns {{accept: true, store: boolean, reason: 'accept-new'|'match'|'disabled'} | {accept: false, store: false, reason: 'changed'|'unknown'}}
316
+ */
317
+ export function decideHostKey(state, keyHash) {
318
+ if (state.mode === 'off') return { accept: true, store: false, reason: 'disabled' }
319
+ if (state.known === null || state.known === undefined) {
320
+ if (state.mode === 'accept-new') return { accept: true, store: true, reason: 'accept-new' }
321
+ return { accept: false, store: false, reason: 'unknown' }
322
+ }
323
+ return state.known.hash === keyHash
324
+ ? { accept: true, store: false, reason: 'match' }
325
+ : { accept: false, store: false, reason: 'changed' }
326
+ }
327
+
328
+ /**
329
+ * The pool. One instance per plugin process; `dispose()` closes every
330
+ * connection (the plugin's cordis dispose hook calls it).
331
+ */
332
+ export class Pool {
333
+ /** @type {number} */
334
+ #idleTimeoutMs
335
+ /** @type {number} */
336
+ #connectTimeoutMs
337
+ /** @type {number} */
338
+ #commandTimeoutMs
339
+ /** @type {number} */
340
+ #maxOutputChars
341
+ /** @type {number} */
342
+ #keepaliveIntervalMs
343
+ /** @type {import('./known-hosts.js').KnownHosts} */
344
+ #knownHosts
345
+ /** @type {(server: any) => Promise<string|null>} */
346
+ #resolvePassword
347
+ /** @type {{info:Function, warn:Function, error:Function}|null} */
348
+ #logger
349
+ /**
350
+ * Live state per server id. `queue` is the serial tail of §7.2; `connection`
351
+ * is `null` while no socket is open.
352
+ * @type {Map<string, {connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}>}
353
+ */
354
+ #servers = new Map()
355
+ /** @type {boolean} */
356
+ #disposed = false
357
+
358
+ /**
359
+ * @param {{
360
+ * knownHosts?: import('./known-hosts.js').KnownHosts,
361
+ * idleTimeoutMs?: number,
362
+ * connectTimeoutMs?: number,
363
+ * commandTimeoutMs?: number,
364
+ * maxOutputChars?: number,
365
+ * keepaliveIntervalMs?: number,
366
+ * resolvePassword?: (server: any) => Promise<string|null>,
367
+ * logger?: {info:Function, warn:Function, error:Function} | null,
368
+ * }} [options]
369
+ */
370
+ constructor(options = {}) {
371
+ if (options.knownHosts === undefined) {
372
+ throw new TypeError('Pool requires a knownHosts store (see lib/known-hosts.js).')
373
+ }
374
+ this.#knownHosts = options.knownHosts
375
+ this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
376
+ this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
377
+ this.#commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
378
+ this.#maxOutputChars = options.maxOutputChars ?? 200000
379
+ // Slightly under a minute: a NAT/firewall idle drop is detected by ssh2
380
+ // before the pool hands the next command a dead socket.
381
+ this.#keepaliveIntervalMs = options.keepaliveIntervalMs ?? 30000
382
+ this.#resolvePassword = options.resolvePassword ?? (async () => null)
383
+ this.#logger = options.logger ?? null
384
+ }
385
+
386
+ /** Effective idle timeout of this pool. */
387
+ get idleTimeoutMs() {
388
+ return this.#idleTimeoutMs
389
+ }
390
+
391
+ /**
392
+ * Apply an edited global configuration without dropping live connections (doc
393
+ * §10, live settings).
394
+ *
395
+ * A volatile settings edit is written into the row config in place, so the
396
+ * pool has to take the new values on the same object it already holds rather
397
+ * than being rebuilt: rebuilding would close every socket and make the user
398
+ * reconnect after changing a timeout. Only the four values the pool owns are
399
+ * accepted; anything else is ignored.
400
+ * @param {{
401
+ * connectTimeoutMs?: number,
402
+ * commandTimeoutMs?: number,
403
+ * idleTimeoutMs?: number,
404
+ * maxOutputChars?: number,
405
+ * }} values
406
+ * @returns {void}
407
+ */
408
+ configure(values) {
409
+ if (typeof values?.connectTimeoutMs === 'number') this.#connectTimeoutMs = values.connectTimeoutMs
410
+ if (typeof values?.commandTimeoutMs === 'number') this.#commandTimeoutMs = values.commandTimeoutMs
411
+ if (typeof values?.maxOutputChars === 'number') this.#maxOutputChars = values.maxOutputChars
412
+ if (typeof values?.idleTimeoutMs === 'number') {
413
+ this.#idleTimeoutMs = values.idleTimeoutMs
414
+ // Re-arm the timers that are already running: a shortened timeout must
415
+ // take effect now, and a disabled one must stop closing sockets.
416
+ for (const entry of this.#servers.values()) {
417
+ this.#clearIdle(entry)
418
+ this.#touch(entry)
419
+ }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Whether a live connection exists for one server, for the panel's green/grey
425
+ * dot (doc §8.2). Never opens a connection.
426
+ * @param {string} serverId
427
+ * @returns {boolean}
428
+ */
429
+ isConnected(serverId) {
430
+ const entry = this.#servers.get(serverId)
431
+ return entry !== undefined && entry.connection !== null && entry.connection.alive === true
432
+ }
433
+
434
+ /**
435
+ * Server ids with a live connection (doc §8.2).
436
+ * @returns {string[]}
437
+ */
438
+ connectedServerIds() {
439
+ /** @type {string[]} */
440
+ const ids = []
441
+ for (const [serverId, entry] of this.#servers) {
442
+ if (entry.connection !== null && entry.connection.alive === true) ids.push(serverId)
443
+ }
444
+ return ids.sort()
445
+ }
446
+
447
+ /**
448
+ * Ensure a reusable connection for one server and return it (§7.2). Two
449
+ * concurrent calls share one dial, so the "one long connection per server"
450
+ * rule survives a burst.
451
+ * @param {import('./registry.js').ServerRecord} server
452
+ * @returns {Promise<{connection: any, reconnected: boolean}>}
453
+ */
454
+ async connect(server) {
455
+ this.#assertUsable()
456
+ const id = requireServer(server).id
457
+ const entry = this.#entry(id)
458
+ const live = entry.connection
459
+ const reused = live !== null && live.alive === true
460
+ const connection = await this.#ensureConnection(server, entry)
461
+ this.#touch(entry)
462
+ // "Reconnected" describes this call: a live socket was reused, or a new one
463
+ // had to be built because the previous one was gone.
464
+ return { connection, reconnected: !reused }
465
+ }
466
+
467
+ /**
468
+ * Run one command on one server (§7.2, §7.3).
469
+ *
470
+ * Commands for the same server are serialised through the entry's promise
471
+ * queue; different servers run concurrently because each has its own socket
472
+ * and channel. A connection that turns out to be dead is rebuilt once, and
473
+ * the command is replayed on the new connection exactly once (§7.2).
474
+ * @param {import('./registry.js').ServerRecord} server
475
+ * @param {ExecRequest} request
476
+ * @returns {Promise<ExecResult>}
477
+ */
478
+ async exec(server, request) {
479
+ this.#assertUsable()
480
+ const record = requireServer(server)
481
+ const command = normalizeCommand(request)
482
+ const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
483
+ const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
484
+ const cwd = normalizeCwd(request.cwd, record)
485
+ const entry = this.#entry(record.id)
486
+ // A queued command must not race the idle timer armed by the previous one:
487
+ // otherwise a command that outlives idleTimeoutMs has its own socket closed
488
+ // and its serial queue reset under it.
489
+ this.#clearIdle(entry)
490
+
491
+ return this.#enqueue(entry, async () => {
492
+ const script = buildRemoteScript(command, timeoutMs, cwd)
493
+ let reconnected = false
494
+ let connection = await this.#ensureConnection(record, entry)
495
+ let outcome
496
+ try {
497
+ outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
498
+ } catch (error) {
499
+ if (!(error instanceof PoolError) || error.code !== 'CONNECTION_LOST') throw error
500
+ // §7.2 allows exactly one automatic retry. It is only safe when the
501
+ // command cannot have run: a channel that already delivered output may
502
+ // have changed the server, and replaying it would repeat those side
503
+ // effects. In that case the caller gets CONNECTION_LOST instead.
504
+ if (error.producedOutput === true) throw error
505
+ reconnected = true
506
+ this.#discard(record.id, entry)
507
+ connection = await this.#ensureConnection(record, entry)
508
+ outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
509
+ }
510
+ this.#touch(entry)
511
+ return {
512
+ ...outcome,
513
+ durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
514
+ reconnected,
515
+ timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
516
+ }
517
+ })
518
+ }
519
+
520
+ /**
521
+ * Run one command and stream its output while it runs (doc §5.4 async jobs).
522
+ *
523
+ * Same transport, watchdog, timeout and error normalisation as {@link exec};
524
+ * the difference is what the caller gets: `onChunk` is called for every
525
+ * stdout/stderr chunk as it arrives, and `onCancel` receives a function that
526
+ * signals the remote script to kill the command's process group and then
527
+ * releases the channel. The watchdog's SIGKILL at the budget stays as the
528
+ * fallback for a process that ignores the signal.
529
+ *
530
+ * No replay: a streaming caller may already have handed bytes to its
531
+ * consumer, so a connection lost mid-command is reported as
532
+ * `CONNECTION_LOST` instead of being retried (§7.2's one-retry rule only
533
+ * holds for a call that produced nothing).
534
+ * @param {import('./registry.js').ServerRecord} server
535
+ * @param {ExecRequest & {
536
+ * onChunk?: (channel: 'stdout'|'stderr', text: string) => void,
537
+ * onCancel?: (cancel: () => void) => void,
538
+ * }} request
539
+ * @param {AbortSignal} [signal] - caller cancellation (the tool call's signal).
540
+ * @returns {Promise<ExecResult & {cancelled: boolean}>}
541
+ */
542
+ async execStream(server, request, signal) {
543
+ this.#assertUsable()
544
+ const record = requireServer(server)
545
+ const command = normalizeCommand(request)
546
+ const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
547
+ const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
548
+ const cwd = normalizeCwd(request.cwd, record)
549
+ const entry = this.#entry(record.id)
550
+ const onChunk = typeof request.onChunk === 'function' ? request.onChunk : () => {}
551
+ const onCancel = typeof request.onCancel === 'function' ? request.onCancel : () => {}
552
+ this.#clearIdle(entry)
553
+
554
+ return this.#enqueue(entry, async () => {
555
+ const script = buildRemoteScript(command, timeoutMs, cwd)
556
+ const connection = await this.#ensureConnection(record, entry)
557
+ const outcome = await this.#runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal)
558
+ this.#touch(entry)
559
+ return {
560
+ ...outcome,
561
+ durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
562
+ reconnected: false,
563
+ timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
564
+ }
565
+ })
566
+ }
567
+
568
+ /**
569
+ * Close one server's connection and forget its queue (§8.3: deleting a server
570
+ * disconnects the pool). A running command finishes first.
571
+ * @param {string} serverId
572
+ * @returns {Promise<void>}
573
+ */
574
+ async disconnect(serverId) {
575
+ const entry = this.#servers.get(serverId)
576
+ if (entry === undefined) return
577
+ // Stop accepting new work for this server, then let whatever is already
578
+ // running finish before the socket goes away.
579
+ this.#servers.delete(serverId)
580
+ this.#clearIdle(entry)
581
+ await entry.queue.catch(() => {})
582
+ const connection = entry.connection
583
+ entry.connection = null
584
+ if (connection !== null && connection.ready === 'ready') await closeConnection(connection).catch(() => {})
585
+ }
586
+
587
+ /**
588
+ * Close every connection. Idempotent; later calls to exec()/connect() report
589
+ * `CONNECTION_LOST` rather than dialling a disposed pool.
590
+ * @returns {Promise<void>}
591
+ */
592
+ async dispose() {
593
+ if (this.#disposed) return
594
+ this.#disposed = true
595
+ /** @type {Promise<void>[]} */
596
+ const closing = []
597
+ for (const [serverId, entry] of this.#servers) {
598
+ this.#clearIdle(entry)
599
+ const connection = entry.connection
600
+ entry.connection = null
601
+ if (connection !== null && connection.ready === 'ready') {
602
+ closing.push(closeConnection(connection).catch(() => {}))
603
+ }
604
+ this.#servers.delete(serverId)
605
+ }
606
+ await Promise.all(closing)
607
+ }
608
+
609
+ /**
610
+ * @param {import('./registry.js').ServerRecord} server
611
+ * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
612
+ * @returns {Promise<any>}
613
+ */
614
+ #dial(server, entry) {
615
+ const id = server.id
616
+ // A dial that is already in flight is shared instead of duplicated: two
617
+ // concurrent callers await this same promise, so neither can leave it
618
+ // rejected-but-unobserved.
619
+ if (entry.connection !== null && entry.connection.ready === 'pending') {
620
+ return entry.connection.promise
621
+ }
622
+ this.#clearIdle(entry)
623
+ /** @type {Promise<any>} */
624
+ const promise = this.#openConnection(server).then(
625
+ (connection) => {
626
+ entry.connection = connection
627
+ return connection
628
+ },
629
+ (error) => {
630
+ entry.connection = null
631
+ const normalized =
632
+ error instanceof PoolError
633
+ ? error
634
+ : normalizeError(error, {
635
+ serverId: id,
636
+ host: server.host,
637
+ port: server.port,
638
+ username: server.username,
639
+ phase: 'connect',
640
+ })
641
+ this.#log('warn', 'dsh-server-connector: ssh connect to %s failed: %s', id, normalized.message)
642
+ throw normalized
643
+ },
644
+ )
645
+ /** @type {{ready: 'pending', promise: Promise<any>, alive: boolean, reconnected: boolean}} */
646
+ entry.connection = { ready: 'pending', promise, alive: false, reconnected: false }
647
+ return promise
648
+ }
649
+
650
+ /**
651
+ * Open one ssh2 client, apply the §7.4 host-key decision, and resolve once the
652
+ * handshake and authentication completed.
653
+ * @param {import('./registry.js').ServerRecord} server
654
+ * @returns {Promise<any>}
655
+ */
656
+ async #openConnection(server) {
657
+ const auth = await this.#authFor(server)
658
+ const known = await this.#knownHosts.get(server.id)
659
+ /** @type {HostKeyRejectedError|null} */
660
+ let rejection = null
661
+
662
+ const client = new Client()
663
+ /** @type {{value: {hash: string, algorithm: string}|null}} */
664
+ const accepted = { value: null }
665
+ // One listener for the whole life of the client, installed *before*
666
+ // connect(): ssh2 emits 'error' both while the handshake is running and
667
+ // long after `ready`. An EventEmitter 'error' with no listener throws, so a
668
+ // socket that dies during a later command must always have somewhere to go.
669
+ /** @type {{settled: boolean, resolve: (value: any) => void, reject: (reason: unknown) => void}} */
670
+ const handshake = { settled: false, resolve: () => {}, reject: () => {} }
671
+ /** @type {Promise<any>} */
672
+ const ready = new Promise((resolve, reject) => {
673
+ handshake.resolve = resolve
674
+ handshake.reject = reject
675
+ })
676
+ /** @param {Error & {level?: string, code?: string}} error */
677
+ const onClientError = (error) => {
678
+ if (!handshake.settled) {
679
+ handshake.settled = true
680
+ handshake.reject(
681
+ rejection ??
682
+ normalizeError(error, {
683
+ serverId: server.id,
684
+ host: server.host,
685
+ port: server.port,
686
+ username: server.username,
687
+ phase: 'connect',
688
+ }),
689
+ )
690
+ return
691
+ }
692
+ this.#log('warn', 'dsh-server-connector: ssh connection to %s dropped: %s', server.id, error.message)
693
+ }
694
+ client.on('error', onClientError)
695
+ client.on('close', () => {
696
+ if (handshake.settled) return
697
+ handshake.settled = true
698
+ handshake.reject(
699
+ normalizeError(new Error('the connection closed before the SSH handshake finished'), {
700
+ serverId: server.id,
701
+ host: server.host,
702
+ port: server.port,
703
+ username: server.username,
704
+ phase: 'connect',
705
+ }),
706
+ )
707
+ })
708
+ client.once('ready', () => {
709
+ if (handshake.settled) return
710
+ handshake.settled = true
711
+ handshake.resolve(client)
712
+ })
713
+
714
+ const options = {
715
+ host: server.host,
716
+ port: server.port,
717
+ username: server.username,
718
+ readyTimeout: this.#connectTimeoutMs,
719
+ keepaliveInterval: this.#keepaliveIntervalMs,
720
+ keepaliveCountMax: 3,
721
+ // §7.4: the callback decides; the store only persists the outcome.
722
+ /** @type {(keyBlob: Buffer|string, callback: (accept: boolean) => void) => void} */
723
+ hostVerifier: (keyBlob, callback) => {
724
+ const fingerprint =
725
+ typeof keyBlob === 'string'
726
+ ? { hash: keyBlob, algorithm: 'unknown' }
727
+ : fingerprintOrNull(keyBlob)
728
+ if (fingerprint === null) {
729
+ rejection = new HostKeyRejectedError(
730
+ server.id,
731
+ `The host key of ${server.host}:${server.port} could not be read; refusing to connect.`,
732
+ )
733
+ callback(false)
734
+ return
735
+ }
736
+ const decision = decideHostKey({ mode: server.hostKeyMode, known }, fingerprint.hash)
737
+ if (!decision.accept) {
738
+ rejection = hostKeyMessage(server, decision.reason)
739
+ callback(false)
740
+ return
741
+ }
742
+ if (decision.store) accepted.value = fingerprint
743
+ callback(true)
744
+ },
745
+ ...auth,
746
+ }
747
+
748
+ try {
749
+ client.connect(options)
750
+ await ready
751
+ } catch (error) {
752
+ safeDestroy(client)
753
+ if (accepted.value !== null) {
754
+ // Record first, then report: a fingerprint accepted for an
755
+ // `accept-new` host must survive the failure of a later auth attempt,
756
+ // otherwise the next call would silently re-trust whatever answers.
757
+ await this.#knownHosts
758
+ .set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
759
+ .catch((storeError) => {
760
+ this.#log('warn', 'dsh-server-connector: cannot store host key for %s: %s', server.id, String(storeError))
761
+ })
762
+ }
763
+ throw error
764
+ }
765
+
766
+ if (accepted.value !== null) {
767
+ await this.#knownHosts.set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
768
+ }
769
+
770
+ const connection = { client, alive: true, ready: 'ready', openedAt: Date.now(), reconnected: false }
771
+ // The single 'error' listener installed above already logs a post-handshake
772
+ // drop; this only flips the liveness flag the pool checks before reuse.
773
+ client.on('error', () => {
774
+ connection.alive = false
775
+ })
776
+ client.on('close', () => {
777
+ connection.alive = false
778
+ })
779
+ return connection
780
+ }
781
+
782
+ /**
783
+ * Resolve the `ssh2` authentication fields for one server (§6.3). Password
784
+ * authentication goes through the injected resolver, so this module never
785
+ * reads `ctx.credentials` itself.
786
+ * @param {import('./registry.js').ServerRecord} server
787
+ * @returns {Promise<Record<string, unknown>>}
788
+ */
789
+ async #authFor(server) {
790
+ const auth = server.auth
791
+ if (auth.kind === 'agent') return {}
792
+ if (auth.kind === 'key') {
793
+ /** @type {Buffer} */
794
+ let privateKey
795
+ try {
796
+ privateKey = await readFile(auth.privateKeyPath)
797
+ } catch (error) {
798
+ throw normalizeError(error, {
799
+ serverId: server.id,
800
+ host: server.host,
801
+ port: server.port,
802
+ username: server.username,
803
+ })
804
+ }
805
+ return { privateKey }
806
+ }
807
+ if (auth.kind === 'password') {
808
+ const password = await this.#resolvePassword(server)
809
+ if (password === null || password === undefined || password === '') {
810
+ throw new PoolError(
811
+ 'NO_CREDENTIAL',
812
+ `No password is stored for server ${server.id} (credentialRef ${auth.credentialRef}). ` +
813
+ 'Set it in the server panel, or switch the server to key authentication.',
814
+ { serverId: server.id, host: server.host, port: server.port, username: server.username },
815
+ )
816
+ }
817
+ return { password }
818
+ }
819
+ throw new PoolError('INVALID_SERVER', `Server ${server.id} has an unsupported auth configuration.`, {
820
+ serverId: server.id,
821
+ host: server.host,
822
+ port: server.port,
823
+ username: server.username,
824
+ })
825
+ }
826
+
827
+ /**
828
+ * Run one already-assembled script over one connection.
829
+ * @param {any} connection
830
+ * @param {string} script
831
+ * @param {number} timeoutMs
832
+ * @param {number} maxOutputChars
833
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, startedAt: number, endedAt: number}>}
834
+ */
835
+ async #run(connection, script, timeoutMs, maxOutputChars) {
836
+ if (connection.alive === false) {
837
+ throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
838
+ }
839
+ const startedAt = Date.now()
840
+ return new Promise((resolve, reject) => {
841
+ let settled = false
842
+ /** @type {NodeJS.Timeout|null} */
843
+ let killTimer = null
844
+ const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
845
+ if (settled) return
846
+ settled = true
847
+ if (killTimer !== null) clearTimeout(killTimer)
848
+ if (error !== undefined) reject(error)
849
+ else resolve(value)
850
+ }
851
+
852
+ let producedOutput = false
853
+ /** @param {Error & {level?: string, code?: string}} error */
854
+ const onExecError = (error) => {
855
+ connection.alive = false
856
+ const normalized = normalizeError(error, { phase: 'exec' })
857
+ // Attach, rather than rebuild: the code and message stay the ones the
858
+ // caller expects, and exec() reads this flag to decide on a replay.
859
+ if (normalized instanceof PoolError) normalized.producedOutput = producedOutput
860
+ finish(undefined, normalized)
861
+ }
862
+
863
+ /** @type {any} */
864
+ let channel = null
865
+ try {
866
+ connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
867
+ if (error) {
868
+ onExecError(error)
869
+ return
870
+ }
871
+ channel = stream
872
+ stream.on('data', () => {
873
+ producedOutput = true
874
+ })
875
+ stream.stderr.on('data', () => {
876
+ producedOutput = true
877
+ })
878
+ this.#consumeChannel(
879
+ stream,
880
+ maxOutputChars,
881
+ (result) => {
882
+ finish(
883
+ {
884
+ stdout: result.stdout,
885
+ stderr: result.stderr,
886
+ exitCode: result.exitCode,
887
+ timedOut: result.timedOut,
888
+ truncated: result.truncated,
889
+ startedAt,
890
+ endedAt: Date.now(),
891
+ },
892
+ undefined,
893
+ )
894
+ },
895
+ (deadError) => {
896
+ onExecError(deadError)
897
+ },
898
+ )
899
+ })
900
+ } catch (error) {
901
+ // `client.exec` throws synchronously when the socket is already gone.
902
+ onExecError(/** @type {Error} */ (error))
903
+ return
904
+ }
905
+
906
+ // The remote watchdog is the primary timeout; this is the safety net for
907
+ // a channel that never reports back (say, a hung sshd). It closes the
908
+ // channel, which the remote kernel turns into a SIGHUP for the command.
909
+ killTimer = setTimeout(() => {
910
+ if (settled || channel === null) return
911
+ try {
912
+ channel.signal('KILL')
913
+ } catch {
914
+ // The channel may already be closed; closing it below is harmless.
915
+ }
916
+ try {
917
+ channel.close()
918
+ } catch {
919
+ // Same: a closed channel needs no second close.
920
+ }
921
+ finish(
922
+ undefined,
923
+ new PoolError(
924
+ 'TIMEOUT',
925
+ `Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
926
+ {},
927
+ ),
928
+ )
929
+ }, timeoutMs + 5000)
930
+ killTimer.unref?.()
931
+ })
932
+ }
933
+
934
+ /**
935
+ * The streaming twin of {@link #run}: same watchdog, same ceiling, but every
936
+ * chunk is forwarded to `onChunk` as it arrives, and the caller can cancel
937
+ * through the returned `onCancel` handle or its abort signal.
938
+ * @param {any} connection
939
+ * @param {string} script
940
+ * @param {number} timeoutMs
941
+ * @param {number} maxOutputChars
942
+ * @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
943
+ * @param {(cancel: () => void) => void} onCancel
944
+ * @param {AbortSignal} [signal]
945
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, cancelled: boolean, startedAt: number, endedAt: number}>}
946
+ */
947
+ async #runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal) {
948
+ if (connection.alive === false) {
949
+ throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
950
+ }
951
+ const startedAt = Date.now()
952
+ return new Promise((resolve, reject) => {
953
+ let settled = false
954
+ let cancelled = false
955
+ /** @type {NodeJS.Timeout|null} */
956
+ let killTimer = null
957
+ /** @type {any} */
958
+ let channel = null
959
+ /** @type {(() => void)|null} */
960
+ let detachAbort = null
961
+
962
+ const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
963
+ if (settled) return
964
+ settled = true
965
+ if (killTimer !== null) clearTimeout(killTimer)
966
+ if (detachAbort !== null) detachAbort()
967
+ if (error !== undefined) reject(error)
968
+ else resolve(value)
969
+ }
970
+
971
+ /**
972
+ * Abort the remote command.
973
+ *
974
+ * `TERM`, not `KILL`: the remote script traps TERM (and HUP/INT) and uses
975
+ * the trap to kill the command's own process group. A KILL would reach the
976
+ * outer shell only and leave the `setsid` child — the actual command —
977
+ * running until its watchdog fired, which is exactly the leak this path
978
+ * must not have. Closing the channel afterwards releases our side.
979
+ */
980
+ const abortChannel = () => {
981
+ const open = channel
982
+ if (open === null) return
983
+ try {
984
+ open.signal('TERM')
985
+ } catch {
986
+ // The channel may already be closed.
987
+ }
988
+ // The signal is delivered on the same channel, so closing it in the
989
+ // same tick can drop the request before it is sent. One macrotask later
990
+ // the packet is on the wire, and closing releases our side.
991
+ const closer = setTimeout(() => {
992
+ try {
993
+ open.close()
994
+ } catch {
995
+ // Already closed.
996
+ }
997
+ }, 50)
998
+ closer.unref?.()
999
+ }
1000
+
1001
+ /** @param {Error & {level?: string, code?: string}} error */
1002
+ const onExecError = (error) => {
1003
+ connection.alive = false
1004
+ finish(undefined, normalizeError(error, { phase: 'exec' }))
1005
+ }
1006
+
1007
+ onCancel(() => {
1008
+ if (settled) return
1009
+ cancelled = true
1010
+ abortChannel()
1011
+ finish(
1012
+ {
1013
+ stdout: '',
1014
+ stderr: '',
1015
+ exitCode: null,
1016
+ timedOut: false,
1017
+ truncated: false,
1018
+ cancelled: true,
1019
+ startedAt,
1020
+ endedAt: Date.now(),
1021
+ },
1022
+ undefined,
1023
+ )
1024
+ })
1025
+
1026
+ try {
1027
+ connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
1028
+ if (error) {
1029
+ onExecError(error)
1030
+ return
1031
+ }
1032
+ channel = stream
1033
+ this.#consumeStreamChannel(
1034
+ stream,
1035
+ maxOutputChars,
1036
+ onChunk,
1037
+ (result) => {
1038
+ finish({ ...result, cancelled, startedAt, endedAt: Date.now() }, undefined)
1039
+ },
1040
+ onExecError,
1041
+ )
1042
+ })
1043
+ } catch (error) {
1044
+ onExecError(/** @type {Error} */ (error))
1045
+ return
1046
+ }
1047
+
1048
+ if (signal !== undefined) {
1049
+ const onAbort = () => {
1050
+ cancelled = true
1051
+ abortChannel()
1052
+ finish(
1053
+ {
1054
+ stdout: '',
1055
+ stderr: '',
1056
+ exitCode: null,
1057
+ timedOut: false,
1058
+ truncated: false,
1059
+ cancelled: true,
1060
+ startedAt,
1061
+ endedAt: Date.now(),
1062
+ },
1063
+ undefined,
1064
+ )
1065
+ }
1066
+ if (signal.aborted) {
1067
+ onAbort()
1068
+ return
1069
+ }
1070
+ signal.addEventListener('abort', onAbort, { once: true })
1071
+ detachAbort = () => signal.removeEventListener('abort', onAbort)
1072
+ }
1073
+
1074
+ killTimer = setTimeout(() => {
1075
+ if (settled) return
1076
+ abortChannel()
1077
+ finish(
1078
+ undefined,
1079
+ new PoolError(
1080
+ 'TIMEOUT',
1081
+ `Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
1082
+ {},
1083
+ ),
1084
+ )
1085
+ }, timeoutMs + 5000)
1086
+ killTimer.unref?.()
1087
+ })
1088
+ }
1089
+
1090
+ /**
1091
+ * Read one channel to completion while forwarding each chunk. The same
1092
+ * `maxOutputChars` ceiling bounds each stream's assembled result separately;
1093
+ * a streaming consumer (a job ring) applies its own retention.
1094
+ * @param {any} stream
1095
+ * @param {number} maxOutputChars
1096
+ * @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
1097
+ * @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
1098
+ * @param {(error: Error) => void} failed
1099
+ * @returns {void}
1100
+ */
1101
+ #consumeStreamChannel(stream, maxOutputChars, onChunk, done, failed) {
1102
+ // One counter per stream: the ceiling is a property of each field of the
1103
+ // result, not of their sum, so stderr still arrives after stdout filled up.
1104
+ /** @type {Record<'stdout'|'stderr', number>} */
1105
+ const size = { stdout: 0, stderr: 0 }
1106
+ let truncated = false
1107
+ let closed = false
1108
+ let stdout = ''
1109
+ let stderr = ''
1110
+ /** @type {{stdout: boolean, stderr: boolean}} */
1111
+ const cut = { stdout: false, stderr: false }
1112
+
1113
+ const forward = (/** @type {'stdout'|'stderr'} */ channel, /** @type {unknown} */ chunk) => {
1114
+ const used = size[channel]
1115
+ if (used >= maxOutputChars) {
1116
+ cut[channel] = true
1117
+ truncated = true
1118
+ return
1119
+ }
1120
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
1121
+ const remaining = maxOutputChars - used
1122
+ const kept = text.length > remaining ? text.slice(0, remaining) : text
1123
+ size[channel] = used + kept.length
1124
+ if (kept.length < text.length) {
1125
+ cut[channel] = true
1126
+ truncated = true
1127
+ }
1128
+ if (kept === '') return
1129
+ if (channel === 'stdout') stdout += kept
1130
+ else stderr += kept
1131
+ onChunk(channel, kept)
1132
+ }
1133
+
1134
+ stream.on('data', (/** @type {unknown} */ chunk) => forward('stdout', chunk))
1135
+ stream.stderr.on('data', (/** @type {unknown} */ chunk) => forward('stderr', chunk))
1136
+
1137
+ let exitSignal = /** @type {string|null} */ (null)
1138
+ stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1139
+ exitSignal = signal
1140
+ stream.exitCode = code
1141
+ })
1142
+ stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1143
+ if (closed) return
1144
+ closed = true
1145
+ const finalSignal = signal ?? exitSignal
1146
+ const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
1147
+ done({
1148
+ stdout: stdout + (cut.stdout ? TRUNCATED_MARKER : ''),
1149
+ stderr: stderr + (cut.stderr ? TRUNCATED_MARKER : ''),
1150
+ exitCode,
1151
+ timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
1152
+ truncated,
1153
+ })
1154
+ })
1155
+ stream.on('error', (/** @type {Error} */ error) => {
1156
+ if (closed) return
1157
+ closed = true
1158
+ failed(error)
1159
+ })
1160
+ }
1161
+
1162
+ /**
1163
+ * Read one channel to completion. Output beyond `maxOutputChars` is dropped
1164
+ * (and flagged) instead of growing without bound; each stream is capped
1165
+ * separately, because the two are returned as two fields.
1166
+ * @param {any} stream
1167
+ * @param {number} maxOutputChars
1168
+ * @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
1169
+ * @param {(error: Error) => void} failed
1170
+ * @returns {void}
1171
+ */
1172
+ #consumeChannel(stream, maxOutputChars, done, failed) {
1173
+ /** @type {string[]} */
1174
+ const stdoutParts = []
1175
+ /** @type {string[]} */
1176
+ const stderrParts = []
1177
+ // One counter per stream, for the same reason as #consumeStreamChannel:
1178
+ // a full stdout must not swallow stderr.
1179
+ /** @type {Record<'stdout'|'stderr', number>} */
1180
+ const size = { stdout: 0, stderr: 0 }
1181
+ let truncated = false
1182
+ let closed = false
1183
+
1184
+ // The cut is tracked per stream so the marker lands in the stream that was
1185
+ // actually truncated (and only there).
1186
+ /** @type {{stdout: boolean, stderr: boolean}} */
1187
+ const cut = { stdout: false, stderr: false }
1188
+ const append = (
1189
+ /** @type {string[]} */ parts,
1190
+ /** @type {unknown} */ chunk,
1191
+ /** @type {'stdout'|'stderr'} */ streamName,
1192
+ ) => {
1193
+ const used = size[streamName]
1194
+ if (used >= maxOutputChars) {
1195
+ cut[streamName] = true
1196
+ truncated = true
1197
+ return
1198
+ }
1199
+ const remaining = maxOutputChars - used
1200
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
1201
+ if (text.length > remaining) {
1202
+ parts.push(text.slice(0, remaining))
1203
+ size[streamName] = maxOutputChars
1204
+ cut[streamName] = true
1205
+ truncated = true
1206
+ return
1207
+ }
1208
+ parts.push(text)
1209
+ size[streamName] = used + text.length
1210
+ }
1211
+
1212
+ stream.on('data', (/** @type {unknown} */ chunk) => append(stdoutParts, chunk, 'stdout'))
1213
+ stream.stderr.on('data', (/** @type {unknown} */ chunk) => append(stderrParts, chunk, 'stderr'))
1214
+
1215
+ let exitSignal = /** @type {string|null} */ (null)
1216
+ stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1217
+ exitSignal = signal
1218
+ stream.exitCode = code
1219
+ })
1220
+ stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
1221
+ if (closed) return
1222
+ closed = true
1223
+ const finalSignal = signal ?? exitSignal
1224
+ const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
1225
+ // The watchdog KILLs the process group, so a timed-out command reports
1226
+ // 137 (128 + SIGKILL) on the channel. Any non-zero code when the process
1227
+ // was signalled is treated as a timeout only when the signal is SIGKILL;
1228
+ // a `kill -9` from inside the command is indistinguishable, which is
1229
+ // acceptable: both mean "the command did not finish on its own".
1230
+ done({
1231
+ stdout: stdoutParts.join('') + (cut.stdout ? TRUNCATED_MARKER : ''),
1232
+ stderr: stderrParts.join('') + (cut.stderr ? TRUNCATED_MARKER : ''),
1233
+ exitCode,
1234
+ timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
1235
+ truncated,
1236
+ })
1237
+ })
1238
+ stream.on('error', (/** @type {Error} */ error) => {
1239
+ if (closed) return
1240
+ closed = true
1241
+ failed(error)
1242
+ })
1243
+ }
1244
+
1245
+ /**
1246
+ * Record a used connection and arm its idle timer (§7.2).
1247
+ * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1248
+ * @returns {void}
1249
+ */
1250
+ #touch(entry) {
1251
+ this.#clearIdle(entry)
1252
+ entry.lastUsed = Date.now()
1253
+ if (this.#idleTimeoutMs <= 0) return
1254
+ entry.idleTimer = setTimeout(() => {
1255
+ // Only a connection nobody is using may be closed. `queue` is *not*
1256
+ // touched here: it is the serialisation tail of the server, and clearing
1257
+ // it would let two commands run at once.
1258
+ const connection = entry.connection
1259
+ entry.connection = null
1260
+ entry.idleTimer = null
1261
+ if (connection !== null && connection.alive === true) {
1262
+ this.#log('info', 'dsh-server-connector: closing idle ssh connection')
1263
+ closeConnection(connection).catch(() => {})
1264
+ }
1265
+ }, this.#idleTimeoutMs)
1266
+ entry.idleTimer.unref?.()
1267
+ }
1268
+
1269
+ /**
1270
+ * @param {{idleTimer: NodeJS.Timeout|null}} entry
1271
+ * @returns {void}
1272
+ */
1273
+ #clearIdle(entry) {
1274
+ if (entry.idleTimer !== null) {
1275
+ clearTimeout(entry.idleTimer)
1276
+ entry.idleTimer = null
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * @param {string} serverId
1282
+ * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1283
+ * @returns {void}
1284
+ */
1285
+ #discard(serverId, entry) {
1286
+ this.#clearIdle(entry)
1287
+ const connection = entry.connection
1288
+ entry.connection = null
1289
+ if (connection !== null && connection.ready === 'ready') closeConnection(connection).catch(() => {})
1290
+ this.#log('info', 'dsh-server-connector: discarded dead connection for %s', serverId)
1291
+ }
1292
+
1293
+ /**
1294
+ * Get a live connection, or dial one. A dead-but-remembered connection is
1295
+ * rebuilt exactly once (§7.2); if the rebuild fails, CONNECT_FAILED (or a
1296
+ * more specific code) travels to the caller unchanged.
1297
+ * @param {import('./registry.js').ServerRecord} server
1298
+ * @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
1299
+ * @returns {Promise<any>}
1300
+ */
1301
+ async #ensureConnection(server, entry) {
1302
+ if (entry.connection !== null && entry.connection.alive === true) {
1303
+ return entry.connection
1304
+ }
1305
+ if (entry.connection !== null) {
1306
+ // One retry only: the previous socket is gone, dial a fresh one.
1307
+ closeConnection(entry.connection).catch(() => {})
1308
+ entry.connection = null
1309
+ }
1310
+ return this.#dial(server, entry)
1311
+ }
1312
+
1313
+ /**
1314
+ * Serialise one operation behind the server's previous one (§7.2). A rejected
1315
+ * operation must not poison the queue for the next caller.
1316
+ * @param {{queue: Promise<void>}} entry
1317
+ * @param {() => Promise<any>} operation
1318
+ * @returns {Promise<any>}
1319
+ */
1320
+ async #enqueue(entry, operation) {
1321
+ const previous = entry.queue
1322
+ /** @type {Promise<any>} */
1323
+ const result = previous.then(operation, operation)
1324
+ entry.queue = result.then(
1325
+ () => {},
1326
+ () => {},
1327
+ )
1328
+ return result
1329
+ }
1330
+
1331
+ /**
1332
+ * @param {string} id
1333
+ * @returns {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}}
1334
+ */
1335
+ #entry(id) {
1336
+ let entry = this.#servers.get(id)
1337
+ if (entry === undefined) {
1338
+ entry = { connection: null, queue: Promise.resolve(), lastUsed: Date.now(), idleTimer: null }
1339
+ this.#servers.set(id, entry)
1340
+ }
1341
+ return entry
1342
+ }
1343
+
1344
+ /** @returns {void} */
1345
+ #assertUsable() {
1346
+ if (this.#disposed) {
1347
+ throw new PoolError('CONNECTION_LOST', 'The SSH pool has been disposed; the plugin is shutting down.', {})
1348
+ }
1349
+ }
1350
+
1351
+ /**
1352
+ * @param {'info'|'warn'|'error'} level
1353
+ * @param {string} message
1354
+ * @param {...unknown} args
1355
+ * @returns {void}
1356
+ */
1357
+ #log(level, message, ...args) {
1358
+ const logger = this.#logger
1359
+ if (logger === null || logger === undefined) return
1360
+ const write = logger[level] ?? logger.info
1361
+ if (typeof write === 'function') write.call(logger, message, ...args)
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * Turn a socket-level signal into the exit code a shell would report.
1367
+ * @param {string|null} signal
1368
+ * @returns {number}
1369
+ */
1370
+ function signalExitCode(signal) {
1371
+ if (signal === null) return 0
1372
+ /** @type {Record<string, number>} */
1373
+ const numbers = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
1374
+ const number = numbers[signal]
1375
+ return number === undefined ? 1 : 128 + number
1376
+ }
1377
+
1378
+ /**
1379
+ * Fingerprint one host key blob through the store's own implementation.
1380
+ * Returns `null` instead of throwing, because the ssh2 callback that calls this
1381
+ * has no channel to report an error through: the pool then refuses the host.
1382
+ * @param {Buffer|Uint8Array|string} keyBlob
1383
+ * @returns {{hash: string, algorithm: string}|null}
1384
+ */
1385
+ function fingerprintOrNull(keyBlob) {
1386
+ try {
1387
+ return fingerprintHostKey(typeof keyBlob === 'string' ? Buffer.from(keyBlob, 'latin1') : keyBlob)
1388
+ } catch {
1389
+ return null
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * The §7.5 messages for a refused host key.
1395
+ * @param {import('./registry.js').ServerRecord} server
1396
+ * @param {'changed'|'unknown'} reason
1397
+ * @returns {HostKeyRejectedError}
1398
+ */
1399
+ function hostKeyMessage(server, reason) {
1400
+ const target = `${server.host}:${server.port}`
1401
+ if (reason === 'changed') {
1402
+ return new HostKeyRejectedError(
1403
+ server.id,
1404
+ `The host key of ${target} changed; refusing to connect. If the change is expected, ` +
1405
+ `remove the entry for server ${server.id} from known_hosts.json (or trust the new key in the panel) and retry.`,
1406
+ )
1407
+ }
1408
+ return new HostKeyRejectedError(
1409
+ server.id,
1410
+ `The host key of ${target} is not trusted yet and server ${server.id} uses hostKeyMode "verify". ` +
1411
+ 'Switch it to "accept-new" (or add the fingerprint to known_hosts.json) and retry.',
1412
+ )
1413
+ }
1414
+
1415
+ /**
1416
+ * @param {unknown} value
1417
+ * @returns {import('./registry.js').ServerRecord}
1418
+ */
1419
+ function requireServer(value) {
1420
+ if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.trim() === '') {
1421
+ throw new PoolError('INVALID_SERVER', 'A complete server record is required (it must carry an id).', {})
1422
+ }
1423
+ if (typeof value.host !== 'string' || value.host.trim() === '') {
1424
+ throw new PoolError('INVALID_SERVER', `Server ${value.id} has no host.`, { serverId: value.id })
1425
+ }
1426
+ return /** @type {import('./registry.js').ServerRecord} */ (value)
1427
+ }
1428
+
1429
+ /**
1430
+ * @param {ExecRequest} request
1431
+ * @returns {string}
1432
+ */
1433
+ function normalizeCommand(request) {
1434
+ if (!isPlainObject(request) || typeof request.command !== 'string' || request.command.trim() === '') {
1435
+ throw new PoolError('INVALID_SERVER', 'exec() needs a non-empty command.', {})
1436
+ }
1437
+ return request.command
1438
+ }
1439
+
1440
+ /**
1441
+ * @param {unknown} value
1442
+ * @param {number} fallback
1443
+ * @returns {number}
1444
+ */
1445
+ function normalizePositive(value, fallback) {
1446
+ if (value === undefined || value === null) return fallback
1447
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1448
+ throw new PoolError('INVALID_SERVER', 'timeoutMs and maxOutputChars must be positive numbers.', {})
1449
+ }
1450
+ return Math.round(value)
1451
+ }
1452
+
1453
+ /**
1454
+ * @param {unknown} value
1455
+ * @param {import('./registry.js').ServerRecord} server
1456
+ * @returns {string|null}
1457
+ */
1458
+ function normalizeCwd(value, server) {
1459
+ if (value === undefined) return server.defaultWorkDir ?? null
1460
+ if (value === null) return server.defaultWorkDir ?? null
1461
+ if (typeof value !== 'string') {
1462
+ throw new PoolError('INVALID_SERVER', 'cwd must be a string or null.', { serverId: server.id })
1463
+ }
1464
+ const trimmed = value.trim()
1465
+ return trimmed === '' ? server.defaultWorkDir ?? null : trimmed
1466
+ }
1467
+
1468
+ /**
1469
+ * Close one connection without letting a half-open socket throw on the way out.
1470
+ * @param {any} connection
1471
+ * @returns {Promise<void>}
1472
+ */
1473
+ async function closeConnection(connection) {
1474
+ connection.alive = false
1475
+ const client = connection.client
1476
+ if (client === undefined) return
1477
+ try {
1478
+ client.end()
1479
+ } catch {
1480
+ // Already destroyed.
1481
+ }
1482
+ }
1483
+
1484
+ /**
1485
+ * @param {any} client
1486
+ * @returns {void}
1487
+ */
1488
+ function safeDestroy(client) {
1489
+ try {
1490
+ client.destroy()
1491
+ } catch {
1492
+ // Never connected.
1493
+ }
1494
+ }
1495
+
1496
+ export default Pool