@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -0,0 +1,1083 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { Client, type ConnectConfig } from 'ssh2'
3
+ import { ConnectionPool, type SshConnectionService } from './pool.ts'
4
+ import { holdLeaseUntilSettled, type ClientLease } from './lease.ts'
5
+ import { BoundedUtf8Output } from '../exec/output.ts'
6
+ import {
7
+ HostKeyMismatchError,
8
+ HostKeyPolicy,
9
+ HostKeyUnknownError,
10
+ type HostKeyCheck,
11
+ type KnownHostsStore,
12
+ } from '../known-hosts.ts'
13
+ import { expandHome, type HostStore } from '../store.ts'
14
+ import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult } from '../protocol.ts'
15
+ import type { HostStoreView } from '../../core.ts'
16
+
17
+ export interface SshInvalidateOptions {
18
+ /**
19
+ * Also invalidate hosts whose ProxyJump chain depends, directly or
20
+ * transitively, on the changed alias.
21
+ */
22
+ includeDependents?: boolean
23
+
24
+ /**
25
+ * drain: reject/reconnect subsequent acquisitions, allow existing leases
26
+ * to complete before closing their transport.
27
+ * force: close the current transport immediately.
28
+ */
29
+ mode?: 'drain' | 'force'
30
+ }
31
+
32
+ /** Default engine knobs. */
33
+ export interface EngineOptions {
34
+ /** Connections idle longer than this are closed (ms). */
35
+ idleTimeoutMs?: number
36
+ /** SSH handshake timeout (ms). */
37
+ connectTimeoutMs?: number
38
+ /** Keepalive ping interval (ms). */
39
+ keepaliveIntervalMs?: number
40
+ /** Cap on captured stdout/stderr bytes per exec (ms). */
41
+ maxOutputBytes?: number
42
+ /** Default exec timeout (ms). */
43
+ defaultExecTimeoutMs?: number
44
+ /** Default cluster concurrency. */
45
+ defaultMaxWorkers?: number
46
+ /** SFTP concurrent channel count for transfers. */
47
+ sftpConcurrency?: number
48
+ /** Deadline for opening an SFTP subsystem channel. */
49
+ sftpOpenTimeoutMs?: number
50
+ /** Per-request SFTP callback deadline. */
51
+ sftpOperationTimeoutMs?: number
52
+ /** Whole-file read/write stream inactivity deadline. */
53
+ sftpReadTimeoutMs?: number
54
+ /** Maximum bytes buffered by readFile before it aborts. */
55
+ maxReadFileBytes?: number
56
+ /** Upload/download inactivity deadline, reset by progress. */
57
+ sftpTransferIdleTimeoutMs?: number
58
+ /** Absolute budget for one recursive rm traversal. */
59
+ sftpRecursiveRmTimeoutMs?: number
60
+ /**
61
+ * Optional server-host-key algorithm whitelist (e.g. ['ssh-ed25519']).
62
+ * Single authoritative source: connection building reads it from these
63
+ * options (EngineDeps deliberately has no duplicate).
64
+ */
65
+ hostKeyAlgorithms?: string[]
66
+ }
67
+
68
+ /**
69
+ * Optional engine dependencies for host-key TOFU and secret resolution.
70
+ * All fields are optional and their absence preserves the pre-security
71
+ * behavior exactly (plaintext inline auth, no host verification) — the
72
+ * existing tests and call sites keep working unchanged.
73
+ */
74
+ export interface EngineDeps {
75
+ /** Known-hosts trust store; when set, connections require a trusted host key. */
76
+ knownHosts?: KnownHostsStore
77
+ /** Fingerprint check policy (defaults to a HostKeyPolicy over knownHosts). */
78
+ hostKeyPolicy?: HostKeyPolicy
79
+ /**
80
+ * Secret resolution for one entry. Absent: read `password`/`passphrase`
81
+ * inline from the entry (plaintext store / test compatibility).
82
+ */
83
+ resolveSecrets?: (entry: SshHostEntry) => Promise<ResolvedAuthDeps>
84
+ /** Final output/error redactor (typically the unlocked credential vault). */
85
+ redactOutput?: (text: string) => string
86
+ }
87
+
88
+ /** The resolved-auth shape passed into connect config building (vault-aware). */
89
+ export interface ResolvedAuthDeps {
90
+ kind: SshHostEntry['auth']['kind']
91
+ keyPath?: string
92
+ password?: string
93
+ passphrase?: string
94
+ }
95
+
96
+ /**
97
+ * Thrown when a connection needs a password/passphrase that is not yet
98
+ * available in this session (secretStorage='none' and the user hasn't entered
99
+ * it yet). The GUI intercepts this and prompts for the credential, then
100
+ * injects it via engine.setSessionPassword and retries.
101
+ */
102
+ export class NeedsPasswordError extends Error {
103
+ /** Which secret the connection needs: 'password' or 'passphrase'. */
104
+ readonly secret: 'password' | 'passphrase'
105
+ constructor(alias: string, secret: 'password' | 'passphrase') {
106
+ super(`SSH 连接 '${alias}' 需要${secret === 'password' ? '密码' : '密钥口令'},请先输入一次(在该连接存活期内复用,连接池回收后需重新输入;不会保存)`)
107
+ this.name = 'NeedsPasswordError'
108
+ this.secret = secret
109
+ }
110
+ }
111
+
112
+ export const DEFAULTS: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> = {
113
+ idleTimeoutMs: 30 * 60_000,
114
+ connectTimeoutMs: 15_000,
115
+ keepaliveIntervalMs: 15_000,
116
+ maxOutputBytes: 2 * 1024 * 1024,
117
+ defaultExecTimeoutMs: 60_000,
118
+ defaultMaxWorkers: 8,
119
+ sftpConcurrency: 8,
120
+ sftpOpenTimeoutMs: 15_000,
121
+ sftpOperationTimeoutMs: 15_000,
122
+ sftpReadTimeoutMs: 60_000,
123
+ maxReadFileBytes: 32 * 1024 * 1024,
124
+ sftpTransferIdleTimeoutMs: 60_000,
125
+ sftpRecursiveRmTimeoutMs: 60_000,
126
+ hostKeyAlgorithms: undefined,
127
+ }
128
+
129
+ /**
130
+ * How much an operation may be retried:
131
+ * - never: one acquisition + one operation attempt.
132
+ * - connect-only: connection acquisition may be retried, but once the
133
+ * operation function starts it is invoked at most once (default).
134
+ * - idempotent: the operation may also be retried until it calls
135
+ * markCommitted() (i.e. while the exec channel is still opening).
136
+ *
137
+ * SFTP operations must never use 'idempotent': they have no commit point, so
138
+ * a replay after a mid-flight timeout would duplicate a remote write.
139
+ */
140
+ export type RetryPolicy = 'never' | 'connect-only' | 'idempotent'
141
+
142
+ /** Options for a one-shot remote command. */
143
+ export interface ExecOptions {
144
+ timeoutMs?: number
145
+ retry?: RetryPolicy
146
+ signal?: AbortSignal
147
+ }
148
+
149
+ /** Internal options for withClient(). */
150
+ export interface WithClientOptions {
151
+ /** Total acquire+operation attempt budget (default 3, capped at 1 for 'never'). */
152
+ attempts?: number
153
+ retryPolicy?: RetryPolicy
154
+ signal?: AbortSignal
155
+ }
156
+
157
+ /** Lets an operation declare the point after which replay is unsafe. */
158
+ export interface OperationControl {
159
+ markCommitted(): void
160
+ /**
161
+ * The caller's abort signal for THIS operation (undefined = not cancellable).
162
+ * Operations that can cancel their own request (`exec` closing its channel,
163
+ * an SFTP read stream) listen here, so an abort does not have to retire the
164
+ * whole shared transport out from under concurrent holders.
165
+ */
166
+ readonly signal?: AbortSignal
167
+ }
168
+
169
+ export interface HostKeyOutcome {
170
+ alias: string
171
+ check: HostKeyCheck
172
+ }
173
+
174
+ /**
175
+ * Half-open channels tolerated on one pooled transport before it is retired.
176
+ * Deliberately well below OpenSSH's default `MaxSessions` (10) so the budget is
177
+ * never actually exhausted.
178
+ */
179
+ export const MAX_LEAKED_CHANNELS = 4
180
+
181
+ /**
182
+ * Resolve the ssh-agent socket to offer to ssh2 (zero-input key auth,
183
+ * VSCode-style): `$SSH_AUTH_SOCK` — the standard OpenSSH agent socket (also
184
+ * exported by Git for Windows' ssh-agent and WSL). Deliberately NOT probing
185
+ * named pipes (Pageant / Windows OpenSSH agent): an absent pipe makes ssh2's
186
+ * agent query stall the whole handshake until readyTimeout instead of
187
+ * falling through to the next method. Keep Pageant compatibility for a
188
+ * future explicit opt-in. An agent that yields no keys makes ssh2 fall
189
+ * through to the configured methods (privateKey → password), so enabling it
190
+ * when a socket is present is safe. Exported for tests.
191
+ */
192
+ export function sshAgentConfig(): string | undefined {
193
+ const sock = process.env.SSH_AUTH_SOCK
194
+ if (sock !== undefined && sock.trim() !== '') return sock
195
+ return undefined
196
+ }
197
+
198
+ /** Detect whether an OpenSSH/PEM private key file is passphrase-encrypted.
199
+ * OpenSSH-format keys keep the cipher/kdf strings in PLAINTEXT inside the
200
+ * base64 payload ('bcrypt' kdf ⇒ encrypted, 'none' ⇒ plain); PEM keys carry
201
+ * "Proc-Type: 4,ENCRYPTED". Used to prompt for a missing passphrase. */
202
+ function keyNeedsPassphrase(keyPath: string): boolean {
203
+ try {
204
+ const text = readFileSync(keyPath, 'utf8')
205
+ if (/Proc-Type:\s*4,ENCRYPTED/i.test(text)) return true
206
+ if (text.includes('OPENSSH PRIVATE KEY')) {
207
+ const base64 = text.replace(/-----[^-]*-----/g, '').replace(/\s+/g, '')
208
+ const header = Buffer.from(base64, 'base64').toString('latin1', 0, 512)
209
+ return header.includes('bcrypt')
210
+ }
211
+ return false
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ /** Build the ssh2 connect config for one entry (key read from disk). The
218
+ * timeout/keepalive knobs come from EngineOptions so they actually take
219
+ * effect instead of being hard-coded. Exported for tests. */
220
+ export function buildConnectConfig(
221
+ entry: SshHostEntry,
222
+ options: Pick<Required<EngineOptions>, 'connectTimeoutMs' | 'keepaliveIntervalMs'>,
223
+ sock?: ConnectConfig['sock'],
224
+ buildContext: {
225
+ hostKeyPolicy?: HostKeyPolicy
226
+ hostKeyAlgorithms?: string[]
227
+ /** Writes the verified/refused outcome back to the caller's capture slot. */
228
+ setOutcome?: (value: HostKeyOutcome) => void
229
+ /** Vault-resolved authentication (overrides entry.auth secrets). */
230
+ authOverride?: ResolvedAuthDeps
231
+ } = {},
232
+ ): ConnectConfig {
233
+ const config: ConnectConfig = {
234
+ host: entry.host,
235
+ port: entry.port,
236
+ username: entry.user,
237
+ readyTimeout: options.connectTimeoutMs,
238
+ keepaliveInterval: options.keepaliveIntervalMs,
239
+ keepaliveCountMax: 3,
240
+ }
241
+ if (sock !== undefined) config.sock = sock
242
+ const agent = sshAgentConfig()
243
+ if (agent !== undefined) config.agent = agent
244
+ if (buildContext.hostKeyPolicy !== undefined) {
245
+ config.hostVerifier = (serverKey: Buffer) => {
246
+ const check = buildContext.hostKeyPolicy!.check(entry.alias, serverKey, { host: entry.host, port: entry.port })
247
+ buildContext.setOutcome?.({ alias: entry.alias, check })
248
+ return check.kind === 'trusted'
249
+ }
250
+ }
251
+ if (buildContext.hostKeyAlgorithms !== undefined && buildContext.hostKeyAlgorithms.length > 0) {
252
+ config.algorithms = { serverHostKey: buildContext.hostKeyAlgorithms as import('ssh2').ServerHostKeyAlgorithm[] }
253
+ }
254
+ const auth = buildContext.authOverride
255
+ ?? { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
256
+ if (auth.kind === 'password') {
257
+ config.password = auth.password
258
+ } else {
259
+ const keyPath = auth.keyPath === undefined ? undefined : expandHome(auth.keyPath)
260
+ if (keyPath !== undefined && keyPath !== '' && existsSync(keyPath)) {
261
+ config.privateKey = readFileSync(keyPath, 'utf8')
262
+ if (auth.passphrase !== undefined && auth.passphrase !== '') {
263
+ config.passphrase = auth.passphrase
264
+ }
265
+ } else if (agent === undefined) {
266
+ // No key file AND no agent to fall back on — fail before the
267
+ // handshake with a precise message instead of a generic auth failure.
268
+ throw new Error(`private key not found: '${auth.keyPath ?? '(unset)'}' and no ssh-agent is available (set SSH_AUTH_SOCK, or configure a key path)`)
269
+ }
270
+ // Else: the key path is unset or missing but an agent is available —
271
+ // leave privateKey unset so ssh2 authenticates from the agent's keys
272
+ // (zero input, the VSCode Remote-SSH way).
273
+ }
274
+ return config
275
+ }
276
+
277
+ /**
278
+ * Connect one ssh2 client (resolve on ready, reject on error/close). A hard
279
+ * `timeoutMs` bounds the WHOLE connect phase: ssh2's own `readyTimeout` only
280
+ * starts ticking after the TCP socket is up, so a SYN-level hang (filtered
281
+ * port, dead route, half-open middlebox) would otherwise stall the promise
282
+ * forever — which hangs every caller (exec, openShell, tunnels). On timeout
283
+ * the socket is destroyed and the promise rejects.
284
+ *
285
+ * When `context` carries a captured host-key outcome from a prior
286
+ * `hostVerifier` refusal, the generic error is rewritten into a typed
287
+ * HostKeyUnknownError / HostKeyMismatchError so callers and the GUI can
288
+ * surface the fingerprint directly.
289
+ */
290
+ function connectClient(
291
+ config: ConnectConfig,
292
+ timeoutMs: number,
293
+ context: { outcome?: HostKeyOutcome | undefined } = {},
294
+ signal?: AbortSignal,
295
+ ): Promise<Client> {
296
+ return new Promise((resolve, reject) => {
297
+ const client = new Client()
298
+ let settled = false
299
+ let onAbort = (): void => {}
300
+ const timer = setTimeout(() => {
301
+ if (settled) return
302
+ settled = true
303
+ signal?.removeEventListener('abort', onAbort)
304
+ const err = new Error(`SSH connect to ${config.host}:${config.port} (${config.username}) timed out after ${timeoutMs} ms`)
305
+ try { client.destroy() } catch { /* already closed */ }
306
+ reject(err)
307
+ }, timeoutMs)
308
+ timer.unref?.()
309
+ const settle = (fn: () => void): void => {
310
+ if (settled) return
311
+ settled = true
312
+ clearTimeout(timer)
313
+ signal?.removeEventListener('abort', onAbort)
314
+ fn()
315
+ }
316
+ const fail = (raw: Error): void => settle(() => {
317
+ // A Client that never reached ready is owned entirely by this attempt;
318
+ // do not rely on ssh2 to close it after auth/handshake failure.
319
+ try { client.destroy() } catch { /* already closed */ }
320
+ reject(rewriteHostKeyError(raw, context.outcome))
321
+ })
322
+ onAbort = (): void => {
323
+ const error = signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('SSH connect aborted'), { name: 'AbortError' })
324
+ fail(error)
325
+ }
326
+ signal?.addEventListener('abort', onAbort, { once: true })
327
+ if (signal?.aborted === true) {
328
+ onAbort()
329
+ return
330
+ }
331
+ client.once('ready', () => settle(() => resolve(client)))
332
+ // PERMANENT listener (not `once`): a Client that already settled its
333
+ // connect promise must never emit an unattended 'error' (e.g. the
334
+ // network drops right after the pooled connection was handed over, or a
335
+ // jump hop dies) — an unhandled 'error' on the EventEmitter CRASHES the
336
+ // whole node process. Post-ready errors are the pool's job (breakRecord);
337
+ // here we only reject while the connect is still in flight.
338
+ client.on('error', (error) => {
339
+ if (settled) return
340
+ fail(error instanceof Error ? error : new Error(String(error)))
341
+ })
342
+ // A server that drops the socket before 'ready' (e.g. during auth or a
343
+ // failed acquire) emits 'close' without 'error' — fail fast instead of
344
+ // waiting out the whole connect timeout.
345
+ client.once('close', () => fail(
346
+ new Error(`SSH connection to ${config.host}:${config.port} (${config.username}) closed before ready`),
347
+ ))
348
+ try {
349
+ client.connect(config)
350
+ } catch (error) {
351
+ fail(error instanceof Error ? error : new Error(String(error)))
352
+ }
353
+ })
354
+ }
355
+
356
+ /** Rewrite a raw connect failure into a typed host-key error when the
357
+ * hostVerifier refused the server key (unknown or mismatch). */
358
+ function rewriteHostKeyError(raw: Error, outcome: HostKeyOutcome | undefined): Error {
359
+ if (outcome?.check.kind === 'unknown') {
360
+ return new HostKeyUnknownError(outcome.alias, outcome.check.fingerprintSha256)
361
+ }
362
+ if (outcome?.check.kind === 'mismatch') {
363
+ return new HostKeyMismatchError(outcome.alias, outcome.check.expected, outcome.check.actual)
364
+ }
365
+ return raw
366
+ }
367
+
368
+ /**
369
+ * Host-side hooks the connection manager needs but must not own. Injected by
370
+ * the facade so every resource keeps exactly one dispose point and the manager
371
+ * never imports the facade back (no cycle).
372
+ */
373
+ export interface ConnectionManagerAccess {
374
+ /** A pooled client is being torn down — drop its cached SFTP channel. */
375
+ onClientDisposed?(client: Client, error: Error): void
376
+ /** Vault-aware output guard applied before session-secret masking. */
377
+ redactOutput?(text: string): string
378
+ /**
379
+ * The one-command runner (the facade's `exec`). Injected rather than
380
+ * reimplemented here so cluster() fans out through the SAME method surface
381
+ * callers/spies observe on the engine, and `test` reuses it verbatim.
382
+ */
383
+ executor?: (alias: string, command: string, options?: ExecOptions) => Promise<ExecResult>
384
+ }
385
+
386
+ /**
387
+ * Owns the pooled SSH connections and everything layered on them: per-alias
388
+ * ProxyJump connect chains, session-scoped secrets and output redaction, the
389
+ * retry/replay policy, and the command surface (exec/cluster/test).
390
+ *
391
+ * Dispose order lives in the facade: tunnels, standalone terminal transports
392
+ * and SFTP channels are closed first, so a resource is always closed before
393
+ * the transport that carries it.
394
+ */
395
+ export class ConnectionManager {
396
+ private readonly store: HostStoreView
397
+ private readonly opts: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'>
398
+ private readonly access: ConnectionManagerAccess
399
+ private readonly executor: (alias: string, command: string, options?: ExecOptions) => Promise<ExecResult>
400
+ private readonly connectionPool: SshConnectionService
401
+ private readonly deps: EngineDeps
402
+ private readonly hostKeyPolicy: HostKeyPolicy | undefined
403
+ /**
404
+ * Session-scoped secrets (secretStorage='none'): keyed by alias, populated
405
+ * by the GUI on first connect, used by connectChain's resolve step, and
406
+ * cleared on dispose. Never persisted.
407
+ */
408
+ private readonly sessionPasswords = new Map<string, { password?: string; passphrase?: string }>()
409
+ /**
410
+ * alias → every secret value registered for it in this process, including
411
+ * ones replaced by a newer value. Released with the alias's connection, so a
412
+ * rotated password stays redacted while the old connection can still echo it.
413
+ */
414
+ private readonly secretHistory = new Map<string, Set<string>>()
415
+ private readonly sensitiveOutputs = new Set<string>()
416
+ /** Per-alias count of channels that never acknowledged close after a timeout. */
417
+ private readonly leakedChannels = new Map<string, number>()
418
+
419
+ /**
420
+ * @param store - the host config store.
421
+ * @param options - engine knobs (defaults applied after validation).
422
+ * @param deps - optional security deps (host-key TOFU, secret resolution).
423
+ * Absent → pre-security behavior (inline auth, no host verification).
424
+ * @param access - optional host-side hooks (SFTP cache eviction, redactor).
425
+ */
426
+ constructor(
427
+ store: HostStoreView,
428
+ options?: EngineOptions,
429
+ deps?: EngineDeps,
430
+ access: ConnectionManagerAccess = {},
431
+ ) {
432
+ this.store = store
433
+ this.opts = { ...DEFAULTS, ...options }
434
+ this.access = access
435
+ this.executor = access.executor ?? (async () => { throw new Error('SSH executor is not wired') })
436
+ this.deps = deps ?? {}
437
+ this.hostKeyPolicy = this.deps.hostKeyPolicy
438
+ ?? (this.deps.knownHosts !== undefined ? new HostKeyPolicy(this.deps.knownHosts) : undefined)
439
+ this.connectionPool = new ConnectionPool({
440
+ idleTimeoutMs: this.opts.idleTimeoutMs,
441
+ connect: async (alias, signal) => {
442
+ const entry = this.store.find(alias)
443
+ if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
444
+ return await this.connectChain(entry, signal)
445
+ },
446
+ onDispose: (client) => {
447
+ this.access.onClientDisposed?.(
448
+ client,
449
+ new Error('SSH connection disposed while SFTP was active'),
450
+ )
451
+ },
452
+ // The session password lives exactly as long as the connection it was
453
+ // entered for: retiring the transport drops it, so an idle sweep cannot
454
+ // leave the credential usable for the rest of the process.
455
+ onRetire: (alias) => { this.forgetSessionPassword(alias) },
456
+ })
457
+ }
458
+
459
+ /** Validate the engine knobs once, so the connection and SFTP components
460
+ * read one frozen source of truth. */
461
+ static resolveOptions(options?: EngineOptions): Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> {
462
+ const resolved = { ...DEFAULTS, ...options }
463
+ for (const [name, value] of Object.entries({
464
+ sftpOpenTimeoutMs: resolved.sftpOpenTimeoutMs,
465
+ sftpOperationTimeoutMs: resolved.sftpOperationTimeoutMs,
466
+ sftpReadTimeoutMs: resolved.sftpReadTimeoutMs,
467
+ sftpTransferIdleTimeoutMs: resolved.sftpTransferIdleTimeoutMs,
468
+ sftpRecursiveRmTimeoutMs: resolved.sftpRecursiveRmTimeoutMs,
469
+ })) {
470
+ if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
471
+ throw new Error(`${name} must be a positive representable timer duration`)
472
+ }
473
+ }
474
+ if (!Number.isSafeInteger(resolved.maxReadFileBytes) || resolved.maxReadFileBytes <= 0) {
475
+ throw new Error('maxReadFileBytes must be a positive safe integer')
476
+ }
477
+ return resolved
478
+ }
479
+
480
+ /** Aliases with a live pooled transport right now (for connection-state
481
+ * indicators — the GUI badge colors bound workspaces by it). */
482
+ connectedAliases(): string[] {
483
+ return this.connectionPool.liveAliases()
484
+ }
485
+
486
+ /**
487
+ * Record one channel that never acknowledged its close after a hard timeout.
488
+ *
489
+ * ssh2 cannot cancel a request, so a peer that ignores KILL + close leaves a
490
+ * half-open channel counted against the server's `MaxSessions` budget. Once
491
+ * enough of them accumulate the pooled transport is DRAINED — not force-
492
+ * closed — so concurrent holders finish their work while new operations open
493
+ * a fresh generation (the pool reaps the old record when its last lease is
494
+ * released).
495
+ *
496
+ * The counter is per alias and is decremented again by
497
+ * {@link noteChannelClosed} when a late acknowledgement finally arrives, so a
498
+ * merely slow peer cannot accumulate its way to a retirement.
499
+ *
500
+ * @param alias - the host the channel belonged to.
501
+ * @returns true when the transport was retired.
502
+ */
503
+ noteLeakedChannel(alias: string): boolean {
504
+ const next = (this.leakedChannels.get(alias) ?? 0) + 1
505
+ this.leakedChannels.set(alias, next)
506
+ if (next < MAX_LEAKED_CHANNELS) return false
507
+ console.warn(`[dsh-hardssh] SSH connection '${alias}' left ${next} channels unacknowledged after timeouts; draining the pooled transport to protect the server session budget`)
508
+ this.leakedChannels.delete(alias)
509
+ this.invalidate(alias, { mode: 'drain' })
510
+ return true
511
+ }
512
+
513
+ /** A timed-out channel finally acknowledged its close: un-count it. */
514
+ noteChannelClosed(alias: string): void {
515
+ const current = this.leakedChannels.get(alias)
516
+ if (current === undefined) return
517
+ if (current <= 1) this.leakedChannels.delete(alias)
518
+ else this.leakedChannels.set(alias, current - 1)
519
+ }
520
+
521
+ /** Pooled connections owned by this manager (the tunnel component's narrow
522
+ * access seam, and the public `connections` getter on the facade). */
523
+ get connections(): SshConnectionService {
524
+ return this.connectionPool
525
+ }
526
+
527
+ /** The validated engine knobs. The facade's exec reads its output cap and
528
+ * default budget here, so defaults keep exactly one source of truth. */
529
+ get resolvedOptions(): Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> {
530
+ return this.opts
531
+ }
532
+
533
+ // ---------------------------------------------------------- session secrets
534
+
535
+ /**
536
+ * Provide a secret for `alias` for THIS session only (never persisted).
537
+ * Used by the GUI when a connection needs a password/passphrase under
538
+ * secretStorage='none'. Once set, pooled connections reuse it until the
539
+ * session ends or clearSessionSecrets() is called.
540
+ */
541
+ setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
542
+ this.sessionPasswords.set(alias, secret)
543
+ // Keep every value ever registered for this alias, not just the newest: a
544
+ // REPLACED password may still be echoed by the connection that was opened
545
+ // with it, and dropping it from the leak guard at that moment would leak it.
546
+ // The history is released when the alias's connection is retired.
547
+ const history = this.secretHistory.get(alias) ?? new Set<string>()
548
+ for (const value of [secret.password, secret.passphrase]) {
549
+ if (value !== undefined && value !== '') history.add(value)
550
+ }
551
+ this.secretHistory.set(alias, history)
552
+ this.rebuildSensitiveOutputs()
553
+ }
554
+
555
+ /** Read the session secret for one alias (undefined = not provided yet). */
556
+ getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
557
+ return this.sessionPasswords.get(alias)
558
+ }
559
+
560
+ /**
561
+ * Drop ONE alias's session secret. Called when its pooled connection is
562
+ * retired: the credential is tied to the connection's lifetime, so an idle
563
+ * sweep must not leave it usable for the rest of the process.
564
+ */
565
+ forgetSessionPassword(alias: string): void {
566
+ const hadSecret = this.sessionPasswords.delete(alias)
567
+ const hadHistory = this.secretHistory.delete(alias)
568
+ if (hadSecret || hadHistory) this.rebuildSensitiveOutputs()
569
+ }
570
+
571
+ /** Drop every session secret (e.g. on secretStorage change / lock). */
572
+ clearSessionSecrets(): void {
573
+ this.sessionPasswords.clear()
574
+ this.secretHistory.clear()
575
+ this.rebuildSensitiveOutputs()
576
+ }
577
+
578
+ /** Recompute the redaction set from every secret still in force, including
579
+ * values replaced by a newer one on the same alias (their connection may
580
+ * still be alive), so dropping one alias never un-redacts another's. */
581
+ private rebuildSensitiveOutputs(): void {
582
+ this.sensitiveOutputs.clear()
583
+ for (const history of this.secretHistory.values()) {
584
+ for (const value of history) this.sensitiveOutputs.add(value)
585
+ }
586
+ }
587
+ /** Redact credentials known to this session and the optional vault guard. */
588
+ redact(text: string): string {
589
+ let output = this.access.redactOutput?.(text) ?? this.deps.redactOutput?.(text) ?? text
590
+ for (const value of [...this.sensitiveOutputs].sort((a, b) => b.length - a.length)) {
591
+ output = output.split(value).join('[REDACTED]')
592
+ }
593
+ return output
594
+ }
595
+
596
+ /** Secret-free host list (filtered by the optional query). */
597
+ list(query?: string): SshHostSummary[] {
598
+ const needle = query?.trim().toLowerCase()
599
+ return this.store.list()
600
+ .filter(entry => needle === undefined || needle === ''
601
+ || entry.alias.toLowerCase().includes(needle)
602
+ || (entry.description ?? '').toLowerCase().includes(needle)
603
+ || entry.host.toLowerCase().includes(needle)
604
+ || entry.tags.some(tag => tag.toLowerCase().includes(needle)))
605
+ .map(entry => this.store.summarize(entry))
606
+ }
607
+
608
+ /** One host summary by alias. */
609
+ find(alias: string): SshHostSummary | undefined {
610
+ const entry = this.store.find(alias)
611
+ return entry === undefined ? undefined : this.store.summarize(entry)
612
+ }
613
+ /**
614
+ * Retire the pooled connection for one alias.
615
+ *
616
+ * ConnectionPool knows nothing about HostStore or ProxyJump configuration
617
+ * (every target owns its complete jump chain, no hop records are shared),
618
+ * so dependent-host expansion belongs here: with includeDependents the
619
+ * transitive reverse ProxyJump closure of `alias` is invalidated too.
620
+ */
621
+ invalidate(alias: string, options: SshInvalidateOptions = {}): void {
622
+ const aliases = new Set<string>([alias])
623
+
624
+ if (options.includeDependents === true) {
625
+ const entries = this.store.list()
626
+ // Fixed-point scan: host counts are small, simpler and more reliable
627
+ // than maintaining a second dependency index.
628
+ let changed = true
629
+ while (changed) {
630
+ changed = false
631
+ for (const entry of entries) {
632
+ if (aliases.has(entry.alias)) continue
633
+ if (!entry.proxyJump.some(hopAlias => aliases.has(hopAlias))) continue
634
+ aliases.add(entry.alias)
635
+ changed = true
636
+ }
637
+ }
638
+ }
639
+
640
+ for (const targetAlias of aliases) {
641
+ // Dependents are already expanded above; the pool itself has no
642
+ // ProxyJump topology.
643
+ this.connectionPool.invalidate(targetAlias, {
644
+ includeDependents: false,
645
+ mode: options.mode,
646
+ })
647
+ }
648
+ }
649
+ /**
650
+ * Run `fn` with a live client for `alias`.
651
+ *
652
+ * Acquisition retry and operation replay are deliberately separate:
653
+ * - never: one acquire + one operation attempt.
654
+ * - connect-only: acquire may be retried, but fn is invoked at most once —
655
+ * once fn starts, the remote may already have observed the request, so
656
+ * replay could duplicate non-idempotent work.
657
+ * - idempotent: fn may be retried until it calls control.markCommitted()
658
+ * (exec marks this when the server accepted the channel).
659
+ *
660
+ * A failed operation retires the transport via lease.markBroken(); the
661
+ * lease is always released before the next acquire attempt.
662
+ */
663
+ async withClient<T>(
664
+ alias: string,
665
+ fn: (client: Client, control: OperationControl) => Promise<T>,
666
+ options: WithClientOptions = {},
667
+ ): Promise<T> {
668
+ const retryPolicy = options.retryPolicy ?? 'connect-only'
669
+ const configuredAttempts = options.attempts ?? 3
670
+ if (!Number.isInteger(configuredAttempts) || configuredAttempts < 1) {
671
+ throw new Error('withClient attempts must be a positive integer')
672
+ }
673
+ const maxAttempts = retryPolicy === 'never' ? 1 : configuredAttempts
674
+ let lastError: unknown
675
+
676
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
677
+ let lease: ClientLease
678
+
679
+ // Acquisition sits OUTSIDE the operation try: an acquire failure means
680
+ // fn never ran, so no remote work was submitted and retrying is safe.
681
+ try {
682
+ lease = await this.connectionPool.acquire(alias, { kind: 'operation', signal: options.signal })
683
+ } catch (error) {
684
+ lastError = error
685
+ if (options.signal?.aborted === true || retryPolicy === 'never' || attempt === maxAttempts) {
686
+ throw error instanceof Error ? error : new Error(String(error))
687
+ }
688
+ continue
689
+ }
690
+
691
+ let committed = false
692
+ const control: OperationControl = {
693
+ markCommitted: (): void => { committed = true },
694
+ signal: options.signal,
695
+ }
696
+
697
+ // The lease is released when the OPERATION settles, never when the caller
698
+ // stops waiting. Releasing on abort used to make `holdsOnlyLease()`
699
+ // report "nobody else is using this connection" while fn was still in
700
+ // flight, so a later abort in another session would end the shared
701
+ // transport under it — killing the very request the first abort had just
702
+ // tried to protect.
703
+ try {
704
+ let operation: Promise<T>
705
+ try {
706
+ operation = Promise.resolve(fn(lease.client, control))
707
+ } catch (error) {
708
+ lease.release()
709
+ throw error
710
+ }
711
+ // Detach: the release (and the rejection of an abandoned operation)
712
+ // belongs to the operation's own lifetime.
713
+ const tracked = holdLeaseUntilSettled(lease, operation)
714
+ void tracked.catch(() => undefined)
715
+ if (options.signal === undefined) return await tracked
716
+ const signal = options.signal
717
+ return await new Promise<T>((resolve, reject) => {
718
+ let settled = false
719
+ const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
720
+ const onAbort = (): void => {
721
+ if (settled) return
722
+ settled = true
723
+ cleanup()
724
+ const error = signal.reason instanceof Error ? signal.reason : Object.assign(new Error('SSH operation aborted'), { name: 'AbortError' })
725
+ // The operation cancels its OWN request through control.signal
726
+ // (exec closes its channel; SFTP streams abort where ssh2 allows).
727
+ // The transport is retired only when this lease is the LAST holder:
728
+ // ending a shared pooled client would destroy unrelated concurrent
729
+ // exec/SFTP/tunnel work on the same alias. The lease itself is
730
+ // still held until the operation settles (see holdLeaseUntilSettled),
731
+ // so that judgement stays truthful.
732
+ if (lease.holdsOnlyLease()) {
733
+ lease.markBroken(error)
734
+ try { lease.client.end() } catch { /* already closed */ }
735
+ }
736
+ reject(error)
737
+ }
738
+ signal.addEventListener('abort', onAbort, { once: true })
739
+ if (signal.aborted) {
740
+ onAbort()
741
+ return
742
+ }
743
+ tracked.then(
744
+ value => {
745
+ if (settled) return
746
+ settled = true
747
+ cleanup()
748
+ resolve(value)
749
+ },
750
+ error => {
751
+ if (settled) return
752
+ settled = true
753
+ cleanup()
754
+ reject(error)
755
+ },
756
+ )
757
+ })
758
+ } catch (error) {
759
+ lastError = error
760
+ // A mid-flight failure usually means the connection died silently
761
+ // (the 'error'/'close' event may not have fired yet). Retire this
762
+ // generation so the next attempt reconnects; the pool reaps the
763
+ // record once every lease is released.
764
+ //
765
+ // An abort is NOT a transport failure: the abort handler above already
766
+ // scoped its damage (per-request cancellation, transport retired only
767
+ // when this was the last holder), so poisoning the shared generation
768
+ // here would break every other holder for no reason. The check keys on
769
+ // the SIGNAL, not on `error.name`: callers abort with reasons like
770
+ // `abort(new Error('cancel'))`, whose name is not 'AbortError'.
771
+ const aborted = options.signal?.aborted === true
772
+ if (!aborted) lease.markBroken(error)
773
+
774
+ const mayReplay = retryPolicy === 'idempotent'
775
+ && !committed
776
+ && attempt < maxAttempts
777
+ if (!mayReplay) {
778
+ throw error instanceof Error ? error : new Error(String(error))
779
+ }
780
+ }
781
+ // No `finally { lease.release() }`: the release is owned by the operation.
782
+ // Reaching the next iteration means this attempt is over; its operation
783
+ // has settled (or was abandoned on abort and will release itself).
784
+ }
785
+
786
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
787
+ }
788
+ /** Resolve an entry's authentication for one connect: session password
789
+ * table first (secretStorage='none'), then deps.resolveSecrets (vault),
790
+ * then the inline store entry; when a password/passphrase is required but
791
+ * unavailable, throw NeedsPasswordError for the GUI to prompt. */
792
+ async resolveEntryAuth(entry: SshHostEntry): Promise<ResolvedAuthDeps | undefined> {
793
+ const session = this.sessionPasswords.get(entry.alias)
794
+ const sessionOverride = session !== undefined
795
+ ? {
796
+ kind: entry.auth.kind,
797
+ keyPath: entry.auth.keyPath,
798
+ password: session.password,
799
+ passphrase: session.passphrase,
800
+ } satisfies ResolvedAuthDeps
801
+ : undefined
802
+ if (sessionOverride !== undefined) return sessionOverride
803
+
804
+ let resolved: ResolvedAuthDeps | undefined
805
+ if (this.deps.resolveSecrets !== undefined) {
806
+ resolved = await this.deps.resolveSecrets(entry).catch((error: unknown) => {
807
+ throw error instanceof Error ? error : new Error(String(error))
808
+ })
809
+ } else {
810
+ // Inline fallback (plaintext store / tests).
811
+ const auth = entry.auth
812
+ if (auth.kind === 'password') {
813
+ resolved = { kind: 'password', password: auth.password }
814
+ } else {
815
+ resolved = auth.passphrase !== undefined && auth.passphrase !== ''
816
+ ? { kind: 'key', keyPath: auth.keyPath, passphrase: auth.passphrase }
817
+ : { kind: 'key', keyPath: auth.keyPath }
818
+ }
819
+ }
820
+
821
+ // Credential gate: a password-kind host without a secret must surface
822
+ // NEEDS_PASSWORD (GUI dialog), NOT a raw ssh2 auth failure; an encrypted
823
+ // key whose passphrase is missing must surface NEEDS_PASSPHRASE.
824
+ if (resolved?.kind === 'password' && (resolved.password === undefined || resolved.password === '')) {
825
+ throw new NeedsPasswordError(entry.alias, 'password')
826
+ }
827
+ if (resolved?.kind === 'key' && resolved.passphrase === undefined && resolved.keyPath !== undefined) {
828
+ const keyPath = expandHome(resolved.keyPath)
829
+ if (keyPath !== '' && existsSync(keyPath) && keyNeedsPassphrase(keyPath)) {
830
+ throw new NeedsPasswordError(entry.alias, 'passphrase')
831
+ }
832
+ }
833
+ return resolved
834
+ }
835
+ /**
836
+ * Build one full jump chain for an entry: hop clients connected through in
837
+ * order, each forwarding a stream to the next destination, ending with the
838
+ * target client. Shared by the pool and standalone shell sessions.
839
+ */
840
+ async connectChain(entry: SshHostEntry, signal?: AbortSignal): Promise<{ client: Client; hops: Client[] }> {
841
+ const hops: Client[] = []
842
+ let sock: ConnectConfig['sock']
843
+ const chain = entry.proxyJump
844
+ // Defensive cycle guard: the store validates on create/update, but the
845
+ // JSON file can be hand-edited — a loop here would open hop connections
846
+ // forever. Follow the live store's full hop graph from this entry.
847
+ const walked = new Set<string>()
848
+ const walk = (alias: string, path: string[]): void => {
849
+ const at = path.indexOf(alias)
850
+ if (at >= 0) throw new Error(`proxyJump cycle detected: ${[...path.slice(at), alias].join(' -> ')}`)
851
+ if (walked.has(alias)) return
852
+ walked.add(alias)
853
+ const hopEntry = this.store.find(alias)
854
+ if (hopEntry === undefined) return
855
+ for (const next of hopEntry.proxyJump) walk(next, [...path, alias])
856
+ }
857
+ walk(entry.alias, [])
858
+ try {
859
+ for (let index = 0; index < chain.length; index += 1) {
860
+ signal?.throwIfAborted()
861
+ const hopAlias = chain[index]
862
+ const hop = this.store.find(hopAlias)
863
+ if (hop === undefined) {
864
+ for (const client of hops) client.end()
865
+ throw new Error(`proxyJump alias '${hopAlias}' not found —create it first`)
866
+ }
867
+ const hopOutcome: { outcome?: HostKeyOutcome | undefined } = {}
868
+ const hopResolved = await this.resolveEntryAuth(hop)
869
+ const hopClient = await connectClient(
870
+ buildConnectConfig(hop, this.opts, sock, {
871
+ hostKeyPolicy: this.hostKeyPolicy,
872
+ hostKeyAlgorithms: this.opts.hostKeyAlgorithms,
873
+ authOverride: hopResolved,
874
+ setOutcome: (value) => { hopOutcome.outcome = value },
875
+ }),
876
+ this.opts.connectTimeoutMs,
877
+ hopOutcome,
878
+ signal,
879
+ )
880
+ hops.push(hopClient)
881
+ const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
882
+ const nextHost = next !== undefined ? next.host : entry.host
883
+ const nextPort = next !== undefined ? next.port : entry.port
884
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
885
+ // forwardOut has no cancel API: bound the hop-channel open so a dead
886
+ // or half-open jump host cannot hang connectChain forever. On timeout
887
+ // the whole hop chain is torn down (mirrors the error branch).
888
+ let settled = false
889
+ const onAbort = (): void => {
890
+ if (settled) return
891
+ settled = true
892
+ clearTimeout(timer)
893
+ for (const client of hops) client.end()
894
+ reject(signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('proxyJump forwardOut aborted'), { name: 'AbortError' }))
895
+ }
896
+ const timer = setTimeout(() => {
897
+ if (settled) return
898
+ settled = true
899
+ signal?.removeEventListener('abort', onAbort)
900
+ for (const client of hops) client.end()
901
+ reject(new Error(`proxyJump forwardOut on '${hopAlias}' timed out after ${this.opts.connectTimeoutMs} ms (target ${nextHost}:${nextPort})`))
902
+ }, this.opts.connectTimeoutMs)
903
+ timer.unref?.()
904
+ signal?.addEventListener('abort', onAbort, { once: true })
905
+ if (signal?.aborted === true) {
906
+ onAbort()
907
+ return
908
+ }
909
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
910
+ if (settled) {
911
+ // Late arrival after the timeout: the chain is being torn down;
912
+ // close any channel that finally opened.
913
+ if (stream !== undefined) {
914
+ try { stream.close() } catch { /* already closed */ }
915
+ }
916
+ return
917
+ }
918
+ settled = true
919
+ clearTimeout(timer)
920
+ signal?.removeEventListener('abort', onAbort)
921
+ if (error !== undefined) {
922
+ for (const client of hops) client.end()
923
+ reject(error)
924
+ } else {
925
+ resolve(stream)
926
+ }
927
+ })
928
+ })
929
+ }
930
+ const targetOutcome: { outcome?: HostKeyOutcome | undefined } = {}
931
+ // Resolve the entry's authentication (session password table first,
932
+ // then vault / inline store). The hostVerifier still runs on the raw
933
+ // server key first, so a secret is never sent to an unverified host.
934
+ const resolvedAuth = await this.resolveEntryAuth(entry)
935
+ const client = await connectClient(
936
+ buildConnectConfig(entry, this.opts, sock, {
937
+ hostKeyPolicy: this.hostKeyPolicy,
938
+ hostKeyAlgorithms: this.opts.hostKeyAlgorithms,
939
+ authOverride: resolvedAuth,
940
+ setOutcome: (value) => { targetOutcome.outcome = value },
941
+ }),
942
+ this.opts.connectTimeoutMs,
943
+ targetOutcome,
944
+ signal,
945
+ )
946
+ return { client, hops }
947
+ } catch (error) {
948
+ for (const client of hops) client.end()
949
+ throw error
950
+ }
951
+ }
952
+ /**
953
+ * Run one command against many hosts concurrently.
954
+ *
955
+ * Per-host failures carry the typed, secret-free fields declared on
956
+ * `ClusterResult` (`code`, and `secret` / `hostKeyFingerprint` / `expected` /
957
+ * `actual` where applicable) alongside the legacy `error` string, so
958
+ * automation can branch on a stable code and the GUI can open the right
959
+ * credential/fingerprint dialog without parsing a localized message.
960
+ */
961
+ async cluster(options: {
962
+ command: string
963
+ aliases?: string[]
964
+ environment?: string
965
+ tags?: string[]
966
+ timeoutMs?: number
967
+ maxWorkers?: number
968
+ /** Aborts every per-host run when the caller disconnects. */
969
+ signal?: AbortSignal
970
+ }): Promise<ClusterResult[]> {
971
+ let targets = this.store.list()
972
+ if (options.aliases !== undefined && options.aliases.length > 0) {
973
+ // Explicit target lists are safety-sensitive: reject the WHOLE batch
974
+ // before executing anything when any alias is unknown. Silently
975
+ // filtering a typo could otherwise report partial production work as a
976
+ // successful complete run.
977
+ const byAlias = new Map(targets.map(entry => [entry.alias, entry]))
978
+ const unknown = options.aliases.filter(alias => !byAlias.has(alias))
979
+ if (unknown.length > 0) {
980
+ throw new Error(`alias '${unknown.join("', '")}' not found — no cluster commands were executed`)
981
+ }
982
+ // Preserve the caller's alias order (store order is not contractual).
983
+ targets = options.aliases.map(alias => byAlias.get(alias)!)
984
+ }
985
+ if (options.environment !== undefined && options.environment !== '') {
986
+ targets = targets.filter(entry => entry.environment === options.environment)
987
+ }
988
+ if (options.tags !== undefined && options.tags.length > 0) {
989
+ // ALL semantics (matches the ssh_cluster tool description).
990
+ targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
991
+ }
992
+ if (targets.length === 0) return []
993
+ if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
994
+ throw new Error('maxWorkers must be a positive integer')
995
+ }
996
+ const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
997
+ // Pre-sized slots keep the result order aligned with the target order
998
+ // regardless of which host finishes first.
999
+ const results = new Array<ClusterResult>(targets.length)
1000
+ const queue = targets.map((entry, index) => ({ entry, index }))
1001
+ const run = async (): Promise<void> => {
1002
+ while (queue.length > 0) {
1003
+ const { entry, index } = queue.shift()!
1004
+ try {
1005
+ const result = await this.executor(entry.alias, options.command, { timeoutMs: options.timeoutMs, signal: options.signal })
1006
+ results[index] = {
1007
+ alias: entry.alias,
1008
+ ok: result.success,
1009
+ exitCode: result.exitCode,
1010
+ timedOut: result.timedOut,
1011
+ stdout: result.stdout,
1012
+ stderr: result.stderr,
1013
+ durationMs: result.durationMs,
1014
+ // A timed-out command ran (and was killed), so it has no failure
1015
+ // code beyond the timeout itself; surface that as a typed code so
1016
+ // automation does not have to infer it from `timedOut`.
1017
+ ...(result.success || result.timedOut !== true ? {} : { code: 'TIMEOUT' as const }),
1018
+ }
1019
+ } catch (error) {
1020
+ results[index] = this.clusterFailure(entry.alias, error)
1021
+ }
1022
+ }
1023
+ }
1024
+ await Promise.all(Array.from({ length: workers }, () => run()))
1025
+ return results
1026
+ }
1027
+
1028
+ /**
1029
+ * Classify one per-host cluster failure. Keeps the legacy `error` string for
1030
+ * backward compatibility while adding the typed reason: a NeedsPassword /
1031
+ * host-key failure is interactive in the GUI, and automation needs a stable
1032
+ * code. The message is redacted because connect/resolve errors can embed a
1033
+ * credential; the added fields are fingerprints and enum values only.
1034
+ */
1035
+ clusterFailure(alias: string, error: unknown): ClusterResult {
1036
+ const message = this.redact(error instanceof Error ? error.message : String(error))
1037
+ const base: ClusterResult = { alias, ok: false, error: message }
1038
+ if (error instanceof NeedsPasswordError) {
1039
+ return { ...base, code: 'NEEDS_PASSWORD', secret: error.secret }
1040
+ }
1041
+ if (error instanceof HostKeyUnknownError) {
1042
+ return { ...base, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
1043
+ }
1044
+ if (error instanceof HostKeyMismatchError) {
1045
+ return { ...base, code: 'HOST_KEY_MISMATCH', expected: error.expected, actual: error.actual }
1046
+ }
1047
+ if (error instanceof Error && error.name === 'AbortError') return { ...base, code: 'ABORTED' }
1048
+ if (/alias '.*' not found/.test(message)) return { ...base, code: 'ALIAS_NOT_FOUND' }
1049
+ if (/timed? ?out|timeout/i.test(message)) return { ...base, code: 'TIMEOUT' }
1050
+ return { ...base, code: 'ERROR' }
1051
+ }
1052
+ /** Probe connectivity: connect, run `true`, close. Typed errors the GUI
1053
+ * must react to (host-key TOFU, session password) are NOT flattened into
1054
+ * a plain message — callers (routes → panel / workspace gate) key their
1055
+ * interactive dialogs on the typed error. Everything else (unreachable,
1056
+ * timeout, auth failure) returns a failed result. */
1057
+ async test(alias: string, signal?: AbortSignal): Promise<TestResult> {
1058
+ const started = Date.now()
1059
+ try {
1060
+ // `true` is idempotent — allow channel-open retries, never replays
1061
+ // once the server accepted the channel.
1062
+ const result = await this.executor(alias, 'true', { timeoutMs: 10_000, retry: 'idempotent', signal })
1063
+ return result.success
1064
+ ? { ok: true, latencyMs: result.durationMs }
1065
+ : { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
1066
+ } catch (error) {
1067
+ if (error instanceof NeedsPasswordError || error instanceof HostKeyUnknownError || error instanceof HostKeyMismatchError) {
1068
+ throw error
1069
+ }
1070
+ return { ok: false, latencyMs: Date.now() - started, error: this.redact(error instanceof Error ? error.message : String(error)) }
1071
+ }
1072
+ }
1073
+
1074
+ /** Close every pooled connection and wipe the in-memory session password
1075
+ * table. The facade calls this LAST, after tunnels, terminal transports and
1076
+ * SFTP channels are already closed (secrets are never persisted anywhere). */
1077
+ disposeSensitive(): void {
1078
+ this.connectionPool.invalidateAll()
1079
+ // Drops the session credentials AND the leak-guard matching material, so
1080
+ // no revealed secret outlives the engine.
1081
+ this.clearSessionSecrets()
1082
+ }
1083
+ }