@tiphareth/dsh-hardssh 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,1761 @@
1
+ /**
2
+ * The SSH engine: a per-alias persistent connection pool (ssh2) with
3
+ * multi-hop jump support, command execution, PTY shells, SFTP transfers,
4
+ * local port-forward tunnels and cluster execution —the DSH counterpart of
5
+ * ssh-skill's daemon + scripts, living entirely in the host process.
6
+ */
7
+
8
+ import { createServer, type Server as NetServer } from 'node:net'
9
+ import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs'
10
+ import { dirname, join, relative, resolve as resolvePath } from 'node:path'
11
+ import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
12
+ import { BoundedUtf8Output } from './exec/output.ts'
13
+ import { ConnectionPool, type SshConnectionService } from './connection/pool.ts'
14
+ import type { ClientLease } from './connection/lease.ts'
15
+ import { createTransferProgressTracker } from './transfer/progress.ts'
16
+ import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
17
+ import { expandHome, type HostStore } from './store.ts'
18
+ import type { HostStoreView } from '../core.ts'
19
+ import {
20
+ HostKeyMismatchError,
21
+ HostKeyPolicy,
22
+ HostKeyUnknownError,
23
+ type HostKeyCheck,
24
+ type KnownHostsStore,
25
+ } from './known-hosts.ts'
26
+
27
+ /** Options used when retiring pooled connections after host configuration changes. */
28
+ export interface SshInvalidateOptions {
29
+ /**
30
+ * Also invalidate hosts whose ProxyJump chain depends, directly or
31
+ * transitively, on the changed alias.
32
+ */
33
+ includeDependents?: boolean
34
+
35
+ /**
36
+ * drain: reject/reconnect subsequent acquisitions, allow existing leases
37
+ * to complete before closing their transport.
38
+ * force: close the current transport immediately.
39
+ */
40
+ mode?: 'drain' | 'force'
41
+ }
42
+
43
+ /** Default engine knobs. */
44
+ export interface EngineOptions {
45
+ /** Connections idle longer than this are closed (ms). */
46
+ idleTimeoutMs?: number
47
+ /** SSH handshake timeout (ms). */
48
+ connectTimeoutMs?: number
49
+ /** Keepalive ping interval (ms). */
50
+ keepaliveIntervalMs?: number
51
+ /** Cap on captured stdout/stderr bytes per exec (ms). */
52
+ maxOutputBytes?: number
53
+ /** Default exec timeout (ms). */
54
+ defaultExecTimeoutMs?: number
55
+ /** Default cluster concurrency. */
56
+ defaultMaxWorkers?: number
57
+ /** SFTP concurrent channel count for transfers. */
58
+ sftpConcurrency?: number
59
+ /** Optional server-host-key algorithm whitelist (e.g. ['ssh-ed25519']). */
60
+ hostKeyAlgorithms?: string[]
61
+ }
62
+
63
+ /**
64
+ * Optional engine dependencies for host-key TOFU and secret resolution.
65
+ * All fields are optional and their absence preserves the pre-security
66
+ * behavior exactly (plaintext inline auth, no host verification) — the
67
+ * existing tests and call sites keep working unchanged.
68
+ */
69
+ export interface EngineDeps {
70
+ /** Known-hosts trust store; when set, connections require a trusted host key. */
71
+ knownHosts?: KnownHostsStore
72
+ /** Fingerprint check policy (defaults to a HostKeyPolicy over knownHosts). */
73
+ hostKeyPolicy?: HostKeyPolicy
74
+ /**
75
+ * Secret resolution for one entry. Absent: read `password`/`passphrase`
76
+ * inline from the entry (plaintext store / test compatibility).
77
+ */
78
+ resolveSecrets?: (entry: SshHostEntry) => Promise<ResolvedAuthDeps>
79
+ /** Optional server-host-key algorithm whitelist override. */
80
+ hostKeyAlgorithms?: string[]
81
+ }
82
+
83
+ /** The resolved-auth shape passed into connect config building (vault-aware). */
84
+ export interface ResolvedAuthDeps {
85
+ kind: SshHostEntry['auth']['kind']
86
+ keyPath?: string
87
+ password?: string
88
+ passphrase?: string
89
+ }
90
+
91
+ /**
92
+ * Thrown when a connection needs a password/passphrase that is not yet
93
+ * available in this session (secretStorage='none' and the user hasn't entered
94
+ * it yet). The GUI intercepts this and prompts for the credential, then
95
+ * injects it via engine.setSessionPassword and retries.
96
+ */
97
+ export class NeedsPasswordError extends Error {
98
+ /** Which secret the connection needs: 'password' or 'passphrase'. */
99
+ readonly secret: 'password' | 'passphrase'
100
+ constructor(alias: string, secret: 'password' | 'passphrase') {
101
+ super(`SSH 连接 '${alias}' 需要${secret === 'password' ? '密码' : '密钥口令'},请先输入一次(本会话内复用,不会保存)`)
102
+ this.name = 'NeedsPasswordError'
103
+ this.secret = secret
104
+ }
105
+ }
106
+
107
+ const DEFAULTS: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> = {
108
+ idleTimeoutMs: 30 * 60_000,
109
+ connectTimeoutMs: 15_000,
110
+ keepaliveIntervalMs: 15_000,
111
+ maxOutputBytes: 2 * 1024 * 1024,
112
+ defaultExecTimeoutMs: 60_000,
113
+ defaultMaxWorkers: 8,
114
+ sftpConcurrency: 8,
115
+ hostKeyAlgorithms: undefined,
116
+ }
117
+
118
+ /**
119
+ * How much an operation may be retried:
120
+ * - never: one acquisition + one operation attempt.
121
+ * - connect-only: connection acquisition may be retried, but once the
122
+ * operation function starts it is invoked at most once (default).
123
+ * - idempotent: the operation may also be retried until it calls
124
+ * markCommitted() (i.e. while the exec channel is still opening).
125
+ *
126
+ * SFTP operations must never use 'idempotent': they have no commit point, so
127
+ * a replay after a mid-flight timeout would duplicate a remote write.
128
+ */
129
+ export type RetryPolicy = 'never' | 'connect-only' | 'idempotent'
130
+
131
+ /** Options for a one-shot remote command. */
132
+ export interface ExecOptions {
133
+ timeoutMs?: number
134
+ retry?: RetryPolicy
135
+ }
136
+
137
+ /** Internal options for withClient(). */
138
+ interface WithClientOptions {
139
+ /** Total acquire+operation attempt budget (default 3, capped at 1 for 'never'). */
140
+ attempts?: number
141
+ retryPolicy?: RetryPolicy
142
+ }
143
+
144
+ /** Lets an operation declare the point after which replay is unsafe. */
145
+ interface OperationControl {
146
+ markCommitted(): void
147
+ }
148
+
149
+ /** SFTP operations must never leave the file tree spinning forever: fail the
150
+ * request after this budget so a stalled channel (half-dead connection,
151
+ * unresponsive server) errors out instead of hanging the GUI. */
152
+ const SFTP_OP_TIMEOUT_MS = 15_000
153
+ /** Read whole remote files with a generous budget (slow links can take a
154
+ * while to stream large files). */
155
+ const SFTP_READ_TIMEOUT_MS = 60_000
156
+ /** A fresh SSH channel (shell/exec) must open within this budget; a dead or
157
+ * half-open connection would otherwise leave the open promise hanging. */
158
+ const CHANNEL_OPEN_TIMEOUT_MS = 10_000
159
+ /** Symlink stat batch width — parallelized so a dir full of links (conda /
160
+ * venv bin, node_modules/.bin) costs a handful of round-trips, not N. ssh2's
161
+ * SFTP window pipelines requests, so one batch ≈ one round-trip. */
162
+ const SYMLINK_STAT_BATCH = 64
163
+ /** A local tunnel socket must obtain its SSH forward channel within this
164
+ * budget; half-open transports can otherwise leave the socket hanging. */
165
+ const TUNNEL_FORWARD_TIMEOUT_MS = 10_000
166
+
167
+ /** A live PTY shell session. */
168
+ export interface ShellSession {
169
+ /** Assign to receive remote output. */
170
+ onData?: (data: Buffer) => void
171
+ /** Assign to be notified when the channel closes. */
172
+ onExit?: (code: number | null, error?: string) => void
173
+ /** Write raw input to the shell. */
174
+ send(data: string): void
175
+ /** Resize the remote PTY. */
176
+ resize(cols: number, rows: number): void
177
+ /** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process group. */
178
+ signal(name: string): void
179
+ /** Close the session and its channel. */
180
+ close(): void
181
+ /** Pause remote output delivery (transport backpressure). */
182
+ pause(): void
183
+ /** Resume remote output delivery. */
184
+ resume(): void
185
+ }
186
+
187
+ /**
188
+ * A live streaming exec channel (no PTY): separate stdout/stderr delivery,
189
+ * stdin writes, SSH signals (TERM/KILL), and an explicit end for the final
190
+ * input burst. Used by the subprocess capability seam's remote provider.
191
+ */
192
+ export interface ExecSession extends ShellSession {
193
+ /** Assign to receive the remote stderr stream. */
194
+ onErrData?: (data: Buffer) => void
195
+ /** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process. */
196
+ signal(name: string): void
197
+ /** Write the final input burst and half-close stdin. */
198
+ end(data?: string): void
199
+ }
200
+
201
+ /** One active tunnel record (server + its connection lease + live sockets). */
202
+ interface TunnelRecord {
203
+ info: TunnelInfo
204
+ server: NetServer
205
+ alias: string
206
+ lease: ClientLease
207
+ sockets: Set<import('node:net').Socket>
208
+ /** Registered for BOTH Client 'error' and 'close'; cleanup removes both. */
209
+ clientFailureHandler: (error?: unknown) => void
210
+ }
211
+
212
+ /** One host-key verification outcome captured during a connect attempt (used
213
+ * to rewrite the generic handshake failure into a typed host-key error). */
214
+ interface HostKeyOutcome {
215
+ alias: string
216
+ check: HostKeyCheck
217
+ }
218
+
219
+ /**
220
+ * Resolve the ssh-agent socket to offer to ssh2 (zero-input key auth,
221
+ * VSCode-style): `$SSH_AUTH_SOCK` — the standard OpenSSH agent socket (also
222
+ * exported by Git for Windows' ssh-agent and WSL). Deliberately NOT probing
223
+ * named pipes (Pageant / Windows OpenSSH agent): an absent pipe makes ssh2's
224
+ * agent query stall the whole handshake until readyTimeout instead of
225
+ * falling through to the next method. Keep Pageant compatibility for a
226
+ * future explicit opt-in. An agent that yields no keys makes ssh2 fall
227
+ * through to the configured methods (privateKey → password), so enabling it
228
+ * when a socket is present is safe. Exported for tests.
229
+ */
230
+ export function sshAgentConfig(): string | undefined {
231
+ const sock = process.env.SSH_AUTH_SOCK
232
+ if (sock !== undefined && sock.trim() !== '') return sock
233
+ return undefined
234
+ }
235
+
236
+ /** Detect whether an OpenSSH/PEM private key file is passphrase-encrypted.
237
+ * OpenSSH-format keys keep the cipher/kdf strings in PLAINTEXT inside the
238
+ * base64 payload ('bcrypt' kdf ⇒ encrypted, 'none' ⇒ plain); PEM keys carry
239
+ * "Proc-Type: 4,ENCRYPTED". Used to prompt for a missing passphrase. */
240
+ function keyNeedsPassphrase(keyPath: string): boolean {
241
+ try {
242
+ const text = readFileSync(keyPath, 'utf8')
243
+ if (/Proc-Type:\s*4,ENCRYPTED/i.test(text)) return true
244
+ if (text.includes('OPENSSH PRIVATE KEY')) {
245
+ const base64 = text.replace(/-----[^-]*-----/g, '').replace(/\s+/g, '')
246
+ const header = Buffer.from(base64, 'base64').toString('latin1', 0, 512)
247
+ return header.includes('bcrypt')
248
+ }
249
+ return false
250
+ } catch {
251
+ return false
252
+ }
253
+ }
254
+
255
+ /** Build the ssh2 connect config for one entry (key read from disk). The
256
+ * timeout/keepalive knobs come from EngineOptions so they actually take
257
+ * effect instead of being hard-coded. Exported for tests. */
258
+ export function buildConnectConfig(
259
+ entry: SshHostEntry,
260
+ options: Pick<Required<EngineOptions>, 'connectTimeoutMs' | 'keepaliveIntervalMs'>,
261
+ sock?: ConnectConfig['sock'],
262
+ buildContext: {
263
+ hostKeyPolicy?: HostKeyPolicy
264
+ hostKeyAlgorithms?: string[]
265
+ /** Writes the verified/refused outcome back to the caller's capture slot. */
266
+ setOutcome?: (value: HostKeyOutcome) => void
267
+ /** Vault-resolved authentication (overrides entry.auth secrets). */
268
+ authOverride?: ResolvedAuthDeps
269
+ } = {},
270
+ ): ConnectConfig {
271
+ const config: ConnectConfig = {
272
+ host: entry.host,
273
+ port: entry.port,
274
+ username: entry.user,
275
+ readyTimeout: options.connectTimeoutMs,
276
+ keepaliveInterval: options.keepaliveIntervalMs,
277
+ keepaliveCountMax: 3,
278
+ }
279
+ if (sock !== undefined) config.sock = sock
280
+ const agent = sshAgentConfig()
281
+ if (agent !== undefined) config.agent = agent
282
+ if (buildContext.hostKeyPolicy !== undefined) {
283
+ config.hostVerifier = (serverKey: Buffer) => {
284
+ const check = buildContext.hostKeyPolicy!.check(entry.alias, serverKey)
285
+ buildContext.setOutcome?.({ alias: entry.alias, check })
286
+ return check.kind === 'trusted'
287
+ }
288
+ }
289
+ if (buildContext.hostKeyAlgorithms !== undefined && buildContext.hostKeyAlgorithms.length > 0) {
290
+ config.algorithms = { serverHostKey: buildContext.hostKeyAlgorithms as import('ssh2').ServerHostKeyAlgorithm[] }
291
+ }
292
+ const auth = buildContext.authOverride
293
+ ?? { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
294
+ if (auth.kind === 'password') {
295
+ config.password = auth.password
296
+ } else {
297
+ const keyPath = auth.keyPath === undefined ? undefined : expandHome(auth.keyPath)
298
+ if (keyPath !== undefined && keyPath !== '' && existsSync(keyPath)) {
299
+ config.privateKey = readFileSync(keyPath, 'utf8')
300
+ if (auth.passphrase !== undefined && auth.passphrase !== '') {
301
+ config.passphrase = auth.passphrase
302
+ }
303
+ } else if (agent === undefined) {
304
+ // No key file AND no agent to fall back on — fail before the
305
+ // handshake with a precise message instead of a generic auth failure.
306
+ 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)`)
307
+ }
308
+ // Else: the key path is unset or missing but an agent is available —
309
+ // leave privateKey unset so ssh2 authenticates from the agent's keys
310
+ // (zero input, the VSCode Remote-SSH way).
311
+ }
312
+ return config
313
+ }
314
+
315
+ /**
316
+ * Connect one ssh2 client (resolve on ready, reject on error/close). A hard
317
+ * `timeoutMs` bounds the WHOLE connect phase: ssh2's own `readyTimeout` only
318
+ * starts ticking after the TCP socket is up, so a SYN-level hang (filtered
319
+ * port, dead route, half-open middlebox) would otherwise stall the promise
320
+ * forever — which hangs every caller (exec, openShell, tunnels). On timeout
321
+ * the socket is destroyed and the promise rejects.
322
+ *
323
+ * When `context` carries a captured host-key outcome from a prior
324
+ * `hostVerifier` refusal, the generic error is rewritten into a typed
325
+ * HostKeyUnknownError / HostKeyMismatchError so callers and the GUI can
326
+ * surface the fingerprint directly.
327
+ */
328
+ function connectClient(
329
+ config: ConnectConfig,
330
+ timeoutMs: number,
331
+ context: { outcome?: HostKeyOutcome | undefined } = {},
332
+ ): Promise<Client> {
333
+ return new Promise((resolve, reject) => {
334
+ const client = new Client()
335
+ let settled = false
336
+ const timer = setTimeout(() => {
337
+ if (settled) return
338
+ settled = true
339
+ const err = new Error(`SSH connect to ${config.host}:${config.port} (${config.username}) timed out after ${timeoutMs} ms`)
340
+ try { client.destroy() } catch { /* already closed */ }
341
+ reject(err)
342
+ }, timeoutMs)
343
+ timer.unref?.()
344
+ const settle = (fn: () => void): void => {
345
+ if (settled) return
346
+ settled = true
347
+ clearTimeout(timer)
348
+ fn()
349
+ }
350
+ client.once('ready', () => settle(() => resolve(client)))
351
+ client.once('error', (error) => {
352
+ const raw = error instanceof Error ? error : new Error(String(error))
353
+ settle(() => reject(rewriteHostKeyError(raw, context.outcome)))
354
+ })
355
+ // A server that drops the socket before 'ready' (e.g. during auth or a
356
+ // failed acquire) emits 'close' without 'error' — fail fast instead of
357
+ // waiting out the whole connect timeout.
358
+ client.once('close', () => settle(() => reject(rewriteHostKeyError(
359
+ new Error(`SSH connection to ${config.host}:${config.port} (${config.username}) closed before ready`),
360
+ context.outcome,
361
+ ))))
362
+ try {
363
+ client.connect(config)
364
+ } catch (error) {
365
+ const raw = error instanceof Error ? error : new Error(String(error))
366
+ settle(() => reject(rewriteHostKeyError(raw, context.outcome)))
367
+ }
368
+ })
369
+ }
370
+
371
+ /** Rewrite a raw connect failure into a typed host-key error when the
372
+ * hostVerifier refused the server key (unknown or mismatch). */
373
+ function rewriteHostKeyError(raw: Error, outcome: HostKeyOutcome | undefined): Error {
374
+ if (outcome?.check.kind === 'unknown') {
375
+ return new HostKeyUnknownError(outcome.alias, outcome.check.fingerprintSha256)
376
+ }
377
+ if (outcome?.check.kind === 'mismatch') {
378
+ return new HostKeyMismatchError(outcome.alias, outcome.check.expected, outcome.check.actual)
379
+ }
380
+ return raw
381
+ }
382
+
383
+ function walkLocalDir(root: string): string[] {
384
+ const files: string[] = []
385
+ const visit = (dir: string): void => {
386
+ for (const name of readdirSync(dir)) {
387
+ const full = join(dir, name)
388
+ const stat = statSync(full)
389
+ if (stat.isDirectory()) visit(full)
390
+ else if (stat.isFile()) files.push(relative(root, full))
391
+ }
392
+ }
393
+ visit(root)
394
+ return files
395
+ }
396
+
397
+ /**
398
+ * The engine. Owns the pool, tunnels, and all operations. One instance per
399
+ * plugin apply; dispose() closes every connection.
400
+ */
401
+ export class SshEngine {
402
+ private readonly store: HostStoreView
403
+ private readonly opts: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'>
404
+ private readonly tunnels = new Map<string, TunnelRecord>()
405
+ /**
406
+ * One cached SFTP subsystem channel per live client. `Client.sftp()` opens a
407
+ * NEW subsystem channel on every call and OpenSSH caps open sessions per
408
+ * connection (MaxSessions, default 10) —reopening SFTP per operation lets
409
+ * channels pile up on the pooled long-lived connection until listing/reading
410
+ * fails intermittently. Caching one channel per client fixes that; the pool
411
+ * drops the cache via onDispose when a connection is torn down.
412
+ */
413
+ private readonly sftpChannels = new Map<Client, Promise<import('ssh2').SFTPWrapper>>()
414
+ private readonly connectionPool: SshConnectionService
415
+ private readonly deps: EngineDeps
416
+ private readonly hostKeyPolicy: HostKeyPolicy | undefined
417
+ private nextTunnelId = 1
418
+ /**
419
+ * Session-scoped secrets (secretStorage='none'): keyed by alias, populated
420
+ * by the GUI on first connect, used by connectChain's resolve step, and
421
+ * cleared on dispose. Never persisted.
422
+ */
423
+ private readonly sessionPasswords = new Map<string, { password?: string; passphrase?: string }>()
424
+
425
+ /**
426
+ * @param store - the host config store.
427
+ * @param options - engine knobs (defaults applied).
428
+ * @param deps - optional security deps (host-key TOFU, secret resolution).
429
+ * Absent → pre-security behavior (inline auth, no host verification).
430
+ */
431
+ constructor(store: HostStoreView, options?: EngineOptions, deps?: EngineDeps) {
432
+ this.store = store
433
+ this.opts = { ...DEFAULTS, ...options }
434
+ this.deps = deps ?? {}
435
+ this.hostKeyPolicy = this.deps.hostKeyPolicy
436
+ ?? (this.deps.knownHosts !== undefined ? new HostKeyPolicy(this.deps.knownHosts) : undefined)
437
+ this.connectionPool = new ConnectionPool({
438
+ idleTimeoutMs: this.opts.idleTimeoutMs,
439
+ connect: async (alias) => {
440
+ const entry = this.store.find(alias)
441
+ if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
442
+ return await this.connectChain(entry)
443
+ },
444
+ onDispose: (client) => {
445
+ this.sftpChannels.delete(client)
446
+ },
447
+ })
448
+ }
449
+
450
+ // ---------------------------------------------------------- session secrets
451
+
452
+ /**
453
+ * Provide a secret for `alias` for THIS session only (never persisted).
454
+ * Used by the GUI when a connection needs a password/passphrase under
455
+ * secretStorage='none'. Once set, pooled connections reuse it until the
456
+ * session ends or clearSessionSecrets() is called.
457
+ */
458
+ setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
459
+ this.sessionPasswords.set(alias, secret)
460
+ }
461
+
462
+ /** Read the session secret for one alias (undefined = not provided yet). */
463
+ getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
464
+ return this.sessionPasswords.get(alias)
465
+ }
466
+
467
+ /** Drop every session secret (e.g. on secretStorage change / lock). */
468
+ clearSessionSecrets(): void {
469
+ this.sessionPasswords.clear()
470
+ }
471
+
472
+ // ---------------------------------------------------------------- config
473
+
474
+ /** Secret-free host list (filtered by the optional query). */
475
+ list(query?: string): SshHostSummary[] {
476
+ const needle = query?.trim().toLowerCase()
477
+ return this.store.list()
478
+ .filter(entry => needle === undefined || needle === ''
479
+ || entry.alias.toLowerCase().includes(needle)
480
+ || (entry.description ?? '').toLowerCase().includes(needle)
481
+ || entry.host.toLowerCase().includes(needle)
482
+ || entry.tags.some(tag => tag.toLowerCase().includes(needle)))
483
+ .map(entry => this.store.summarize(entry))
484
+ }
485
+
486
+ /** One host summary by alias. */
487
+ find(alias: string): SshHostSummary | undefined {
488
+ const entry = this.store.find(alias)
489
+ return entry === undefined ? undefined : this.store.summarize(entry)
490
+ }
491
+
492
+ // -------------------------------------------------------------- pool
493
+
494
+ /**
495
+ * Shared connection/lease service.
496
+ *
497
+ * The returned service is owned by this engine. Consumers may acquire
498
+ * leases or invalidate individual aliases, but must not treat it as a
499
+ * separately owned pool.
500
+ */
501
+ get connections(): SshConnectionService {
502
+ return this.connectionPool
503
+ }
504
+
505
+ /** Aliases with a live pooled transport right now (for connection-state
506
+ * indicators — the GUI badge colors bound workspaces by it). */
507
+ connectedAliases(): string[] {
508
+ return this.connectionPool.liveAliases()
509
+ }
510
+
511
+ /**
512
+ * Retire the pooled connection for one alias.
513
+ *
514
+ * ConnectionPool knows nothing about HostStore or ProxyJump configuration
515
+ * (every target owns its complete jump chain, no hop records are shared),
516
+ * so dependent-host expansion belongs here: with includeDependents the
517
+ * transitive reverse ProxyJump closure of `alias` is invalidated too.
518
+ */
519
+ invalidate(alias: string, options: SshInvalidateOptions = {}): void {
520
+ const aliases = new Set<string>([alias])
521
+
522
+ if (options.includeDependents === true) {
523
+ const entries = this.store.list()
524
+ // Fixed-point scan: host counts are small, simpler and more reliable
525
+ // than maintaining a second dependency index.
526
+ let changed = true
527
+ while (changed) {
528
+ changed = false
529
+ for (const entry of entries) {
530
+ if (aliases.has(entry.alias)) continue
531
+ if (!entry.proxyJump.some(hopAlias => aliases.has(hopAlias))) continue
532
+ aliases.add(entry.alias)
533
+ changed = true
534
+ }
535
+ }
536
+ }
537
+
538
+ for (const targetAlias of aliases) {
539
+ // Dependents are already expanded above; the pool itself has no
540
+ // ProxyJump topology.
541
+ this.connectionPool.invalidate(targetAlias, {
542
+ includeDependents: false,
543
+ mode: options.mode,
544
+ })
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Run `fn` with a live client for `alias`.
550
+ *
551
+ * Acquisition retry and operation replay are deliberately separate:
552
+ * - never: one acquire + one operation attempt.
553
+ * - connect-only: acquire may be retried, but fn is invoked at most once —
554
+ * once fn starts, the remote may already have observed the request, so
555
+ * replay could duplicate non-idempotent work.
556
+ * - idempotent: fn may be retried until it calls control.markCommitted()
557
+ * (exec marks this when the server accepted the channel).
558
+ *
559
+ * A failed operation retires the transport via lease.markBroken(); the
560
+ * lease is always released before the next acquire attempt.
561
+ */
562
+ private async withClient<T>(
563
+ alias: string,
564
+ fn: (client: Client, control: OperationControl) => Promise<T>,
565
+ options: WithClientOptions = {},
566
+ ): Promise<T> {
567
+ const retryPolicy = options.retryPolicy ?? 'connect-only'
568
+ const configuredAttempts = options.attempts ?? 3
569
+ if (!Number.isInteger(configuredAttempts) || configuredAttempts < 1) {
570
+ throw new Error('withClient attempts must be a positive integer')
571
+ }
572
+ const maxAttempts = retryPolicy === 'never' ? 1 : configuredAttempts
573
+ let lastError: unknown
574
+
575
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
576
+ let lease: ClientLease
577
+
578
+ // Acquisition sits OUTSIDE the operation try: an acquire failure means
579
+ // fn never ran, so no remote work was submitted and retrying is safe.
580
+ try {
581
+ lease = await this.connectionPool.acquire(alias, { kind: 'operation' })
582
+ } catch (error) {
583
+ lastError = error
584
+ if (retryPolicy === 'never' || attempt === maxAttempts) {
585
+ throw error instanceof Error ? error : new Error(String(error))
586
+ }
587
+ continue
588
+ }
589
+
590
+ let committed = false
591
+ const control: OperationControl = {
592
+ markCommitted: (): void => { committed = true },
593
+ }
594
+
595
+ try {
596
+ return await fn(lease.client, control)
597
+ } catch (error) {
598
+ lastError = error
599
+ // A mid-flight failure usually means the connection died silently
600
+ // (the 'error'/'close' event may not have fired yet). Retire this
601
+ // generation so the next attempt reconnects; the pool reaps the
602
+ // record once every lease is released.
603
+ lease.markBroken(error)
604
+
605
+ const mayReplay = retryPolicy === 'idempotent'
606
+ && !committed
607
+ && attempt < maxAttempts
608
+ if (!mayReplay) {
609
+ throw error instanceof Error ? error : new Error(String(error))
610
+ }
611
+ } finally {
612
+ // release before the next iteration: if markBroken drained the
613
+ // record and this was its last lease, release() tears the transport
614
+ // down so the next acquire opens a fresh generation.
615
+ lease.release()
616
+ }
617
+ }
618
+
619
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
620
+ }
621
+
622
+ /** Resolve an entry's authentication for one connect: session password
623
+ * table first (secretStorage='none'), then deps.resolveSecrets (vault),
624
+ * then the inline store entry; when a password/passphrase is required but
625
+ * unavailable, throw NeedsPasswordError for the GUI to prompt. */
626
+ private async resolveEntryAuth(entry: SshHostEntry): Promise<ResolvedAuthDeps | undefined> {
627
+ const session = this.sessionPasswords.get(entry.alias)
628
+ const sessionOverride = session !== undefined
629
+ ? {
630
+ kind: entry.auth.kind,
631
+ keyPath: entry.auth.keyPath,
632
+ password: session.password,
633
+ passphrase: session.passphrase,
634
+ } satisfies ResolvedAuthDeps
635
+ : undefined
636
+ if (sessionOverride !== undefined) return sessionOverride
637
+
638
+ let resolved: ResolvedAuthDeps | undefined
639
+ if (this.deps.resolveSecrets !== undefined) {
640
+ resolved = await this.deps.resolveSecrets(entry).catch((error: unknown) => {
641
+ throw error instanceof Error ? error : new Error(String(error))
642
+ })
643
+ } else {
644
+ // Inline fallback (plaintext store / tests).
645
+ const auth = entry.auth
646
+ if (auth.kind === 'password') {
647
+ resolved = { kind: 'password', password: auth.password }
648
+ } else {
649
+ resolved = auth.passphrase !== undefined && auth.passphrase !== ''
650
+ ? { kind: 'key', keyPath: auth.keyPath, passphrase: auth.passphrase }
651
+ : { kind: 'key', keyPath: auth.keyPath }
652
+ }
653
+ }
654
+
655
+ // Credential gate: a password-kind host without a secret must surface
656
+ // NEEDS_PASSWORD (GUI dialog), NOT a raw ssh2 auth failure; an encrypted
657
+ // key whose passphrase is missing must surface NEEDS_PASSPHRASE.
658
+ if (resolved?.kind === 'password' && (resolved.password === undefined || resolved.password === '')) {
659
+ throw new NeedsPasswordError(entry.alias, 'password')
660
+ }
661
+ if (resolved?.kind === 'key' && resolved.passphrase === undefined && resolved.keyPath !== undefined) {
662
+ const keyPath = expandHome(resolved.keyPath)
663
+ if (keyPath !== '' && existsSync(keyPath) && keyNeedsPassphrase(keyPath)) {
664
+ throw new NeedsPasswordError(entry.alias, 'passphrase')
665
+ }
666
+ }
667
+ return resolved
668
+ }
669
+
670
+ /**
671
+ * Build one full jump chain for an entry: hop clients connected through in
672
+ * order, each forwarding a stream to the next destination, ending with the
673
+ * target client. Shared by the pool and standalone shell sessions.
674
+ */
675
+ private async connectChain(entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
676
+ const hops: Client[] = []
677
+ let sock: ConnectConfig['sock']
678
+ const chain = entry.proxyJump
679
+ // Defensive cycle guard: the store validates on create/update, but the
680
+ // JSON file can be hand-edited — a loop here would open hop connections
681
+ // forever. Follow the live store's full hop graph from this entry.
682
+ const walked = new Set<string>()
683
+ const walk = (alias: string, path: string[]): void => {
684
+ const at = path.indexOf(alias)
685
+ if (at >= 0) throw new Error(`proxyJump cycle detected: ${[...path.slice(at), alias].join(' -> ')}`)
686
+ if (walked.has(alias)) return
687
+ walked.add(alias)
688
+ const hopEntry = this.store.find(alias)
689
+ if (hopEntry === undefined) return
690
+ for (const next of hopEntry.proxyJump) walk(next, [...path, alias])
691
+ }
692
+ walk(entry.alias, [])
693
+ for (let index = 0; index < chain.length; index += 1) {
694
+ const hopAlias = chain[index]
695
+ const hop = this.store.find(hopAlias)
696
+ if (hop === undefined) {
697
+ for (const client of hops) client.end()
698
+ throw new Error(`proxyJump alias '${hopAlias}' not found —create it first`)
699
+ }
700
+ const hopOutcome: { outcome?: HostKeyOutcome | undefined } = {}
701
+ const hopResolved = await this.resolveEntryAuth(hop)
702
+ const hopClient = await connectClient(
703
+ buildConnectConfig(hop, this.opts, sock, {
704
+ hostKeyPolicy: this.hostKeyPolicy,
705
+ hostKeyAlgorithms: this.deps.hostKeyAlgorithms,
706
+ authOverride: hopResolved,
707
+ setOutcome: (value) => { hopOutcome.outcome = value },
708
+ }),
709
+ this.opts.connectTimeoutMs,
710
+ hopOutcome,
711
+ )
712
+ hops.push(hopClient)
713
+ const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
714
+ const nextHost = next !== undefined ? next.host : entry.host
715
+ const nextPort = next !== undefined ? next.port : entry.port
716
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
717
+ // forwardOut has no cancel API: bound the hop-channel open so a dead
718
+ // or half-open jump host cannot hang connectChain forever. On timeout
719
+ // the whole hop chain is torn down (mirrors the error branch).
720
+ let settled = false
721
+ const timer = setTimeout(() => {
722
+ if (settled) return
723
+ settled = true
724
+ for (const client of hops) client.end()
725
+ reject(new Error(`proxyJump forwardOut on '${hopAlias}' timed out after ${this.opts.connectTimeoutMs} ms (target ${nextHost}:${nextPort})`))
726
+ }, this.opts.connectTimeoutMs)
727
+ timer.unref?.()
728
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
729
+ if (settled) {
730
+ // Late arrival after the timeout: the chain is being torn down;
731
+ // close any channel that finally opened.
732
+ if (stream !== undefined) {
733
+ try { stream.close() } catch { /* already closed */ }
734
+ }
735
+ return
736
+ }
737
+ settled = true
738
+ clearTimeout(timer)
739
+ if (error !== undefined) {
740
+ for (const client of hops) client.end()
741
+ reject(error)
742
+ } else {
743
+ resolve(stream)
744
+ }
745
+ })
746
+ })
747
+ }
748
+ try {
749
+ const targetOutcome: { outcome?: HostKeyOutcome | undefined } = {}
750
+ // Resolve the entry's authentication (session password table first,
751
+ // then vault / inline store). The hostVerifier still runs on the raw
752
+ // server key first, so a secret is never sent to an unverified host.
753
+ const resolvedAuth = await this.resolveEntryAuth(entry)
754
+ const client = await connectClient(
755
+ buildConnectConfig(entry, this.opts, sock, {
756
+ hostKeyPolicy: this.hostKeyPolicy,
757
+ hostKeyAlgorithms: this.deps.hostKeyAlgorithms,
758
+ authOverride: resolvedAuth,
759
+ setOutcome: (value) => { targetOutcome.outcome = value },
760
+ }),
761
+ this.opts.connectTimeoutMs,
762
+ targetOutcome,
763
+ )
764
+ return { client, hops }
765
+ } catch (error) {
766
+ for (const client of hops) client.end()
767
+ throw error
768
+ }
769
+ }
770
+
771
+ // --------------------------------------------------------------- exec
772
+
773
+ /** Run one command on `alias` (reusing the pooled connection). */
774
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult>
775
+ async exec(alias: string, command: string, options: ExecOptions): Promise<ExecResult>
776
+ async exec(alias: string, command: string, optionsOrTimeout?: ExecOptions | number): Promise<ExecResult> {
777
+ // Backward compatible: exec(alias, command, timeoutMs) ===
778
+ // exec(alias, command, { timeoutMs, retry: 'connect-only' }).
779
+ const options: ExecOptions = typeof optionsOrTimeout === 'number'
780
+ ? { timeoutMs: optionsOrTimeout, retry: 'connect-only' }
781
+ : { ...optionsOrTimeout, retry: optionsOrTimeout?.retry ?? 'connect-only' }
782
+
783
+ const started = Date.now()
784
+ const budget = options.timeoutMs !== undefined && options.timeoutMs > 0 ? options.timeoutMs : this.opts.defaultExecTimeoutMs
785
+ return this.withClient(alias, async (client, control) => {
786
+ return await new Promise<ExecResult>((resolve, reject) => {
787
+ client.exec(command, (error, stream) => {
788
+ if (error !== undefined) {
789
+ // The channel never opened: only an explicit 'idempotent'
790
+ // caller may replay this window (the server may or may not
791
+ // have seen the request).
792
+ reject(error)
793
+ return
794
+ }
795
+ // The server accepted the channel — the command may already be
796
+ // running. From here the command is NEVER replayed.
797
+ control.markCommitted()
798
+ const stdout = new BoundedUtf8Output(this.opts.maxOutputBytes)
799
+ const stderr = new BoundedUtf8Output(this.opts.maxOutputBytes)
800
+ let timedOut = false
801
+ let settled = false
802
+ const finish = (): void => {
803
+ if (settled) return
804
+ settled = true
805
+ clearTimeout(timer)
806
+ resolve({
807
+ success: false,
808
+ exitCode: null,
809
+ timedOut,
810
+ stdout: stdout.finish(),
811
+ stderr: stderr.finish(),
812
+ durationMs: Date.now() - started,
813
+ error: timedOut ? `command timed out after ${budget} ms` : undefined,
814
+ })
815
+ }
816
+ const timer = setTimeout(() => {
817
+ timedOut = true
818
+ try { stream.signal('KILL') } catch { /* channel gone */ }
819
+ try { stream.close() } catch { /* channel gone */ }
820
+ // Hard deadline: settle now even if the peer never acks the
821
+ // channel close (the stream 'close' handler is then a no-op).
822
+ finish()
823
+ }, budget)
824
+ stream.on('data', (chunk: Buffer) => stdout.append(chunk))
825
+ stream.stderr.on('data', (chunk: Buffer) => stderr.append(chunk))
826
+ stream.on('close', (code: number | null) => {
827
+ if (settled) return
828
+ settled = true
829
+ clearTimeout(timer)
830
+ resolve({
831
+ success: code === 0 && !timedOut,
832
+ exitCode: code,
833
+ timedOut,
834
+ stdout: stdout.finish(),
835
+ stderr: stderr.finish(),
836
+ durationMs: Date.now() - started,
837
+ })
838
+ })
839
+ stream.on('error', (streamError: Error) => {
840
+ if (settled) return
841
+ settled = true
842
+ clearTimeout(timer)
843
+ reject(streamError)
844
+ })
845
+ })
846
+ })
847
+ }, { attempts: 3, retryPolicy: options.retry })
848
+ }
849
+
850
+ /** Run one command against many hosts concurrently. */
851
+ async cluster(options: {
852
+ command: string
853
+ aliases?: string[]
854
+ environment?: string
855
+ tags?: string[]
856
+ timeoutMs?: number
857
+ maxWorkers?: number
858
+ }): Promise<ClusterResult[]> {
859
+ let targets = this.store.list()
860
+ if (options.aliases !== undefined && options.aliases.length > 0) {
861
+ // Preserve the caller's alias order (store order is not contractual).
862
+ const byAlias = new Map(targets.map(entry => [entry.alias, entry]))
863
+ targets = options.aliases
864
+ .map(alias => byAlias.get(alias))
865
+ .filter((entry): entry is SshHostEntry => entry !== undefined)
866
+ }
867
+ if (options.environment !== undefined && options.environment !== '') {
868
+ targets = targets.filter(entry => entry.environment === options.environment)
869
+ }
870
+ if (options.tags !== undefined && options.tags.length > 0) {
871
+ // ALL semantics (matches the ssh_cluster tool description).
872
+ targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
873
+ }
874
+ if (targets.length === 0) return []
875
+ if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
876
+ throw new Error('maxWorkers must be a positive integer')
877
+ }
878
+ const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
879
+ // Pre-sized slots keep the result order aligned with the target order
880
+ // regardless of which host finishes first.
881
+ const results = new Array<ClusterResult>(targets.length)
882
+ const queue = targets.map((entry, index) => ({ entry, index }))
883
+ const run = async (): Promise<void> => {
884
+ while (queue.length > 0) {
885
+ const { entry, index } = queue.shift()!
886
+ try {
887
+ const result = await this.exec(entry.alias, options.command, options.timeoutMs)
888
+ results[index] = { alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs }
889
+ } catch (error) {
890
+ results[index] = { alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) }
891
+ }
892
+ }
893
+ }
894
+ await Promise.all(Array.from({ length: workers }, () => run()))
895
+ return results
896
+ }
897
+
898
+ // -------------------------------------------------------------- shell
899
+
900
+ /**
901
+ * Open one standalone channel on its own connection (never a pooled one).
902
+ * Shared by openShell/openExec (P1-18): alias lookup, jump chain,
903
+ * channel-open timeout, idempotent teardown, and late-callback cleanup live
904
+ * here, so the two public methods keep only their session-specific assembly.
905
+ */
906
+ private async openStandaloneChannel(
907
+ alias: string,
908
+ open: (client: Client, callback: (error: Error | undefined, stream?: ClientChannel) => void) => void,
909
+ ): Promise<{ stream: ClientChannel; teardown: () => void }> {
910
+ const entry = this.store.find(alias)
911
+ if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
912
+ const { client, hops } = await this.connectChain(entry)
913
+ return await new Promise<{ stream: ClientChannel; teardown: () => void }>((resolve, reject) => {
914
+ let settled = false
915
+ let tornDown = false
916
+ const teardown = (): void => {
917
+ if (tornDown) return
918
+ tornDown = true
919
+ try { client.end() } catch { /* closed */ }
920
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
921
+ }
922
+ const timer = setTimeout(() => {
923
+ if (settled) return
924
+ settled = true
925
+ teardown()
926
+ reject(new Error(`channel on '${alias}' did not open within ${CHANNEL_OPEN_TIMEOUT_MS} ms`))
927
+ }, CHANNEL_OPEN_TIMEOUT_MS)
928
+ timer.unref?.()
929
+ open(client, (error, stream) => {
930
+ if (settled) {
931
+ // Late arrival after the timeout: the connection is being torn
932
+ // down; close any channel that finally opened instead of leaking it.
933
+ if (stream !== undefined) {
934
+ try { stream.close() } catch { /* already closed */ }
935
+ }
936
+ teardown()
937
+ return
938
+ }
939
+ settled = true
940
+ clearTimeout(timer)
941
+ if (error !== undefined) {
942
+ teardown()
943
+ reject(error)
944
+ return
945
+ }
946
+ if (stream === undefined) {
947
+ teardown()
948
+ reject(new Error(`channel on '${alias}' opened without a stream`))
949
+ return
950
+ }
951
+ resolve({ stream, teardown })
952
+ })
953
+ })
954
+ }
955
+
956
+ /** Open a PTY shell session for the web terminal (standalone connection). */
957
+ async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
958
+ // The shell is a long-lived exclusive stream: use its own connection so
959
+ // closing it can never tear down a pooled exec/tunnel sharing the alias.
960
+ const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
961
+ client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, callback)
962
+ })
963
+ const session: ShellSession = {
964
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
965
+ resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
966
+ signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
967
+ close: () => {
968
+ try { stream.close() } catch { /* channel gone */ }
969
+ teardown()
970
+ },
971
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
972
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
973
+ }
974
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
975
+ stream.on('close', (code: number | null) => {
976
+ teardown()
977
+ session.onExit?.(code)
978
+ })
979
+ stream.on('error', (streamError: Error) => {
980
+ teardown()
981
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
982
+ })
983
+ return session
984
+ }
985
+
986
+ /**
987
+ * Open a streaming exec channel (no PTY) for the remote subprocess seam.
988
+ * Like the PTY shell, the channel rides its own connection so closing it
989
+ * can never tear down a pooled exec/tunnel sharing the alias.
990
+ */
991
+ async openExec(alias: string, command: string): Promise<ExecSession> {
992
+ const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
993
+ client.exec(command, callback)
994
+ })
995
+ const session: ExecSession = {
996
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
997
+ end: (data) => {
998
+ try {
999
+ if (data !== undefined && data !== '') stream.write(data)
1000
+ stream.end()
1001
+ } catch { /* channel gone */ }
1002
+ },
1003
+ resize: () => { /* exec channels have no PTY */ },
1004
+ signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
1005
+ close: () => {
1006
+ try { stream.close() } catch { /* channel gone */ }
1007
+ teardown()
1008
+ },
1009
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
1010
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
1011
+ }
1012
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
1013
+ stream.stderr.on('data', (chunk: Buffer) => { session.onErrData?.(chunk) })
1014
+ stream.on('close', (code: number | null) => {
1015
+ teardown()
1016
+ session.onExit?.(code)
1017
+ })
1018
+ stream.on('error', (streamError: Error) => {
1019
+ teardown()
1020
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
1021
+ })
1022
+ return session
1023
+ }
1024
+
1025
+ // -------------------------------------------------------------- sftp
1026
+
1027
+ /** Upload one local file (or directory tree) to a remote path. */ async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number; files: number }> {
1028
+ // Remote paths must be absolute: the mkdir chain and fastPut must agree
1029
+ // on one resolution (relative paths previously created dirs at the root).
1030
+ if (!remotePath.startsWith('/')) {
1031
+ throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
1032
+ }
1033
+ const local = resolvePath(localPath)
1034
+ if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
1035
+ return this.withClient(alias, async (client) => {
1036
+ const sftp = await this.sftpFor(client)
1037
+ const stat = statSync(local)
1038
+ let files: string[]
1039
+ if (stat.isDirectory()) {
1040
+ if (!recursive) throw new Error(`'${localPath}' is a directory —enable recursive upload`)
1041
+ files = walkLocalDir(local)
1042
+ await this.ensureRemoteDir(sftp, remotePath)
1043
+ } else {
1044
+ files = ['']
1045
+ await this.ensureRemoteDir(sftp, dirname(remotePath))
1046
+ }
1047
+ let bytes = 0
1048
+ for (const rel of files) {
1049
+ const src = rel === '' ? local : join(local, rel)
1050
+ // Remote paths always use forward slashes; normalize any OS separators.
1051
+ const remoteRel = rel.split(/[\\/]/).join('/')
1052
+ const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
1053
+ await this.fastPut(sftp, src, dst, onProgress)
1054
+ bytes += statSync(src).size
1055
+ }
1056
+ return { bytes, files: files.length }
1057
+ })
1058
+ }
1059
+
1060
+ /** Download one remote file to a local path. */
1061
+ async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
1062
+ return this.withClient(alias, async (client) => {
1063
+ const sftp = await this.sftpFor(client)
1064
+ const stats = await new Promise<import('ssh2').Stats>((resolve, reject) => {
1065
+ sftp.stat(remotePath, (error, result) => error !== undefined ? reject(error) : resolve(result))
1066
+ })
1067
+ if (stats.isDirectory()) {
1068
+ throw new Error(`'${remotePath}' is a directory —directory download is not supported yet (download individual files)`)
1069
+ }
1070
+ const local = resolvePath(localPath)
1071
+ if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
1072
+ await this.fastGet(sftp, remotePath, local, stats.size, onProgress)
1073
+ return { bytes: statSync(local).size }
1074
+ })
1075
+ }
1076
+
1077
+ /** List a remote directory (file browser). Bounded by a timeout so a
1078
+ * stalled SFTP request fails instead of leaving the file tree spinning. */
1079
+ async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
1080
+ return this.withClient(alias, async (client) => {
1081
+ const sftp = await this.sftpFor(client)
1082
+ // readdir/stat requests have no per-request cancel in ssh2; the timeout
1083
+ // only stops the caller. withClient marks the lease broken and the
1084
+ // release() (single holder) reaps the transport, so a hung request
1085
+ // dies with the connection instead of lingering.
1086
+ return this.withTimeout(
1087
+ (async () => {
1088
+ const list = await new Promise<Array<{ filename: string; attrs: import('ssh2').Stats }>>((resolve, reject) => {
1089
+ sftp.readdir(path, (error, items) => error !== undefined ? reject(error) : resolve(items))
1090
+ })
1091
+ return this.classifyEntries(sftp, path, list)
1092
+ })(),
1093
+ SFTP_OP_TIMEOUT_MS,
1094
+ `remote ls timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${path}`,
1095
+ )
1096
+ })
1097
+ }
1098
+
1099
+ /**
1100
+ * Resolve many remote paths to their canonical form in one SFTP pass
1101
+ * (P1-26): one lease + one batch of `sftp.realpath` calls instead of N
1102
+ * `realpath` execs. A path that cannot resolve (dangling symlink, vanished
1103
+ * entry) fails the whole batch — callers treat an unresolvable listing as
1104
+ * an error rather than silently using an uncanonical path.
1105
+ */
1106
+ async realpaths(alias: string, remotePaths: readonly string[]): Promise<string[]> {
1107
+ if (remotePaths.length === 0) return []
1108
+ return this.withClient(alias, async (client) => {
1109
+ const sftp = await this.sftpFor(client)
1110
+ const results = new Array<string>(remotePaths.length)
1111
+ for (let start = 0; start < remotePaths.length; start += SYMLINK_STAT_BATCH) {
1112
+ const batch = remotePaths.slice(start, start + SYMLINK_STAT_BATCH)
1113
+ const resolved = await Promise.all(batch.map((path) => this.withTimeout(
1114
+ new Promise<string>((resolve, reject) => {
1115
+ sftp.realpath(path, (error, canonical) => error !== undefined ? reject(error) : resolve(canonical))
1116
+ }),
1117
+ SFTP_OP_TIMEOUT_MS,
1118
+ `remote realpath timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${path}`,
1119
+ )))
1120
+ for (let i = 0; i < batch.length; i += 1) results[start + i] = resolved[i]!
1121
+ }
1122
+ return results
1123
+ })
1124
+ }
1125
+
1126
+ /**
1127
+ * Classify readdir entries, following symlinks so a link to a directory
1128
+ * (e.g. AutoDL's /root/autodl-tmp) lists as a directory instead of 'other'.
1129
+ * Symlinks are stat'd in PARALLEL batches: serializing them turns a conda /
1130
+ * venv bin full of links into N round-trips (seconds to tens of seconds on a
1131
+ * slow link) — batching keeps it to a handful of round-trips. The whole pass
1132
+ * is bounded by ls()'s timeout.
1133
+ */
1134
+ private async classifyEntries(
1135
+ sftp: import('ssh2').SFTPWrapper,
1136
+ dirPath: string,
1137
+ list: Array<{ filename: string; attrs: import('ssh2').Stats }>,
1138
+ ): Promise<import('./protocol.ts').RemoteDirEntry[]> {
1139
+ const resolved = new Array<'dir' | 'file' | 'other' | null>(list.length).fill(null)
1140
+ const linkIndexes = list
1141
+ .map((item, index) => (item.attrs.isSymbolicLink() ? index : -1))
1142
+ .filter((index) => index >= 0)
1143
+ const base = dirPath.replace(/\/+$/, '')
1144
+ for (let start = 0; start < linkIndexes.length; start += SYMLINK_STAT_BATCH) {
1145
+ const batch = linkIndexes.slice(start, start + SYMLINK_STAT_BATCH)
1146
+ await Promise.all(batch.map(async (index) => {
1147
+ try {
1148
+ const stats = await new Promise<import('ssh2').Stats>((res, rej) => {
1149
+ sftp.stat(`${base}/${list[index].filename}`, (statError, stats) => statError !== undefined ? rej(statError) : res(stats))
1150
+ })
1151
+ resolved[index] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : 'other'
1152
+ } catch {
1153
+ resolved[index] = 'other' // dangling link
1154
+ }
1155
+ }))
1156
+ }
1157
+ return list.map((item, index): import('./protocol.ts').RemoteDirEntry => {
1158
+ let type: 'dir' | 'file' | 'other' = item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other'
1159
+ if (type === 'other' && item.attrs.isSymbolicLink()) type = resolved[index] ?? 'other'
1160
+ return { name: item.filename, type, size: item.attrs.size, mtimeMs: item.attrs.mtime * 1000, mode: item.attrs.mode }
1161
+ })
1162
+ }
1163
+
1164
+ /** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
1165
+ async stat(alias: string, remotePath: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
1166
+ return this.withClient(alias, async (client) => {
1167
+ const sftp = await this.sftpFor(client)
1168
+ const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), SFTP_OP_TIMEOUT_MS, `remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${remotePath}`)
1169
+ return {
1170
+ type: attrs.isDirectory() ? 'dir' : attrs.isFile() ? 'file' : 'other',
1171
+ size: attrs.size,
1172
+ mtimeMs: attrs.mtime * 1000,
1173
+ mode: attrs.mode,
1174
+ }
1175
+ })
1176
+ }
1177
+
1178
+ /**
1179
+ * Lstat one remote path without following the final symlink. Returns
1180
+ * undefined when the path is absent (the fs seam's lstat contract).
1181
+ */
1182
+ async lstat(alias: string, remotePath: string): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
1183
+ return this.withClient(alias, async (client) => {
1184
+ const sftp = await this.sftpFor(client)
1185
+ try {
1186
+ const attrs = await new Promise<import('ssh2').Stats>((resolve, reject) => {
1187
+ sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
1188
+ })
1189
+ return {
1190
+ type: attrs.isSymbolicLink() ? 'symlink' : attrs.isDirectory() ? 'directory' : attrs.isFile() ? 'file' : 'other',
1191
+ size: attrs.size,
1192
+ mtimeMs: attrs.mtime * 1000,
1193
+ mode: attrs.mode,
1194
+ }
1195
+ } catch (error) {
1196
+ const code = String((error as { code?: unknown }).code ?? '')
1197
+ if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${String(error)}`)) return undefined
1198
+ throw error
1199
+ }
1200
+ })
1201
+ }
1202
+
1203
+ /**
1204
+ * Open a remote file read stream (the fs seam's streamText). The returned
1205
+ * stream must be consumed or destroyed; the pooled connection stays busy
1206
+ * for the stream's lifetime.
1207
+ */
1208
+ /**
1209
+ * Open a remote file read stream (the fs seam's streamText). The returned
1210
+ * stream must be consumed or destroyed; the pooled connection stays busy
1211
+ * for the stream's lifetime (P0-10: a 'stream' lease, released on
1212
+ * end/close/error/destroy — not when this function returns).
1213
+ */
1214
+ async readStream(alias: string, remotePath: string): Promise<import('node:stream').Readable> {
1215
+ let lease: ClientLease | undefined
1216
+ let lastError: unknown
1217
+
1218
+ // Preserve withClient's connect-only behavior: acquisition is safe to
1219
+ // retry because no SFTP operation has started until a lease is obtained.
1220
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1221
+ try {
1222
+ lease = await this.connectionPool.acquire(alias, { kind: 'stream' })
1223
+ break
1224
+ } catch (error) {
1225
+ lastError = error
1226
+ if (attempt === 3) {
1227
+ throw error instanceof Error ? error : new Error(String(error))
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ // The loop either obtained a lease or threw on its final attempt.
1233
+ if (lease === undefined) {
1234
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
1235
+ }
1236
+
1237
+ try {
1238
+ const sftp = await this.sftpFor(lease.client)
1239
+ const stream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
1240
+
1241
+ let released = false
1242
+ const release = (): void => {
1243
+ if (released) return
1244
+ released = true
1245
+ // Drop the other terminal listeners so the lease closure is not
1246
+ // retained after, e.g., 'end' fires before 'close'.
1247
+ stream.removeListener('end', release)
1248
+ stream.removeListener('close', release)
1249
+ stream.removeListener('error', release)
1250
+ lease.release()
1251
+ }
1252
+
1253
+ stream.once('end', release)
1254
+ stream.once('close', release)
1255
+ stream.once('error', release)
1256
+
1257
+ // Node Readable.destroy() normally emits 'close', but ssh2's SFTP
1258
+ // stream is outside our control — release synchronously as a fallback
1259
+ // even if the implementation suppresses 'close'.
1260
+ const originalDestroy = stream.destroy
1261
+ stream.destroy = function destroy(error?: Error): typeof stream {
1262
+ try {
1263
+ return originalDestroy.call(this, error) as typeof stream
1264
+ } finally {
1265
+ release()
1266
+ }
1267
+ }
1268
+
1269
+ return stream
1270
+ } catch (error) {
1271
+ // Covers both caching/opening the SFTP subsystem and a synchronous
1272
+ // createReadStream failure; no stream escaped, ownership ends here.
1273
+ lease.release()
1274
+ throw error
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Read one remote file fully into memory (text or binary) with its mtime.
1280
+ * The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
1281
+ */
1282
+ async readFile(alias: string, remotePath: string): Promise<{ content: Buffer; mtime: number; size: number }> {
1283
+ return this.withClient(alias, async (client) => {
1284
+ const sftp = await this.sftpFor(client)
1285
+ const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), SFTP_OP_TIMEOUT_MS, `remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${remotePath}`)
1286
+ if (attrs.isDirectory()) throw new Error(`'${remotePath}' is a directory`)
1287
+ const chunks: Buffer[] = []
1288
+ let readStream: import('node:stream').Readable | undefined
1289
+ await this.withTimeout(new Promise<void>((resolve, reject) => {
1290
+ readStream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
1291
+ readStream.on('data', (chunk: Buffer) => { chunks.push(chunk) })
1292
+ readStream.on('error', (error: Error) => reject(error))
1293
+ readStream.on('end', () => resolve())
1294
+ }), SFTP_READ_TIMEOUT_MS, `remote read timed out after ${SFTP_READ_TIMEOUT_MS}ms: ${remotePath}`, () => {
1295
+ // Abort the transfer: an un-destroyed stream keeps pulling data into
1296
+ // `chunks` (and holding an SFTP channel) after the caller timed out.
1297
+ try { readStream?.destroy() } catch { /* already closed */ }
1298
+ })
1299
+ return { content: Buffer.concat(chunks), mtime: attrs.mtime * 1000, size: attrs.size }
1300
+ })
1301
+ }
1302
+
1303
+ /**
1304
+ * Write one remote file from memory (parents are created). When
1305
+ * `expectedMtime` is given, a stat-then-write conflict check throws before
1306
+ * any byte is written (the GUI and the workspace tools use it for
1307
+ * overwrite protection).
1308
+ */
1309
+ async writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }> {
1310
+ return this.withClient(alias, async (client) => {
1311
+ const sftp = await this.sftpFor(client)
1312
+ await this.ensureRemoteDir(sftp, dirname(remotePath))
1313
+ if (expectedMtime !== undefined) {
1314
+ const attrs = await this.sftpStat(sftp, remotePath)
1315
+ const current = attrs.mtime * 1000
1316
+ if (current !== expectedMtime) {
1317
+ throw new Error(`mtime conflict: remote mtime ${current} != expected ${expectedMtime}`)
1318
+ }
1319
+ }
1320
+ await new Promise<void>((resolve, reject) => {
1321
+ const stream = sftp.createWriteStream(remotePath)
1322
+ stream.on('error', (error: Error) => reject(error))
1323
+ stream.on('close', () => resolve())
1324
+ stream.end(content)
1325
+ })
1326
+ const attrs = await this.sftpStat(sftp, remotePath)
1327
+ return { mtime: attrs.mtime * 1000 }
1328
+ })
1329
+ }
1330
+
1331
+ /** Create a remote directory chain (mkdir -p semantics). */
1332
+ async mkdir(alias: string, remotePath: string): Promise<void> {
1333
+ return this.withClient(alias, async (client) => {
1334
+ const sftp = await this.sftpFor(client)
1335
+ await this.ensureRemoteDir(sftp, remotePath)
1336
+ })
1337
+ }
1338
+
1339
+ /**
1340
+ * Remove a remote file or directory. Directories require `recursive: true`
1341
+ * and are walked depth-first (children first, then the directory itself).
1342
+ *
1343
+ * Deletion never follows symlinks: every node is classified with lstat, so
1344
+ * a symlink pointing at a directory is unlinked (only the link), never
1345
+ * recursed into — the old stat/readdir-attr check could delete the link
1346
+ * target's contents.
1347
+ */
1348
+ async rm(alias: string, remotePath: string, recursive = false): Promise<void> {
1349
+ const normalized = remotePath.replace(/\/+$/, '')
1350
+ if (remotePath === '' || normalized === '' || normalized === '/') {
1351
+ throw new Error(`refusing to delete root path '${remotePath}'`)
1352
+ }
1353
+ return this.withClient(alias, async (client) => {
1354
+ const sftp = await this.sftpFor(client)
1355
+ const attrs = await this.sftpLstat(sftp, remotePath)
1356
+ if (attrs.isSymbolicLink() || !attrs.isDirectory()) {
1357
+ // Symlink or plain file: unlink only, never follow the link.
1358
+ await new Promise<void>((resolve, reject) => {
1359
+ sftp.unlink(remotePath, (error) => error !== undefined ? reject(error) : resolve())
1360
+ })
1361
+ return
1362
+ }
1363
+ if (!recursive) throw new Error(`'${remotePath}' is a directory —pass recursive: true`)
1364
+ const remove = async (dir: string): Promise<void> => {
1365
+ const list = await new Promise<Array<{ filename: string }>>((resolve, reject) => {
1366
+ sftp.readdir(dir, (error, entries) => error !== undefined ? reject(error) : resolve(entries))
1367
+ })
1368
+ for (const entry of list) {
1369
+ const child = dir.replace(/\/+$/, '') + '/' + entry.filename
1370
+ // lstat every child: readdir attrs may misreport a symlink as a
1371
+ // directory, and recursing into the link target is the exact
1372
+ // deletion hazard we must avoid.
1373
+ const childAttrs = await this.sftpLstat(sftp, child)
1374
+ if (childAttrs.isSymbolicLink() || !childAttrs.isDirectory()) {
1375
+ await new Promise<void>((resolve, reject) => {
1376
+ sftp.unlink(child, (error) => error !== undefined ? reject(error) : resolve())
1377
+ })
1378
+ } else {
1379
+ await remove(child)
1380
+ }
1381
+ }
1382
+ await new Promise<void>((resolve, reject) => {
1383
+ sftp.rmdir(dir, (error) => error !== undefined ? reject(error) : resolve())
1384
+ })
1385
+ }
1386
+ await remove(remotePath)
1387
+ })
1388
+ }
1389
+
1390
+ /** Rename / move a remote path (mv semantics, same filesystem). */
1391
+ async rename(alias: string, fromPath: string, toPath: string): Promise<void> {
1392
+ return this.withClient(alias, async (client) => {
1393
+ const sftp = await this.sftpFor(client)
1394
+ await new Promise<void>((resolve, reject) => {
1395
+ sftp.rename(fromPath, toPath, (error) => error !== undefined ? reject(error) : resolve())
1396
+ })
1397
+ })
1398
+ }
1399
+
1400
+ /** Reject a promise after `ms` (unref'd so it never keeps the process alive).
1401
+ * `onTimeout` (when given) runs right before the rejection: ssh2 SFTP
1402
+ * requests have no cancel API, so callers that hold an abort handle (e.g.
1403
+ * a read stream) destroy it here — otherwise the underlying transfer would
1404
+ * keep running (and, for reads, keep buffering) after the caller was told
1405
+ * it timed out. */
1406
+ private withTimeout<T>(promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T> {
1407
+ return new Promise<T>((resolve, reject) => {
1408
+ const timer = setTimeout(() => {
1409
+ try { onTimeout?.() } catch { /* best-effort abort */ }
1410
+ reject(new Error(message))
1411
+ }, ms)
1412
+ timer.unref?.()
1413
+ promise.then(
1414
+ (value) => { clearTimeout(timer); resolve(value) },
1415
+ (error) => { clearTimeout(timer); reject(error) },
1416
+ )
1417
+ })
1418
+ }
1419
+
1420
+ /** Stat wrapper (one SFTP stat call). */
1421
+ private sftpStat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
1422
+ return new Promise((resolve, reject) => {
1423
+ sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
1424
+ })
1425
+ }
1426
+
1427
+ /** Lstat wrapper (does NOT follow symlinks — the deletion safety gate). */
1428
+ private sftpLstat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
1429
+ return new Promise((resolve, reject) => {
1430
+ sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
1431
+ })
1432
+ }
1433
+
1434
+ /**
1435
+ * The (cached) SFTP channel for a pooled client. `Client.sftp()` opens a new
1436
+ * subsystem channel per call, so this memoizes one channel per live client;
1437
+ * when the channel closes the cache entry is dropped so the next call opens
1438
+ * SFTP on the replacement connection. Failed opens are also evicted so a
1439
+ * transient channel failure can be retried.
1440
+ */
1441
+ private sftpFor(client: Client): Promise<import('ssh2').SFTPWrapper> {
1442
+ const cached = this.sftpChannels.get(client)
1443
+ if (cached !== undefined) return cached
1444
+ const pending = new Promise<import('ssh2').SFTPWrapper>((resolve, reject) => {
1445
+ client.sftp((error, sftp) => {
1446
+ if (error !== undefined) {
1447
+ this.sftpChannels.delete(client)
1448
+ reject(error)
1449
+ return
1450
+ }
1451
+ sftp.on('close', () => { this.sftpChannels.delete(client) })
1452
+ sftp.on('error', () => { this.sftpChannels.delete(client) })
1453
+ resolve(sftp)
1454
+ })
1455
+ })
1456
+ this.sftpChannels.set(client, pending)
1457
+ return pending
1458
+ }
1459
+
1460
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
1461
+ private async ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
1462
+ const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
1463
+ for (let index = 0; index < segments.length; index += 1) {
1464
+ const current = '/' + segments.slice(0, index + 1).join('/')
1465
+ // Stat-then-mkdir: a missing path fails the stat, and mkdir is
1466
+ // idempotent because the stat check runs first (some sftp servers
1467
+ // throw on EEXIST). Any stat error is treated as "not there", which
1468
+ // matches the previous recursive behavior.
1469
+ const exists = await this.withTimeout(
1470
+ this.sftpStat(sftp, current),
1471
+ SFTP_OP_TIMEOUT_MS,
1472
+ `remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${current}`,
1473
+ ).then(() => true, () => false)
1474
+ if (exists) continue
1475
+ await new Promise<void>((resolve, reject) => {
1476
+ sftp.mkdir(current, (error) => error !== undefined ? reject(error) : resolve())
1477
+ })
1478
+ }
1479
+ }
1480
+
1481
+ private fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
1482
+ return new Promise((resolve, reject) => {
1483
+ const tracker = createTransferProgressTracker(dst, statSync(src).size, onProgress)
1484
+ sftp.fastPut(src, dst, {
1485
+ concurrency: this.opts.sftpConcurrency,
1486
+ step: (transferred: number, _chunk: number, total: number) => tracker.step(transferred, total),
1487
+ }, (error) => {
1488
+ if (error !== undefined) {
1489
+ tracker.fail(error)
1490
+ reject(error)
1491
+ } else {
1492
+ tracker.done()
1493
+ resolve()
1494
+ }
1495
+ })
1496
+ })
1497
+ }
1498
+
1499
+ private fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, initialTotal: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
1500
+ return new Promise((resolve, reject) => {
1501
+ const tracker = createTransferProgressTracker(src, initialTotal, onProgress)
1502
+ sftp.fastGet(src, dst, {
1503
+ concurrency: this.opts.sftpConcurrency,
1504
+ step: (transferred: number, _chunk: number, total: number) => tracker.step(transferred, total),
1505
+ }, (error) => {
1506
+ if (error !== undefined) {
1507
+ tracker.fail(error)
1508
+ reject(error)
1509
+ } else {
1510
+ tracker.done()
1511
+ resolve()
1512
+ }
1513
+ })
1514
+ })
1515
+ }
1516
+
1517
+ // ------------------------------------------------------------- tunnel
1518
+
1519
+ /** Remove one tunnel's transport-failure listener (both events). */
1520
+ private removeTunnelClientFailureListener(tunnel: TunnelRecord): void {
1521
+ const client = tunnel.lease.client
1522
+ client.removeListener('error', tunnel.clientFailureHandler)
1523
+ client.removeListener('close', tunnel.clientFailureHandler)
1524
+ }
1525
+
1526
+ /**
1527
+ * Mark every tunnel sharing a failed physical SSH client as failed. Failed
1528
+ * records stay in this.tunnels so listTunnels() exposes the terminal state
1529
+ * and stopTunnel() remains the single place that deletes + releases.
1530
+ */
1531
+ private markTunnelsFailedForClient(client: Client, _error?: unknown): void {
1532
+ for (const tunnel of this.tunnels.values()) {
1533
+ if (tunnel.lease.client !== client) continue
1534
+ tunnel.info.state = 'failed'
1535
+ this.removeTunnelClientFailureListener(tunnel)
1536
+ try { tunnel.server.close() } catch { /* never listened or already closed */ }
1537
+ for (const socket of tunnel.sockets) {
1538
+ try { socket.destroy() } catch { /* peer already gone */ }
1539
+ }
1540
+ tunnel.sockets.clear()
1541
+ }
1542
+ }
1543
+
1544
+ /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
1545
+ async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
1546
+ if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
1547
+ throw new Error('remotePort must be an integer in 1..65535')
1548
+ }
1549
+ if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
1550
+ throw new Error('localPort must be an integer in 1..65535')
1551
+ }
1552
+ const entry = this.store.find(alias)
1553
+ if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
1554
+ const remoteHost = options.remoteHost ?? '127.0.0.1'
1555
+ const id = `tun-${this.nextTunnelId++}`
1556
+ const info: TunnelInfo = {
1557
+ id,
1558
+ alias,
1559
+ localPort: 0,
1560
+ remoteHost,
1561
+ remotePort: options.remotePort,
1562
+ state: 'connecting',
1563
+ startedAt: Date.now(),
1564
+ }
1565
+ const lease = await this.connectionPool.acquire(alias, { kind: 'tunnel' })
1566
+ const client = lease.client
1567
+ const sockets = new Set<import('node:net').Socket>()
1568
+
1569
+ const server = createServer((socket) => {
1570
+ sockets.add(socket)
1571
+
1572
+ let forwardFinished = false
1573
+ let forwardTimer: NodeJS.Timeout | undefined
1574
+
1575
+ const abandonForward = (): void => {
1576
+ if (forwardFinished) return
1577
+ forwardFinished = true
1578
+ if (forwardTimer !== undefined) clearTimeout(forwardTimer)
1579
+ }
1580
+ socket.once('close', abandonForward)
1581
+
1582
+ forwardTimer = setTimeout(() => {
1583
+ if (forwardFinished) return
1584
+ forwardFinished = true
1585
+ // forwardOut cannot be cancelled through ssh2; destroy the local side
1586
+ // now, and the late callback will close any channel that arrives.
1587
+ try {
1588
+ socket.destroy(new Error(`SSH tunnel forward timed out after ${TUNNEL_FORWARD_TIMEOUT_MS}ms`))
1589
+ } catch { /* local peer already gone */ }
1590
+ }, TUNNEL_FORWARD_TIMEOUT_MS)
1591
+ forwardTimer.unref?.()
1592
+
1593
+ client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
1594
+ // Timeout / local disconnect / tunnel stop may have happened while
1595
+ // forwardOut was pending — never attach a late channel.
1596
+ if (forwardFinished || socket.destroyed) {
1597
+ if (forwardTimer !== undefined) clearTimeout(forwardTimer)
1598
+ forwardFinished = true
1599
+ if (stream !== undefined) {
1600
+ try { stream.close() } catch { /* late channel already closed */ }
1601
+ }
1602
+ return
1603
+ }
1604
+ forwardFinished = true
1605
+ if (forwardTimer !== undefined) clearTimeout(forwardTimer)
1606
+ if (error !== undefined) {
1607
+ socket.destroy()
1608
+ return
1609
+ }
1610
+ // Both ends of the pipe can die independently; destroy the pair so an
1611
+ // unhandled 'error' event can never crash the host process.
1612
+ const destroy = (): void => {
1613
+ try { socket.destroy() } catch { /* gone */ }
1614
+ try { stream.close() } catch { /* gone */ }
1615
+ }
1616
+ stream.on('error', destroy)
1617
+ socket.on('error', destroy)
1618
+ stream.on('close', destroy)
1619
+ socket.on('close', destroy)
1620
+ stream.pipe(socket).pipe(stream)
1621
+ })
1622
+ })
1623
+
1624
+ let rejectStart: ((reason?: unknown) => void) | undefined
1625
+
1626
+ const clientFailureHandler = (error?: unknown): void => {
1627
+ this.markTunnelsFailedForClient(client, error)
1628
+ // If the transport dies while server.listen() is still pending, reject
1629
+ // instead of returning a failed tunnel as a success.
1630
+ rejectStart?.(error instanceof Error
1631
+ ? error
1632
+ : new Error(`SSH connection '${alias}' closed while starting tunnel`))
1633
+ }
1634
+
1635
+ const tunnel: TunnelRecord = { info, server, alias, lease, sockets, clientFailureHandler }
1636
+
1637
+ // Register before listen() so a transport failure during the async listen
1638
+ // window can find and fail this record.
1639
+ this.tunnels.set(id, tunnel)
1640
+ client.once('error', clientFailureHandler)
1641
+ client.once('close', clientFailureHandler)
1642
+
1643
+ try {
1644
+ await new Promise<void>((resolve, reject) => {
1645
+ let settled = false
1646
+ const resolveOnce = (): void => {
1647
+ if (settled) return
1648
+ settled = true
1649
+ rejectStart = undefined
1650
+ server.removeListener('error', rejectOnce)
1651
+ resolve()
1652
+ }
1653
+ const rejectOnce = (error: unknown): void => {
1654
+ if (settled) return
1655
+ settled = true
1656
+ rejectStart = undefined
1657
+ server.removeListener('error', rejectOnce)
1658
+ reject(error)
1659
+ }
1660
+ rejectStart = rejectOnce
1661
+ server.once('error', rejectOnce)
1662
+ server.listen(options.localPort ?? 0, '127.0.0.1', resolveOnce)
1663
+ })
1664
+ } catch (error) {
1665
+ // Full rollback for listen failure or transport failure during start.
1666
+ this.tunnels.delete(id)
1667
+ this.removeTunnelClientFailureListener(tunnel)
1668
+ try { server.close() } catch { /* never listened */ }
1669
+ for (const socket of sockets) {
1670
+ try { socket.destroy() } catch { /* already closed */ }
1671
+ }
1672
+ sockets.clear()
1673
+ lease.release()
1674
+ throw error
1675
+ }
1676
+
1677
+ // The client-failure handler may have set failed right around listen
1678
+ // completion; do not overwrite that terminal state with 'forwarding'.
1679
+ if (info.state === 'failed') {
1680
+ this.tunnels.delete(id)
1681
+ this.removeTunnelClientFailureListener(tunnel)
1682
+ lease.release()
1683
+ throw new Error(`SSH connection '${alias}' closed while starting tunnel`)
1684
+ }
1685
+
1686
+ const address = server.address()
1687
+ info.localPort = typeof address === 'object' && address !== null ? address.port : 0
1688
+ info.state = 'forwarding'
1689
+ return info
1690
+ }
1691
+
1692
+ /** All active tunnels. */
1693
+ listTunnels(): TunnelInfo[] {
1694
+ return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
1695
+ }
1696
+
1697
+ /** Stop one tunnel (closes listener and live sockets, releases its lease). */
1698
+ stopTunnel(id: string): boolean {
1699
+ const tunnel = this.tunnels.get(id)
1700
+ if (tunnel === undefined) return false
1701
+ // Delete first so re-entrant stop calls and transport events cannot
1702
+ // process this tunnel twice.
1703
+ this.tunnels.delete(id)
1704
+ this.removeTunnelClientFailureListener(tunnel)
1705
+ try { tunnel.server.close() } catch { /* already closed by failure handler */ }
1706
+ for (const socket of tunnel.sockets) {
1707
+ try { socket.destroy() } catch { /* already closed */ }
1708
+ }
1709
+ tunnel.sockets.clear()
1710
+ // Release only THIS tunnel's lease — other tunnels (or operations) on the
1711
+ // same alias keep their own ownership of the pooled connection.
1712
+ tunnel.lease.release()
1713
+ return true
1714
+ }
1715
+
1716
+ /** Stop all tunnels (optionally for one alias). */
1717
+ stopAllTunnels(alias?: string): number {
1718
+ let count = 0
1719
+ for (const [id, tunnel] of [...this.tunnels]) {
1720
+ if (alias === undefined || tunnel.alias === alias) {
1721
+ this.stopTunnel(id)
1722
+ count += 1
1723
+ }
1724
+ }
1725
+ return count
1726
+ }
1727
+
1728
+ // ------------------------------------------------------------- misc
1729
+
1730
+ /** Probe connectivity: connect, run `true`, close. Typed errors the GUI
1731
+ * must react to (host-key TOFU, session password) are NOT flattened into
1732
+ * a plain message — callers (routes → panel / workspace gate) key their
1733
+ * interactive dialogs on the typed error. Everything else (unreachable,
1734
+ * timeout, auth failure) returns a failed result. */
1735
+ async test(alias: string): Promise<TestResult> {
1736
+ const started = Date.now()
1737
+ try {
1738
+ // `true` is idempotent — allow channel-open retries, never replays
1739
+ // once the server accepted the channel.
1740
+ const result = await this.exec(alias, 'true', { timeoutMs: 10_000, retry: 'idempotent' })
1741
+ return result.success
1742
+ ? { ok: true, latencyMs: result.durationMs }
1743
+ : { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
1744
+ } catch (error) {
1745
+ if (error instanceof NeedsPasswordError || error instanceof HostKeyUnknownError || error instanceof HostKeyMismatchError) {
1746
+ throw error
1747
+ }
1748
+ return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
1749
+ }
1750
+ }
1751
+
1752
+ /** Close every pooled connection and tunnel, and wipe the in-memory
1753
+ * session password table (secrets are never persisted anywhere). */
1754
+ dispose(): void {
1755
+ for (const id of [...this.tunnels.keys()]) this.stopTunnel(id)
1756
+ this.connectionPool.invalidateAll()
1757
+ this.sftpChannels.clear()
1758
+ this.sessionPasswords.clear()
1759
+ }
1760
+ }
1761
+