@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -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 +18 -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 +36 -11
  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 -99
  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 +137 -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 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/ssh/engine.ts CHANGED
@@ -1,168 +1,104 @@
1
1
  /**
2
- * The SSH engine: a per-alias persistent connection pool (ssh2) with
2
+ * The SSH engine facade: a per-alias persistent connection pool (ssh2) with
3
3
  * multi-hop jump support, command execution, PTY shells, SFTP transfers,
4
4
  * local port-forward tunnels and cluster execution —the DSH counterpart of
5
5
  * ssh-skill's daemon + scripts, living entirely in the host process.
6
+ *
7
+ * This module stays the public entry point (its export surface and the
8
+ * SshEngine class identity are load-bearing: production keys caches on the
9
+ * engine object in a WeakMap). The implementation lives in focused internal
10
+ * components, each owning one resource family:
11
+ *
12
+ * connection/manager.ts pooled connections, connect chains, secrets,
13
+ * retry/replay policy, exec/cluster/test, redaction
14
+ * sftp/service.ts SFTP subsystem channel cache + every SFTP operation
15
+ * terminal/service.ts standalone PTY shell / streaming exec transports
16
+ * tunnel/service.ts local listeners, forwarded sockets, tunnel leases
17
+ *
18
+ * Dependency direction is one-way (sftp/terminal/tunnel → connection); no
19
+ * component imports this facade back, so there is no cycle.
6
20
  */
7
21
 
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'
22
+ import type { Writable } from 'node:stream'
12
23
  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'
24
+ // POSIX single-quoting for the server-budget wrapper below.
25
+ import { shellQuote } from '../shell.ts'
26
+ import type { SshConnectionService } from './connection/pool.ts'
19
27
  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
- }
28
+ ConnectionManager,
29
+ type EngineDeps,
30
+ type EngineOptions,
31
+ type ExecOptions,
32
+ type SshInvalidateOptions,
33
+ } from './connection/manager.ts'
34
+ import { SftpService } from './sftp/service.ts'
35
+ import { TerminalService } from './terminal/service.ts'
36
+ import { TunnelService } from './tunnel/service.ts'
37
+ import type { SshHostSummary, TransferProgress, TunnelInfo } from './protocol.ts'
38
+ import type { HostStoreView } from '../core.ts'
62
39
 
63
40
  /**
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.
41
+ * How long a timed-out channel gets to acknowledge its close before it counts
42
+ * as half-open. Peers that honor KILL/close finish in milliseconds; only a
43
+ * genuinely stuck peer (or a dropped transport) leaves the channel open.
68
44
  */
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
- }
45
+ const CHANNEL_CLOSE_GRACE_MS = 5_000
82
46
 
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
- }
47
+ /** Extra seconds the SERVER-side budget gets over the client one, so the
48
+ * client's own deadline normally fires first (with its `timedOut` result) and
49
+ * `timeout` only becomes the janitor that reaps the process group. */
50
+ const SERVER_BUDGET_SLACK_SECONDS = 1
90
51
 
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
- }
52
+ /** Seconds `timeout` waits after SIGTERM before it escalates to SIGKILL. */
53
+ const SERVER_KILL_GRACE_SECONDS = 2
117
54
 
118
55
  /**
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).
56
+ * Enforce an exec budget ON THE SERVER as well as in the client.
125
57
  *
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.
58
+ * The client-side deadline only stops WAITING: measured against a real host
59
+ * (a CentOS 7 host running OpenSSH 7.4), a timed-out exec left BOTH the wrapper
60
+ * shell and its child running — `bash -c sleep 30` plus `sleep 30` were still
61
+ * alive 20s later — because ssh2's channel `signal` request was not delivered
62
+ * to the remote process, and closing the channel does not kill it either.
63
+ * Wrapping the command in coreutils `timeout` moves the deadline INSIDE the
64
+ * session, which does reap the whole process group (verified on the same host:
65
+ * exit 124 within the budget, zero orphans).
66
+ *
67
+ * The wrapper degrades to running the command verbatim when the server has no
68
+ * `timeout` (busybox or a minimal image), so no host becomes unusable.
69
+ *
70
+ * @param command - the caller's command, run unchanged inside the wrapper.
71
+ * @param budgetMs - the client-side budget this mirrors.
72
+ * @returns the command to hand to the remote shell.
128
73
  */
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
74
+ export function wrapCommandWithServerBudget(command: string, budgetMs: number): string {
75
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0) return command
76
+ const seconds = Math.ceil(budgetMs / 1000) + SERVER_BUDGET_SLACK_SECONDS
77
+ const quoted = shellQuote(command)
78
+ const shell = '"${SHELL:-/bin/sh}"'
79
+ return 'if command -v timeout >/dev/null 2>&1; then '
80
+ + `timeout -k ${SERVER_KILL_GRACE_SECONDS} ${seconds} ${shell} -c ${quoted}; `
81
+ + `else ${command}; fi`
135
82
  }
136
83
 
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
84
+ // Public surface kept on this path: these declarations live in the connection
85
+ // component (single source of truth) and are re-exported here so every
86
+ // existing `from './engine.ts'` / `from '.../engine.ts'` import keeps working.
87
+ export {
88
+ buildConnectConfig,
89
+ DEFAULTS,
90
+ NeedsPasswordError,
91
+ sshAgentConfig,
92
+ } from './connection/manager.ts'
93
+ export type {
94
+ ConnectionManager,
95
+ EngineDeps,
96
+ EngineOptions,
97
+ ExecOptions,
98
+ ResolvedAuthDeps,
99
+ RetryPolicy,
100
+ SshInvalidateOptions,
101
+ } from './connection/manager.ts'
166
102
 
167
103
  /** A live PTY shell session. */
168
104
  export interface ShellSession {
@@ -190,307 +126,105 @@ export interface ShellSession {
190
126
  * input burst. Used by the subprocess capability seam's remote provider.
191
127
  */
192
128
  export interface ExecSession extends ShellSession {
129
+ /** Native ssh2 channel input. Using its real Writable contract preserves
130
+ * bytes, backpressure, errors and finish semantics for subprocess stdin. */
131
+ readonly stdin: Writable
193
132
  /** Assign to receive the remote stderr stream. */
194
133
  onErrData?: (data: Buffer) => void
195
134
  /** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process. */
196
135
  signal(name: string): void
197
- /** Write the final input burst and half-close stdin. */
136
+ /** Compatibility half-close for non-streaming callers. */
198
137
  end(data?: string): void
199
138
  }
200
139
 
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
140
  /**
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.
141
+ * The SSH engine. Owns the connection pool, tunnels, standalone terminal
142
+ * transports and SFTP channels. One instance per plugin apply.
322
143
  *
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.
144
+ * Kept as the public facade over ConnectionManager/TunnelService/
145
+ * TerminalService/SftpService: every method here is a thin delegation, so the
146
+ * class identity (and therefore WeakMap cache keys in production) is stable.
400
147
  */
401
148
  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 }>()
149
+ private readonly manager: ConnectionManager
150
+ private readonly sftpService: SftpService
151
+ private readonly tunnelService: TunnelService
152
+ private readonly terminalService: TerminalService
424
153
 
425
154
  /**
426
155
  * @param store - the host config store.
427
- * @param options - engine knobs (defaults applied).
156
+ * @param options - engine knobs (defaults applied after validation).
428
157
  * @param deps - optional security deps (host-key TOFU, secret resolution).
429
158
  * Absent → pre-security behavior (inline auth, no host verification).
430
159
  */
431
160
  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)
161
+ // One frozen source of truth for timer/concurrency knobs.
162
+ const resolved = ConnectionManager.resolveOptions(options)
163
+ // The pool is constructed inside the manager, which is why it takes the
164
+ // SFTP eviction hook rather than reaching for the SFTP service itself.
165
+ this.manager = new ConnectionManager(store, resolved, deps, {
166
+ onClientDisposed: (client, error) => { this.sftpService?.onClientDisposed(client, error) },
167
+ redactOutput: text => deps?.redactOutput?.(text) ?? text,
168
+ // Bound so cluster()/test() fan out through the same engine.exec
169
+ // identity that callers observe (and tests can spy on).
170
+ executor: (alias, command, execOptions) => execOptions === undefined
171
+ ? this.exec(alias, command)
172
+ : this.exec(alias, command, execOptions),
173
+ })
174
+ this.sftpService = new SftpService(
175
+ {
176
+ acquire: (alias, acquireOptions) => this.manager.connections.acquire(alias, acquireOptions),
177
+ withClient: (alias, fn, withOptions) => this.manager.withClient(alias, fn, withOptions),
443
178
  },
444
- onDispose: (client) => {
445
- this.sftpChannels.delete(client)
179
+ resolved,
180
+ )
181
+ this.tunnelService = new TunnelService({
182
+ connections: this.manager.connections,
183
+ findEntry: alias => store.find(alias),
184
+ })
185
+ this.terminalService = new TerminalService({
186
+ connectStandalone: async (alias) => {
187
+ const entry = store.find(alias)
188
+ if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
189
+ return await this.manager.connectChain(entry)
446
190
  },
447
191
  })
448
192
  }
449
193
 
450
194
  // ---------------------------------------------------------- session secrets
451
195
 
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
- */
196
+ /** Provide a secret for `alias` for THIS session only (never persisted). */
458
197
  setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
459
- this.sessionPasswords.set(alias, secret)
198
+ this.manager.setSessionPassword(alias, secret)
460
199
  }
461
200
 
462
201
  /** Read the session secret for one alias (undefined = not provided yet). */
463
202
  getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
464
- return this.sessionPasswords.get(alias)
203
+ return this.manager.getSessionPassword(alias)
465
204
  }
466
205
 
467
206
  /** Drop every session secret (e.g. on secretStorage change / lock). */
468
207
  clearSessionSecrets(): void {
469
- this.sessionPasswords.clear()
208
+ this.manager.clearSessionSecrets()
209
+ }
210
+
211
+ /** Redact credentials known to this session and the optional vault guard. */
212
+ redact(text: string): string {
213
+ return this.manager.redact(text)
470
214
  }
471
215
 
472
216
  // ---------------------------------------------------------------- config
473
217
 
474
218
  /** Secret-free host list (filtered by the optional query). */
475
219
  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))
220
+ return this.manager.list(query)
484
221
  }
485
222
 
486
223
  /** One host summary by alias. */
487
224
  find(alias: string): SshHostSummary | undefined {
488
- const entry = this.store.find(alias)
489
- return entry === undefined ? undefined : this.store.summarize(entry)
225
+ return this.manager.find(alias)
490
226
  }
491
227
 
492
- // -------------------------------------------------------------- pool
493
-
494
228
  /**
495
229
  * Shared connection/lease service.
496
230
  *
@@ -499,281 +233,41 @@ export class SshEngine {
499
233
  * separately owned pool.
500
234
  */
501
235
  get connections(): SshConnectionService {
502
- return this.connectionPool
236
+ return this.manager.connections
503
237
  }
504
238
 
505
239
  /** Aliases with a live pooled transport right now (for connection-state
506
240
  * indicators — the GUI badge colors bound workspaces by it). */
507
241
  connectedAliases(): string[] {
508
- return this.connectionPool.liveAliases()
242
+ return this.manager.connectedAliases()
509
243
  }
510
244
 
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
- */
245
+ /** Retire the pooled connection for one alias (optionally its dependents). */
519
246
  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
- }
247
+ this.manager.invalidate(alias, options)
546
248
  }
547
249
 
548
250
  /**
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.
251
+ * Record one channel that never acknowledged close after a hard timeout.
252
+ * Exposed for the exec grace path and for diagnostics/tests.
253
+ * @param alias - the host the channel belonged to.
254
+ * @returns true when the pooled transport was drained.
561
255
  */
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))
256
+ noteLeakedChannel(alias: string): boolean {
257
+ return this.manager.noteLeakedChannel(alias)
620
258
  }
621
259
 
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
- }
260
+ /** A late channel close: un-count one half-open channel for this alias. */
261
+ noteChannelClosed(alias: string): void {
262
+ this.manager.noteChannelClosed(alias)
769
263
  }
770
264
 
771
265
  // --------------------------------------------------------------- exec
772
266
 
773
267
  /** 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> {
268
+ exec(alias: string, command: string, timeoutMs?: number): Promise<import('./protocol.ts').ExecResult>
269
+ exec(alias: string, command: string, options: ExecOptions): Promise<import('./protocol.ts').ExecResult>
270
+ exec(alias: string, command: string, optionsOrTimeout?: ExecOptions | number): Promise<import('./protocol.ts').ExecResult> {
777
271
  // Backward compatible: exec(alias, command, timeoutMs) ===
778
272
  // exec(alias, command, { timeoutMs, retry: 'connect-only' }).
779
273
  const options: ExecOptions = typeof optionsOrTimeout === 'number'
@@ -781,34 +275,48 @@ export class SshEngine {
781
275
  : { ...optionsOrTimeout, retry: optionsOrTimeout?.retry ?? 'connect-only' }
782
276
 
783
277
  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) => {
278
+ const budget = options.timeoutMs !== undefined && options.timeoutMs > 0
279
+ ? options.timeoutMs
280
+ : this.manager.resolvedOptions.defaultExecTimeoutMs
281
+ // The client deadline below only stops waiting; this makes the SERVER stop
282
+ // the command too, so a timeout cannot leave orphan processes behind.
283
+ const remoteCommand = wrapCommandWithServerBudget(command, budget)
284
+ return this.manager.withClient(alias, async (client, control) => {
285
+ return await new Promise<import('./protocol.ts').ExecResult>((resolve, reject) => {
286
+ client.exec(remoteCommand, (error, stream) => {
788
287
  if (error !== undefined) {
789
288
  // The channel never opened: only an explicit 'idempotent'
790
289
  // caller may replay this window (the server may or may not
791
290
  // have seen the request).
792
- reject(error)
291
+ reject(new Error(this.redact(error.message), { cause: error }))
793
292
  return
794
293
  }
795
294
  // The server accepted the channel — the command may already be
796
295
  // running. From here the command is NEVER replayed.
797
296
  control.markCommitted()
798
- const stdout = new BoundedUtf8Output(this.opts.maxOutputBytes)
799
- const stderr = new BoundedUtf8Output(this.opts.maxOutputBytes)
297
+ const stdout = new BoundedUtf8Output(this.manager.resolvedOptions.maxOutputBytes)
298
+ const stderr = new BoundedUtf8Output(this.manager.resolvedOptions.maxOutputBytes)
800
299
  let timedOut = false
801
300
  let settled = false
301
+ let channelClosed = false
302
+ let leakCounted = false
303
+ let graceTimer: NodeJS.Timeout | undefined
304
+ /** Set by onAbort below; declared first so settle paths can detach it. */
305
+ let onAbort: (() => void) | undefined
306
+ const detachAbort = (): void => {
307
+ if (onAbort !== undefined) control.signal?.removeEventListener('abort', onAbort)
308
+ }
802
309
  const finish = (): void => {
803
310
  if (settled) return
804
311
  settled = true
805
312
  clearTimeout(timer)
313
+ detachAbort()
806
314
  resolve({
807
315
  success: false,
808
316
  exitCode: null,
809
317
  timedOut,
810
- stdout: stdout.finish(),
811
- stderr: stderr.finish(),
318
+ stdout: this.redact(stdout.finish()),
319
+ stderr: this.redact(stderr.finish()),
812
320
  durationMs: Date.now() - started,
813
321
  error: timedOut ? `command timed out after ${budget} ms` : undefined,
814
322
  })
@@ -820,19 +328,49 @@ export class SshEngine {
820
328
  // Hard deadline: settle now even if the peer never acks the
821
329
  // channel close (the stream 'close' handler is then a no-op).
822
330
  finish()
331
+ // ...but keep watching for that ack: a peer that ignores close
332
+ // leaves a half-open channel against the server's MaxSessions
333
+ // budget. Count it (the manager drains the transport once enough
334
+ // accumulate) instead of leaking silently.
335
+ graceTimer = setTimeout(() => {
336
+ if (channelClosed) return
337
+ leakCounted = true
338
+ this.manager.noteLeakedChannel(alias)
339
+ }, CHANNEL_CLOSE_GRACE_MS)
340
+ graceTimer.unref?.()
823
341
  }, budget)
342
+ // Per-request cancellation: ssh2 cannot cancel a request, but it CAN
343
+ // close this one channel. Doing that here keeps a caller abort from
344
+ // having to retire the whole pooled transport (and thus every
345
+ // concurrent operation on the same alias). The manager still owns the
346
+ // rejected promise; this listener only stops the remote work.
347
+ onAbort = (): void => {
348
+ try { stream.signal('KILL') } catch { /* channel gone */ }
349
+ try { stream.close() } catch { /* channel gone */ }
350
+ finish()
351
+ }
352
+ if (control.signal !== undefined) {
353
+ if (control.signal.aborted) onAbort()
354
+ else control.signal.addEventListener('abort', onAbort, { once: true })
355
+ }
824
356
  stream.on('data', (chunk: Buffer) => stdout.append(chunk))
825
357
  stream.stderr.on('data', (chunk: Buffer) => stderr.append(chunk))
826
358
  stream.on('close', (code: number | null) => {
359
+ channelClosed = true
360
+ if (graceTimer !== undefined) clearTimeout(graceTimer)
361
+ // A late acknowledgement un-counts the channel: a merely slow peer
362
+ // must not accumulate its way to a transport retirement.
363
+ if (leakCounted) this.manager.noteChannelClosed(alias)
827
364
  if (settled) return
828
365
  settled = true
829
366
  clearTimeout(timer)
367
+ detachAbort()
830
368
  resolve({
831
369
  success: code === 0 && !timedOut,
832
370
  exitCode: code,
833
371
  timedOut,
834
- stdout: stdout.finish(),
835
- stderr: stderr.finish(),
372
+ stdout: this.redact(stdout.finish()),
373
+ stderr: this.redact(stderr.finish()),
836
374
  durationMs: Date.now() - started,
837
375
  })
838
376
  })
@@ -840,147 +378,36 @@ export class SshEngine {
840
378
  if (settled) return
841
379
  settled = true
842
380
  clearTimeout(timer)
381
+ detachAbort()
843
382
  reject(streamError)
844
383
  })
845
384
  })
846
385
  })
847
- }, { attempts: 3, retryPolicy: options.retry })
386
+ }, { attempts: 3, retryPolicy: options.retry, signal: options.signal })
848
387
  }
849
388
 
850
- /** Run one command against many hosts concurrently. */
851
- async cluster(options: {
389
+ /**
390
+ * Run one command against many hosts concurrently. Per-host failures carry
391
+ * the typed, secret-free fields declared on `ClusterResult`.
392
+ */
393
+ cluster(options: {
852
394
  command: string
853
395
  aliases?: string[]
854
396
  environment?: string
855
397
  tags?: string[]
856
398
  timeoutMs?: number
857
399
  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
400
+ /** Aborts every per-host run when the caller disconnects. */
401
+ signal?: AbortSignal
402
+ }): Promise<import('./protocol.ts').ClusterResult[]> {
403
+ return this.manager.cluster(options)
896
404
  }
897
405
 
898
406
  // -------------------------------------------------------------- shell
899
407
 
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
408
  /** 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
409
+ openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
410
+ return this.terminalService.openShell(alias, size)
984
411
  }
985
412
 
986
413
  /**
@@ -988,352 +415,76 @@ export class SshEngine {
988
415
  * Like the PTY shell, the channel rides its own connection so closing it
989
416
  * can never tear down a pooled exec/tunnel sharing the alias.
990
417
  */
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
418
+ openExec(alias: string, command: string): Promise<ExecSession> {
419
+ return this.terminalService.openExec(alias, command)
1023
420
  }
1024
421
 
1025
422
  // -------------------------------------------------------------- sftp
1026
423
 
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
- })
424
+ /** Upload one local file (or directory tree) to a remote path. */
425
+ upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number; files: number }> {
426
+ return this.sftpService.upload(alias, localPath, remotePath, recursive, onProgress, signal)
1058
427
  }
1059
428
 
1060
429
  /** 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
- })
430
+ download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number }> {
431
+ return this.sftpService.download(alias, remotePath, localPath, onProgress, signal)
1075
432
  }
1076
433
 
1077
434
  /** List a remote directory (file browser). Bounded by a timeout so a
1078
435
  * 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
- })
436
+ ls(alias: string, path: string, signal?: AbortSignal): Promise<import('./protocol.ts').RemoteDirEntry[]> {
437
+ return this.sftpService.ls(alias, path, signal)
1097
438
  }
1098
439
 
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
- })
440
+ /** Resolve many remote paths to their canonical form in one SFTP pass. */
441
+ realpaths(alias: string, remotePaths: readonly string[], signal?: AbortSignal): Promise<string[]> {
442
+ return this.sftpService.realpaths(alias, remotePaths, signal)
1162
443
  }
1163
444
 
1164
445
  /** 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
- })
446
+ stat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
447
+ return this.sftpService.stat(alias, remotePath, signal)
1176
448
  }
1177
449
 
1178
450
  /**
1179
451
  * Lstat one remote path without following the final symlink. Returns
1180
452
  * undefined when the path is absent (the fs seam's lstat contract).
1181
453
  */
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
- })
454
+ lstat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
455
+ return this.sftpService.lstat(alias, remotePath, signal)
1201
456
  }
1202
457
 
1203
458
  /**
1204
459
  * Open a remote file read stream (the fs seam's streamText). The returned
1205
460
  * 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
461
+ * for the stream's lifetime (a 'stream' lease, released on
1212
462
  * end/close/error/destroy — not when this function returns).
1213
463
  */
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
- }
464
+ readStream(alias: string, remotePath: string, signal?: AbortSignal): Promise<import('node:stream').Readable> {
465
+ return this.sftpService.readStream(alias, remotePath, signal)
1276
466
  }
1277
467
 
1278
468
  /**
1279
469
  * Read one remote file fully into memory (text or binary) with its mtime.
1280
470
  * The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
1281
471
  */
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
- })
472
+ readFile(alias: string, remotePath: string, maxBytes?: number, signal?: AbortSignal): Promise<{ content: Buffer; mtime: number; size: number }> {
473
+ return this.sftpService.readFile(alias, remotePath, maxBytes, signal)
1301
474
  }
1302
475
 
1303
476
  /**
1304
477
  * Write one remote file from memory (parents are created). When
1305
478
  * `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).
479
+ * any byte is written (overwrite protection for the GUI and workspace tools).
1308
480
  */
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
- })
481
+ writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number, signal?: AbortSignal): Promise<{ mtime: number }> {
482
+ return this.sftpService.writeFile(alias, remotePath, content, expectedMtime, signal)
1329
483
  }
1330
484
 
1331
485
  /** 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
- })
486
+ mkdir(alias: string, remotePath: string, signal?: AbortSignal): Promise<void> {
487
+ return this.sftpService.mkdir(alias, remotePath, signal)
1337
488
  }
1338
489
 
1339
490
  /**
@@ -1345,384 +496,31 @@ export class SshEngine {
1345
496
  * recursed into — the old stat/readdir-attr check could delete the link
1346
497
  * target's contents.
1347
498
  */
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
- })
499
+ rm(alias: string, remotePath: string, recursive = false, signal?: AbortSignal): Promise<void> {
500
+ return this.sftpService.rm(alias, remotePath, recursive, signal)
1388
501
  }
1389
502
 
1390
503
  /** 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
- })
504
+ rename(alias: string, fromPath: string, toPath: string, signal?: AbortSignal): Promise<void> {
505
+ return this.sftpService.rename(alias, fromPath, toPath, signal)
1515
506
  }
1516
507
 
1517
508
  // ------------------------------------------------------------- tunnel
1518
509
 
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
510
  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
511
+ return this.tunnelService.startTunnel(alias, options)
1690
512
  }
1691
513
 
1692
- /** All active tunnels. */
1693
514
  listTunnels(): TunnelInfo[] {
1694
- return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
515
+ return this.tunnelService.listTunnels()
1695
516
  }
1696
517
 
1697
- /** Stop one tunnel (closes listener and live sockets, releases its lease). */
1698
518
  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
519
+ return this.tunnelService.stopTunnel(id)
1714
520
  }
1715
521
 
1716
- /** Stop all tunnels (optionally for one alias). */
1717
522
  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
523
+ return this.tunnelService.stopAllTunnels(alias)
1726
524
  }
1727
525
 
1728
526
  // ------------------------------------------------------------- misc
@@ -1732,30 +530,24 @@ export class SshEngine {
1732
530
  * a plain message — callers (routes → panel / workspace gate) key their
1733
531
  * interactive dialogs on the typed error. Everything else (unreachable,
1734
532
  * 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
- }
533
+ test(alias: string, signal?: AbortSignal): Promise<import('./protocol.ts').TestResult> {
534
+ return this.manager.test(alias, signal)
1750
535
  }
1751
536
 
1752
- /** Close every pooled connection and tunnel, and wipe the in-memory
1753
- * session password table (secrets are never persisted anywhere). */
537
+ /**
538
+ * Close every resource this engine owns and wipe the in-memory session
539
+ * password table (secrets are never persisted anywhere).
540
+ *
541
+ * Order: tunnel → terminal → SFTP → connection, so a resource is always
542
+ * closed before the transport that carries it, and each resource has exactly
543
+ * one dispose point.
544
+ */
1754
545
  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()
546
+ this.tunnelService.dispose()
547
+ this.terminalService.dispose()
548
+ this.sftpService.dispose(new Error('SSH engine disposed while SFTP was active'))
549
+ // Retires every pooled transport (whose onDispose hook drops the SFTP
550
+ // channel of that client) and then clears the session secrets.
551
+ this.manager.disposeSensitive()
1759
552
  }
1760
553
  }
1761
-