@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
@@ -0,0 +1,967 @@
1
+ import type { Readable, Writable } from 'node:stream'
2
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve as resolvePath } from 'node:path'
4
+ import { Client, type SFTPWrapper, type Stats } from 'ssh2'
5
+ import type { ClientLease } from '../connection/lease.ts'
6
+ import { createTransferProgressTracker } from '../transfer/progress.ts'
7
+ import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
8
+
9
+ /** Lets an SFTP operation declare the point after which replay is unsafe. */
10
+ export interface SftpOperationControl {
11
+ markCommitted(): void
12
+ }
13
+
14
+ /** Options for one access.withClient() call. */
15
+ export interface SftpClientOptions {
16
+ /** Total acquire+operation attempt budget (default 3). */
17
+ attempts?: number
18
+ retryPolicy?: 'never' | 'connect-only' | 'idempotent'
19
+ signal?: AbortSignal
20
+ }
21
+
22
+ /**
23
+ * Narrow connection dependency of the SFTP component: the pooled lease
24
+ * acquisition plus the engine's retry/replay policy. The component owns the
25
+ * lease for the whole operation (including the lifetime of a returned read
26
+ * stream) and never reaches into the pool's bookkeeping.
27
+ */
28
+ export interface SftpClientAccess {
29
+ withClient<T>(
30
+ alias: string,
31
+ fn: (client: Client, control: SftpOperationControl) => Promise<T>,
32
+ options?: SftpClientOptions,
33
+ ): Promise<T>
34
+ acquire(alias: string, options: { kind: 'operation' | 'stream'; signal?: AbortSignal }): Promise<ClientLease>
35
+ }
36
+
37
+ /**
38
+ * The engine knobs the SFTP component reads. Keyed exactly like the engine's
39
+ * resolved options, so every SFTP deadline/concurrency keeps one source of
40
+ * truth.
41
+ */
42
+ export interface SftpOptions {
43
+ sftpConcurrency: number
44
+ sftpOpenTimeoutMs: number
45
+ sftpOperationTimeoutMs: number
46
+ sftpReadTimeoutMs: number
47
+ maxReadFileBytes: number
48
+ sftpTransferIdleTimeoutMs: number
49
+ sftpRecursiveRmTimeoutMs: number
50
+ }
51
+
52
+ /** One cached SFTP subsystem channel for a live pooled client. */
53
+ interface SftpCacheEntry {
54
+ promise: Promise<SFTPWrapper>
55
+ wrapper?: SFTPWrapper
56
+ /** Owner client, so a timed-out request can find and rotate its channel. */
57
+ client: Client
58
+ /** Requests currently riding this channel. The channel is shared, so a
59
+ * per-request timeout must NOT close it while others are still in flight. */
60
+ inFlight: number
61
+ /** A request timed out while others were in flight: rotate once idle. */
62
+ suspect: boolean
63
+ cancel(error: Error): void
64
+ }
65
+
66
+ function isMissingSftpError(error: unknown): boolean {
67
+ const code = String((error as { code?: unknown } | undefined)?.code ?? '')
68
+ return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${String(error)}`)
69
+ }
70
+
71
+ /** Symlink stat batch width — parallelized so a dir full of links (conda /
72
+ * venv bin, node_modules/.bin) costs a handful of round-trips, not N. ssh2's
73
+ * SFTP window pipelines requests, so one batch ≈ one round-trip. */
74
+ const SYMLINK_STAT_BATCH = 64
75
+
76
+ function walkLocalDir(root: string): string[] {
77
+ const files: string[] = []
78
+ const visit = (dir: string): void => {
79
+ for (const name of readdirSync(dir)) {
80
+ const full = join(dir, name)
81
+ const stat = statSync(full)
82
+ if (stat.isDirectory()) visit(full)
83
+ else if (stat.isFile()) files.push(relative(root, full))
84
+ }
85
+ }
86
+ visit(root)
87
+ return files
88
+ }
89
+
90
+ /**
91
+ * Owns every SFTP operation for the engine: one cached subsystem channel per
92
+ * live pooled client (plus its cancellation), the per-request deadlines, and
93
+ * the upload/download/ls/stat/read/write/mkdir/rm/rename surface.
94
+ */
95
+ export class SftpService {
96
+ /**
97
+ * One cached SFTP subsystem channel per live client. `Client.sftp()` opens a
98
+ * NEW subsystem channel on every call and OpenSSH caps open sessions per
99
+ * connection (MaxSessions, default 10) —reopening SFTP per operation lets
100
+ * channels pile up on the pooled long-lived connection until listing/reading
101
+ * fails intermittently. Caching one channel per client fixes that; the pool
102
+ * drops the cache via onClientDisposed when a connection is torn down.
103
+ */
104
+ private readonly cache = new Map<Client, SftpCacheEntry>()
105
+
106
+ constructor(
107
+ private readonly access: SftpClientAccess,
108
+ private readonly sftpOpts: SftpOptions,
109
+ ) {}
110
+ /** Upload one local file (or directory tree) to a remote path. */
111
+ async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number; files: number }> {
112
+ // on one resolution (relative paths previously created dirs at the root).
113
+ if (!remotePath.startsWith('/')) {
114
+ throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
115
+ }
116
+ const local = resolvePath(localPath)
117
+ if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
118
+ return this.access.withClient(alias, async (client) => {
119
+ const sftp = await this.sftpFor(client)
120
+ const stat = statSync(local)
121
+ let files: string[]
122
+ if (stat.isDirectory()) {
123
+ if (!recursive) throw new Error(`'${localPath}' is a directory —enable recursive upload`)
124
+ files = walkLocalDir(local)
125
+ await this.ensureRemoteDir(sftp, remotePath)
126
+ } else {
127
+ files = ['']
128
+ await this.ensureRemoteDir(sftp, dirname(remotePath))
129
+ }
130
+ let bytes = 0
131
+ for (const rel of files) {
132
+ const src = rel === '' ? local : join(local, rel)
133
+ // Remote paths always use forward slashes; normalize any OS separators.
134
+ const remoteRel = rel.split(/[\\/]/).join('/')
135
+ const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
136
+ await this.fastPut(sftp, src, dst, onProgress)
137
+ bytes += statSync(src).size
138
+ }
139
+ return { bytes, files: files.length }
140
+ }, { signal })
141
+ }
142
+
143
+ /** Download one remote file to a local path. */
144
+ async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number }> {
145
+ return this.access.withClient(alias, async (client) => {
146
+ const sftp = await this.sftpFor(client)
147
+ const stats = await this.sftpStat(sftp, remotePath)
148
+ if (stats.isDirectory()) {
149
+ throw new Error(`'${remotePath}' is a directory —directory download is not supported yet (download individual files)`)
150
+ }
151
+ const local = resolvePath(localPath)
152
+ if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
153
+ await this.fastGet(sftp, remotePath, local, stats.size, onProgress)
154
+ return { bytes: statSync(local).size }
155
+ }, { signal })
156
+ }
157
+
158
+ /** List a remote directory (file browser). Bounded by a timeout so a
159
+ * stalled SFTP request fails instead of leaving the file tree spinning. */
160
+ async ls(alias: string, path: string, signal?: AbortSignal): Promise<import('../protocol.ts').RemoteDirEntry[]> {
161
+ return this.access.withClient(alias, async (client) => {
162
+ const sftp = await this.sftpFor(client)
163
+ // readdir/stat requests have no per-request cancel in ssh2; the timeout
164
+ // only stops the caller. withClient marks the lease broken and the
165
+ // release() (single holder) reaps the transport, so a hung request
166
+ // dies with the connection instead of lingering.
167
+ return this.withSftpTimeout(
168
+ sftp,
169
+ (async () => {
170
+ const list = await new Promise<Array<{ filename: string; attrs: import('ssh2').Stats }>>((resolve, reject) => {
171
+ sftp.readdir(path, (error, items) => error !== undefined ? reject(error) : resolve(items))
172
+ })
173
+ return this.classifyEntries(sftp, path, list)
174
+ })(),
175
+ this.sftpOpts.sftpOperationTimeoutMs,
176
+ `remote ls timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${path}`,
177
+ )
178
+ }, { signal })
179
+ }
180
+
181
+ /**
182
+ * Resolve many remote paths to their canonical form in one SFTP pass
183
+ * (P1-26): one lease + one batch of `sftp.realpath` calls instead of N
184
+ * `realpath` execs. A path that cannot resolve (dangling symlink, vanished
185
+ * entry) fails the whole batch — callers treat an unresolvable listing as
186
+ * an error rather than silently using an uncanonical path.
187
+ */
188
+ async realpaths(alias: string, remotePaths: readonly string[], signal?: AbortSignal): Promise<string[]> {
189
+ if (remotePaths.length === 0) return []
190
+ return this.access.withClient(alias, async (client) => {
191
+ const sftp = await this.sftpFor(client)
192
+ const results = new Array<string>(remotePaths.length)
193
+ for (let start = 0; start < remotePaths.length; start += SYMLINK_STAT_BATCH) {
194
+ const batch = remotePaths.slice(start, start + SYMLINK_STAT_BATCH)
195
+ const resolved = await Promise.all(batch.map((path) => this.withSftpTimeout(
196
+ sftp,
197
+ new Promise<string>((resolve, reject) => {
198
+ sftp.realpath(path, (error, canonical) => error !== undefined ? reject(error) : resolve(canonical))
199
+ }),
200
+ this.sftpOpts.sftpOperationTimeoutMs,
201
+ `remote realpath timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${path}`,
202
+ )))
203
+ for (let i = 0; i < batch.length; i += 1) results[start + i] = resolved[i]!
204
+ }
205
+ return results
206
+ }, { signal })
207
+ }
208
+
209
+ /**
210
+ * Classify readdir entries, following symlinks so a link to a directory
211
+ * (e.g. AutoDL's /root/autodl-tmp) lists as a directory instead of 'other'.
212
+ * Symlinks are stat'd in PARALLEL batches: serializing them turns a conda /
213
+ * venv bin full of links into N round-trips (seconds to tens of seconds on a
214
+ * slow link) — batching keeps it to a handful of round-trips. The whole pass
215
+ * is bounded by ls()'s timeout.
216
+ */
217
+ async classifyEntries(
218
+ sftp: import('ssh2').SFTPWrapper,
219
+ dirPath: string,
220
+ list: Array<{ filename: string; attrs: import('ssh2').Stats }>,
221
+ ): Promise<import('../protocol.ts').RemoteDirEntry[]> {
222
+ const resolved = new Array<'dir' | 'file' | 'other' | null>(list.length).fill(null)
223
+ const linkIndexes = list
224
+ .map((item, index) => (item.attrs.isSymbolicLink() ? index : -1))
225
+ .filter((index) => index >= 0)
226
+ const base = dirPath.replace(/\/+$/, '')
227
+ for (let start = 0; start < linkIndexes.length; start += SYMLINK_STAT_BATCH) {
228
+ const batch = linkIndexes.slice(start, start + SYMLINK_STAT_BATCH)
229
+ await Promise.all(batch.map(async (index) => {
230
+ try {
231
+ const stats = await new Promise<import('ssh2').Stats>((res, rej) => {
232
+ sftp.stat(`${base}/${list[index].filename}`, (statError, stats) => statError !== undefined ? rej(statError) : res(stats))
233
+ })
234
+ resolved[index] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : 'other'
235
+ } catch {
236
+ resolved[index] = 'other' // dangling link
237
+ }
238
+ }))
239
+ }
240
+ return list.map((item, index): import('../protocol.ts').RemoteDirEntry => {
241
+ let type: 'dir' | 'file' | 'other' = item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other'
242
+ if (type === 'other' && item.attrs.isSymbolicLink()) type = resolved[index] ?? 'other'
243
+ return { name: item.filename, type, size: item.attrs.size, mtimeMs: item.attrs.mtime * 1000, mode: item.attrs.mode }
244
+ })
245
+ }
246
+
247
+ /** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
248
+ async stat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
249
+ return this.access.withClient(alias, async (client) => {
250
+ const sftp = await this.sftpFor(client)
251
+ const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
252
+ return {
253
+ type: attrs.isDirectory() ? 'dir' : attrs.isFile() ? 'file' : 'other',
254
+ size: attrs.size,
255
+ mtimeMs: attrs.mtime * 1000,
256
+ mode: attrs.mode,
257
+ }
258
+ }, { signal })
259
+ }
260
+
261
+ /**
262
+ * Lstat one remote path without following the final symlink. Returns
263
+ * undefined when the path is absent (the fs seam's lstat contract).
264
+ */
265
+ async lstat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
266
+ return this.access.withClient(alias, async (client) => {
267
+ const sftp = await this.sftpFor(client)
268
+ try {
269
+ const attrs = await this.sftpLstat(sftp, remotePath)
270
+ return {
271
+ type: attrs.isSymbolicLink() ? 'symlink' : attrs.isDirectory() ? 'directory' : attrs.isFile() ? 'file' : 'other',
272
+ size: attrs.size,
273
+ mtimeMs: attrs.mtime * 1000,
274
+ mode: attrs.mode,
275
+ }
276
+ } catch (error) {
277
+ if (isMissingSftpError(error)) return undefined
278
+ throw error
279
+ }
280
+ }, { signal })
281
+ }
282
+
283
+ /**
284
+ * Open a remote file read stream (the fs seam's streamText). The returned
285
+ * stream must be consumed or destroyed; the pooled connection stays busy
286
+ * for the stream's lifetime.
287
+ */
288
+ /**
289
+ * Open a remote file read stream (the fs seam's streamText). The returned
290
+ * stream must be consumed or destroyed; the pooled connection stays busy
291
+ * for the stream's lifetime (P0-10: a 'stream' lease, released on
292
+ * end/close/error/destroy — not when this function returns).
293
+ */
294
+ async readStream(alias: string, remotePath: string, signal?: AbortSignal): Promise<import('node:stream').Readable> {
295
+ let lease: ClientLease | undefined
296
+ let lastError: unknown
297
+
298
+ // Preserve withClient's connect-only behavior: acquisition is safe to
299
+ // retry because no SFTP operation has started until a lease is obtained.
300
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
301
+ try {
302
+ lease = await this.access.acquire(alias, { kind: 'stream', signal })
303
+ break
304
+ } catch (error) {
305
+ lastError = error
306
+ if (signal?.aborted === true || attempt === 3) {
307
+ throw error instanceof Error ? error : new Error(String(error))
308
+ }
309
+ }
310
+ }
311
+
312
+ // The loop either obtained a lease or threw on its final attempt.
313
+ if (lease === undefined) {
314
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
315
+ }
316
+
317
+ try {
318
+ const sftp = await this.sftpFor(lease.client)
319
+ const stream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
320
+
321
+ let released = false
322
+ let idleTimer: NodeJS.Timeout | undefined
323
+ const onAbort = (): void => {
324
+ const error = signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('remote read stream aborted'), { name: 'AbortError' })
325
+ stream.destroy(error)
326
+ }
327
+ const armIdleTimeout = (): void => {
328
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
329
+ idleTimer = setTimeout(() => {
330
+ stream.destroy(new Error(`remote read stream made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`))
331
+ }, this.sftpOpts.sftpReadTimeoutMs)
332
+ idleTimer.unref?.()
333
+ }
334
+ const release = (): void => {
335
+ if (released) return
336
+ released = true
337
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
338
+ signal?.removeEventListener('abort', onAbort)
339
+ // Drop the other terminal listeners so the lease closure is not
340
+ // retained after, e.g., 'end' fires before 'close'.
341
+ stream.removeListener('data', armIdleTimeout)
342
+ stream.removeListener('end', release)
343
+ stream.removeListener('close', release)
344
+ stream.removeListener('error', release)
345
+ lease.release()
346
+ }
347
+
348
+ stream.on('data', armIdleTimeout)
349
+ stream.once('end', release)
350
+ stream.once('close', release)
351
+ stream.once('error', release)
352
+
353
+ // Node Readable.destroy() normally emits 'close', but ssh2's SFTP
354
+ // stream is outside our control — release synchronously as a fallback
355
+ // even if the implementation suppresses 'close'.
356
+ const originalDestroy = stream.destroy
357
+ stream.destroy = function destroy(error?: Error): typeof stream {
358
+ try {
359
+ return originalDestroy.call(this, error) as typeof stream
360
+ } finally {
361
+ release()
362
+ }
363
+ }
364
+ signal?.addEventListener('abort', onAbort, { once: true })
365
+ if (signal?.aborted === true) onAbort()
366
+ else armIdleTimeout()
367
+
368
+ return stream
369
+ } catch (error) {
370
+ // Covers both caching/opening the SFTP subsystem and a synchronous
371
+ // createReadStream failure; no stream escaped, ownership ends here.
372
+ lease.release()
373
+ throw error
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Read one remote file fully into memory (text or binary) with its mtime.
379
+ * The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
380
+ */
381
+ async readFile(alias: string, remotePath: string, maxBytes?: number, signal?: AbortSignal): Promise<{ content: Buffer; mtime: number; size: number }> {
382
+ const requestedLimit = maxBytes ?? this.sftpOpts.maxReadFileBytes
383
+ if (!Number.isSafeInteger(requestedLimit) || requestedLimit <= 0) throw new Error('readFile maxBytes must be a positive safe integer')
384
+ const limit = Math.min(requestedLimit, this.sftpOpts.maxReadFileBytes)
385
+ return this.access.withClient(alias, async (client) => {
386
+ const sftp = await this.sftpFor(client)
387
+ const attrs = await this.sftpStat(sftp, remotePath)
388
+ if (attrs.isDirectory()) throw new Error(`'${remotePath}' is a directory`)
389
+ if (attrs.size > limit) throw new Error(`remote file exceeds the ${limit}-byte read limit: ${remotePath}`)
390
+ const chunks: Buffer[] = []
391
+ let total = 0
392
+ let readStream: import('node:stream').Readable | undefined
393
+ await new Promise<void>((resolve, reject) => {
394
+ readStream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
395
+ let settled = false
396
+ let idleTimer: NodeJS.Timeout | undefined
397
+ const clearIdle = (): void => {
398
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
399
+ }
400
+ const fail = (error: Error): void => {
401
+ if (settled) return
402
+ settled = true
403
+ clearIdle()
404
+ reject(error)
405
+ }
406
+ const armIdle = (): void => {
407
+ clearIdle()
408
+ idleTimer = setTimeout(() => {
409
+ if (settled) return
410
+ fail(new Error(`remote read made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`))
411
+ try { readStream?.destroy() } catch { /* already closed */ }
412
+ }, this.sftpOpts.sftpReadTimeoutMs)
413
+ idleTimer.unref?.()
414
+ }
415
+ readStream.on('data', (chunk: Buffer) => {
416
+ if (settled) return
417
+ armIdle()
418
+ total += chunk.length
419
+ if (total > limit) {
420
+ fail(new Error(`remote file exceeded the ${limit}-byte read limit while streaming: ${remotePath}`))
421
+ try { readStream?.destroy() } catch { /* already closed */ }
422
+ return
423
+ }
424
+ chunks.push(chunk)
425
+ })
426
+ readStream.on('error', (error: Error) => { fail(error) })
427
+ readStream.on('end', () => {
428
+ if (settled) return
429
+ settled = true
430
+ clearIdle()
431
+ resolve()
432
+ })
433
+ armIdle()
434
+ })
435
+ return { content: Buffer.concat(chunks, total), mtime: attrs.mtime * 1000, size: attrs.size }
436
+ }, { signal })
437
+ }
438
+
439
+ /**
440
+ * Write one remote file from memory (parents are created). When
441
+ * `expectedMtime` is given, a stat-then-write conflict check throws before
442
+ * any byte is written (the GUI and the workspace tools use it for
443
+ * overwrite protection).
444
+ */
445
+ async writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number, signal?: AbortSignal): Promise<{ mtime: number }> {
446
+ return this.access.withClient(alias, async (client) => {
447
+ const sftp = await this.sftpFor(client)
448
+ // Counted as an in-flight user of the shared subsystem: an upload that
449
+ // stalls must not have the channel rotated out from under it.
450
+ const entry = this.enter(sftp)
451
+ try {
452
+ await this.ensureRemoteDir(sftp, dirname(remotePath))
453
+ if (expectedMtime !== undefined) {
454
+ const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote write preflight stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
455
+ const current = attrs.mtime * 1000
456
+ if (current !== expectedMtime) {
457
+ throw new Error(`mtime conflict: remote mtime ${current} != expected ${expectedMtime}`)
458
+ }
459
+ }
460
+ let writeStream: import('node:stream').Writable | undefined
461
+ await new Promise<void>((resolve, reject) => {
462
+ writeStream = sftp.createWriteStream(remotePath)
463
+ let settled = false
464
+ let idleTimer: NodeJS.Timeout | undefined
465
+ const clearIdle = (): void => {
466
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
467
+ }
468
+ const fail = (error: Error): void => {
469
+ if (settled) return
470
+ settled = true
471
+ clearIdle()
472
+ reject(error)
473
+ }
474
+ const armIdle = (): void => {
475
+ clearIdle()
476
+ idleTimer = setTimeout(() => {
477
+ if (settled) return
478
+ const error = new Error(`remote write made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`)
479
+ fail(error)
480
+ try { writeStream?.destroy(error) } catch { /* already closed */ }
481
+ this.retireSharedSubsystem(sftp)
482
+ }, this.sftpOpts.sftpReadTimeoutMs)
483
+ idleTimer.unref?.()
484
+ }
485
+ writeStream.on('error', (error: Error) => { fail(error) })
486
+ writeStream.on('close', () => {
487
+ if (settled) return
488
+ settled = true
489
+ clearIdle()
490
+ resolve()
491
+ })
492
+
493
+ // Write sequential bounded chunks so each completed write is observable
494
+ // progress and refreshes the inactivity timer. There is deliberately no
495
+ // second absolute deadline: a slow transfer may continue while moving.
496
+ const writeNext = (offset: number): void => {
497
+ if (settled || writeStream === undefined) return
498
+ if (offset >= content.length) {
499
+ try { writeStream.end() } catch (error) { fail(error instanceof Error ? error : new Error(String(error))) }
500
+ return
501
+ }
502
+ const nextOffset = Math.min(offset + 64 * 1024, content.length)
503
+ try {
504
+ writeStream.write(content.subarray(offset, nextOffset), (error?: Error | null) => {
505
+ if (settled) return
506
+ if (error != null) {
507
+ fail(error)
508
+ return
509
+ }
510
+ armIdle()
511
+ queueMicrotask(() => { writeNext(nextOffset) })
512
+ })
513
+ } catch (error) {
514
+ fail(error instanceof Error ? error : new Error(String(error)))
515
+ }
516
+ }
517
+ armIdle()
518
+ writeNext(0)
519
+ })
520
+ const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote write result stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
521
+ return { mtime: attrs.mtime * 1000 }
522
+ } finally {
523
+ this.leave(entry)
524
+ }
525
+ }, { signal })
526
+ }
527
+
528
+ /** Create a remote directory chain (mkdir -p semantics). */
529
+ async mkdir(alias: string, remotePath: string, signal?: AbortSignal): Promise<void> {
530
+ return this.access.withClient(alias, async (client) => {
531
+ const sftp = await this.sftpFor(client)
532
+ await this.ensureRemoteDir(sftp, remotePath)
533
+ }, { signal })
534
+ }
535
+
536
+ /**
537
+ * Remove a remote file or directory. Directories require `recursive: true`
538
+ * and are walked depth-first (children first, then the directory itself).
539
+ *
540
+ * Deletion never follows symlinks: every node is classified with lstat, so
541
+ * a symlink pointing at a directory is unlinked (only the link), never
542
+ * recursed into — the old stat/readdir-attr check could delete the link
543
+ * target's contents.
544
+ */
545
+ async rm(alias: string, remotePath: string, recursive = false, signal?: AbortSignal): Promise<void> {
546
+ const candidatePath = remotePath.replace(/\/+$/, '')
547
+ if (remotePath === '' || candidatePath === '' || candidatePath === '/') {
548
+ throw new Error(`refusing to delete root path '${remotePath}'`)
549
+ }
550
+ return this.access.withClient(alias, async (client) => {
551
+ const sftp = await this.sftpFor(client)
552
+ // A recursive rm runs many requests on the shared subsystem: count it so a
553
+ // timeout marks the channel suspect instead of tearing it down under other
554
+ // in-flight SFTP work.
555
+ const entry = this.enter(sftp)
556
+ try {
557
+ const deadlineAt = Date.now() + this.sftpOpts.sftpRecursiveRmTimeoutMs
558
+ const remaining = (): number => {
559
+ const value = deadlineAt - Date.now()
560
+ if (value <= 0) {
561
+ this.retireSharedSubsystem(sftp)
562
+ throw new Error(`remote recursive rm timed out after ${this.sftpOpts.sftpRecursiveRmTimeoutMs}ms: ${remotePath}`)
563
+ }
564
+ return Math.min(value, this.sftpOpts.sftpOperationTimeoutMs)
565
+ }
566
+
567
+ // Inspect the leaf before canonicalizing: realpath follows a leaf
568
+ // symlink, which could turn `/link-to-dir` into its target and recurse
569
+ // through it. A leaf link is always unlinked by its original path.
570
+ const leafAttrs = await this.sftpLstat(sftp, candidatePath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
571
+ if (leafAttrs.isSymbolicLink()) {
572
+ await this.sftpUnlink(sftp, candidatePath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
573
+ return
574
+ }
575
+
576
+ // Canonicalize on this exact SFTP session before any destructive walk.
577
+ // This catches root-equivalent spellings such as `/.` and `/tmp/..`.
578
+ const realpathBudget = recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs
579
+ const canonical = await this.withSftpTimeout(sftp, new Promise<string>((resolve, reject) => {
580
+ sftp.realpath(candidatePath, (error, resolved) => error !== undefined ? reject(error) : resolve(resolved))
581
+ }), realpathBudget, `remote realpath timed out after ${realpathBudget}ms: ${candidatePath}`)
582
+ const canonicalPath = canonical.replace(/\/+$/, '') || '/'
583
+ if (canonicalPath === '/') {
584
+ throw new Error(`refusing to delete root-equivalent path '${remotePath}'`)
585
+ }
586
+
587
+ if (!leafAttrs.isDirectory()) {
588
+ await this.sftpUnlink(sftp, canonicalPath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
589
+ return
590
+ }
591
+ if (!recursive) throw new Error(`'${remotePath}' is a directory —pass recursive: true`)
592
+ const remove = async (dir: string): Promise<void> => {
593
+ const readBudget = remaining()
594
+ const list = await this.withSftpTimeout(sftp, new Promise<Array<{ filename: string }>>((resolve, reject) => {
595
+ sftp.readdir(dir, (error, entries) => error !== undefined ? reject(error) : resolve(entries))
596
+ }), readBudget, `remote readdir timed out after ${readBudget}ms: ${dir}`)
597
+ for (const entry of list) {
598
+ const child = dir.replace(/\/+$/, '') + '/' + entry.filename
599
+ const childAttrs = await this.sftpLstat(sftp, child, remaining())
600
+ if (childAttrs.isSymbolicLink() || !childAttrs.isDirectory()) {
601
+ await this.sftpUnlink(sftp, child, remaining())
602
+ } else {
603
+ await remove(child)
604
+ }
605
+ }
606
+ const removeBudget = remaining()
607
+ await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
608
+ sftp.rmdir(dir, (error) => error !== undefined ? reject(error) : resolve())
609
+ }), removeBudget, `remote rmdir timed out after ${removeBudget}ms: ${dir}`)
610
+ }
611
+ await remove(canonicalPath)
612
+ } finally {
613
+ this.leave(entry)
614
+ }
615
+ }, { signal })
616
+ }
617
+
618
+ /** Rename / move a remote path (mv semantics, same filesystem). */
619
+ async rename(alias: string, fromPath: string, toPath: string, signal?: AbortSignal): Promise<void> {
620
+ return this.access.withClient(alias, async (client) => {
621
+ const sftp = await this.sftpFor(client)
622
+ await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
623
+ sftp.rename(fromPath, toPath, (error) => error !== undefined ? reject(error) : resolve())
624
+ }), this.sftpOpts.sftpOperationTimeoutMs, `remote rename timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${fromPath} -> ${toPath}`)
625
+ }, { signal })
626
+ }
627
+
628
+ /** Reject a promise after `ms` (unref'd so it never keeps the process alive).
629
+ * `onTimeout` (when given) runs right before the rejection: ssh2 SFTP
630
+ * requests have no cancel API, so callers that hold an abort handle (e.g.
631
+ * a read stream) destroy it here — otherwise the underlying transfer would
632
+ * keep running (and, for reads, keep buffering) after the caller was told
633
+ * it timed out. */
634
+ withTimeout<T>(promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T> {
635
+ return new Promise<T>((resolve, reject) => {
636
+ let settled = false
637
+ const timer = setTimeout(() => {
638
+ if (settled) return
639
+ settled = true
640
+ try { onTimeout?.() } catch { /* best-effort abort */ }
641
+ reject(new Error(message))
642
+ }, ms)
643
+ timer.unref?.()
644
+ promise.then(
645
+ (value) => {
646
+ if (settled) return
647
+ settled = true
648
+ clearTimeout(timer)
649
+ resolve(value)
650
+ },
651
+ (error) => {
652
+ if (settled) return
653
+ settled = true
654
+ clearTimeout(timer)
655
+ reject(error)
656
+ },
657
+ )
658
+ })
659
+ }
660
+
661
+ /** Bound one SFTP request and retire the subsystem only when it is safe.
662
+ *
663
+ * Every request rides ONE cached subsystem channel per pooled client. Closing
664
+ * that channel on a single request's timeout used to destroy every concurrent
665
+ * SFTP operation on the same connection. A timeout therefore only MARKS the
666
+ * channel suspect; the decision to close it belongs to the drain path, which
667
+ * ends it exactly once the last in-flight request has settled — so a stalled
668
+ * callback cannot linger behind another lease, and a healthy concurrent request
669
+ * is never destroyed. */
670
+ withSftpTimeout<T>(
671
+ sftp: import('ssh2').SFTPWrapper,
672
+ promise: Promise<T>,
673
+ ms: number,
674
+ message: string,
675
+ onTimeout?: () => void,
676
+ ): Promise<T> {
677
+ const entry = this.enter(sftp)
678
+ return this.withTimeout(promise, ms, message, () => {
679
+ try { onTimeout?.() } catch { /* best-effort operation abort */ }
680
+ this.retireSharedSubsystem(sftp)
681
+ }).finally(() => { this.leave(entry) })
682
+ }
683
+
684
+ /** Count one operation against a cached subsystem's in-flight set. */
685
+ private enter(sftp: import('ssh2').SFTPWrapper): SftpCacheEntry | undefined {
686
+ const entry = this.entryFor(sftp)
687
+ if (entry !== undefined) entry.inFlight += 1
688
+ return entry
689
+ }
690
+
691
+ /** End one operation's count; rotate a suspect channel only once it drains. */
692
+ private leave(entry: SftpCacheEntry | undefined): void {
693
+ if (entry === undefined) return
694
+ entry.inFlight = Math.max(0, entry.inFlight - 1)
695
+ if (entry.inFlight === 0 && entry.suspect) this.rotateSuspect(entry)
696
+ }
697
+
698
+ /**
699
+ * Retire a subsystem after a timeout without destroying concurrent SFTP work.
700
+ *
701
+ * A cached channel is SHARED by every operation on the pooled client, so
702
+ * closing it here would fail whatever else is in flight. Instead it is marked
703
+ * suspect and rotated by `leave()` once the last operation finishes. Only a
704
+ * wrapper this service does not track (a direct caller) is ended immediately.
705
+ *
706
+ * @param sftp - the subsystem that timed out.
707
+ */
708
+ private retireSharedSubsystem(sftp: import('ssh2').SFTPWrapper): void {
709
+ const entry = this.entryFor(sftp)
710
+ if (entry === undefined) {
711
+ try { sftp.end() } catch { /* channel already closed */ }
712
+ return
713
+ }
714
+ entry.suspect = true
715
+ if (entry.inFlight === 0) this.rotateSuspect(entry)
716
+ }
717
+
718
+ /** The cache entry owning one resolved subsystem channel, if still cached. */
719
+ private entryFor(sftp: import('ssh2').SFTPWrapper): SftpCacheEntry | undefined {
720
+ for (const entry of this.cache.values()) {
721
+ if (entry.wrapper === sftp) return entry
722
+ }
723
+ return undefined
724
+ }
725
+
726
+ /** Drop a suspect channel once it has drained, so the next call reopens. */
727
+ private rotateSuspect(entry: SftpCacheEntry): void {
728
+ if (this.cache.get(entry.client) === entry) this.cache.delete(entry.client)
729
+ try { entry.wrapper?.end() } catch { /* already closed */ }
730
+ }
731
+
732
+ /** Stat wrapper (one SFTP stat call). */
733
+ sftpStat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
734
+ return this.withSftpTimeout(sftp, new Promise((resolve, reject) => {
735
+ sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
736
+ }), this.sftpOpts.sftpOperationTimeoutMs, `remote stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
737
+ }
738
+
739
+ /** Lstat wrapper (does NOT follow symlinks — the deletion safety gate). */
740
+ sftpLstat(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs = this.sftpOpts.sftpOperationTimeoutMs): Promise<import('ssh2').Stats> {
741
+ return this.withSftpTimeout(sftp, new Promise((resolve, reject) => {
742
+ sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
743
+ }), timeoutMs, `remote lstat timed out after ${timeoutMs}ms: ${remotePath}`)
744
+ }
745
+
746
+ /** Unlink wrapper with the same per-operation deadline as every other SFTP callback. */
747
+ sftpUnlink(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs = this.sftpOpts.sftpOperationTimeoutMs): Promise<void> {
748
+ return this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
749
+ sftp.unlink(remotePath, (error) => error !== undefined ? reject(error) : resolve())
750
+ }), timeoutMs, `remote unlink timed out after ${timeoutMs}ms: ${remotePath}`)
751
+ }
752
+
753
+ /**
754
+ * The (cached) SFTP channel for a pooled client. `Client.sftp()` opens a new
755
+ * subsystem channel per call, so this memoizes one channel per live client;
756
+ * when the channel closes the cache entry is dropped so the next call opens
757
+ * SFTP on the replacement connection. Failed opens are also evicted so a
758
+ * transient channel failure can be retried.
759
+ */
760
+ sftpFor(client: Client): Promise<import('ssh2').SFTPWrapper> {
761
+ const cached = this.cache.get(client)
762
+ if (cached !== undefined) return cached.promise
763
+
764
+ let resolveOpen!: (sftp: import('ssh2').SFTPWrapper) => void
765
+ let rejectOpen!: (error: Error) => void
766
+ let openSettled = false
767
+ let disposed = false
768
+ let timer: NodeJS.Timeout | undefined
769
+ const promise = new Promise<import('ssh2').SFTPWrapper>((resolve, reject) => {
770
+ resolveOpen = resolve
771
+ rejectOpen = reject
772
+ })
773
+ const entry: SftpCacheEntry = {
774
+ promise,
775
+ client,
776
+ inFlight: 0,
777
+ suspect: false,
778
+ cancel: (error) => {
779
+ if (disposed) return
780
+ disposed = true
781
+ if (timer !== undefined) clearTimeout(timer)
782
+ if (this.cache.get(client) === entry) this.cache.delete(client)
783
+ if (entry.wrapper !== undefined) {
784
+ try { entry.wrapper.end() } catch { /* already closed */ }
785
+ } else {
786
+ try { client.destroy() } catch { /* already closed */ }
787
+ }
788
+ if (!openSettled) {
789
+ openSettled = true
790
+ rejectOpen(error)
791
+ }
792
+ },
793
+ }
794
+ this.cache.set(client, entry)
795
+ timer = setTimeout(() => {
796
+ entry.cancel(new Error(`SFTP subsystem open timed out after ${this.sftpOpts.sftpOpenTimeoutMs}ms`))
797
+ }, this.sftpOpts.sftpOpenTimeoutMs)
798
+ timer.unref?.()
799
+
800
+ try {
801
+ client.sftp((error, sftp) => {
802
+ if (disposed || this.cache.get(client) !== entry) {
803
+ if (sftp !== undefined) {
804
+ try { sftp.end() } catch { /* stale late callback */ }
805
+ }
806
+ return
807
+ }
808
+ if (error !== undefined) {
809
+ entry.cancel(error instanceof Error ? error : new Error(String(error)))
810
+ return
811
+ }
812
+ openSettled = true
813
+ if (timer !== undefined) clearTimeout(timer)
814
+ entry.wrapper = sftp
815
+ const evict = (): void => {
816
+ if (this.cache.get(client) === entry) this.cache.delete(client)
817
+ }
818
+ sftp.once('close', evict)
819
+ sftp.once('error', evict)
820
+ resolveOpen(sftp)
821
+ })
822
+ } catch (error) {
823
+ entry.cancel(error instanceof Error ? error : new Error(String(error)))
824
+ }
825
+ return promise
826
+ }
827
+
828
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
829
+ async ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
830
+ const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
831
+ for (let index = 0; index < segments.length; index += 1) {
832
+ const current = '/' + segments.slice(0, index + 1).join('/')
833
+ try {
834
+ await this.sftpStat(sftp, current)
835
+ continue
836
+ } catch (error) {
837
+ // Only absence authorizes mkdir. Permission, timeout and transport
838
+ // failures must not be converted into an unintended remote mutation.
839
+ if (!isMissingSftpError(error)) throw error
840
+ }
841
+ try {
842
+ await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
843
+ sftp.mkdir(current, (error) => error !== undefined ? reject(error) : resolve())
844
+ }), this.sftpOpts.sftpOperationTimeoutMs, `remote mkdir timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${current}`)
845
+ } catch (error) {
846
+ const code = String((error as { code?: unknown } | undefined)?.code ?? '')
847
+ if (!/EEXIST|already exists/i.test(`${code} ${String(error)}`)) throw error
848
+ }
849
+ }
850
+ }
851
+
852
+ fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
853
+ const entry = this.enter(sftp)
854
+ return new Promise<void>((resolve, reject) => {
855
+ const tracker = createTransferProgressTracker(dst, statSync(src).size, onProgress)
856
+ let settled = false
857
+ let timer: NodeJS.Timeout | undefined
858
+ const arm = (): void => {
859
+ if (timer !== undefined) clearTimeout(timer)
860
+ timer = setTimeout(() => {
861
+ if (settled) return
862
+ settled = true
863
+ const error = new Error(`remote upload made no progress for ${this.sftpOpts.sftpTransferIdleTimeoutMs}ms: ${dst}`)
864
+ tracker.fail(error)
865
+ this.retireSharedSubsystem(sftp)
866
+ reject(error)
867
+ }, this.sftpOpts.sftpTransferIdleTimeoutMs)
868
+ timer.unref?.()
869
+ }
870
+ arm()
871
+ try {
872
+ sftp.fastPut(src, dst, {
873
+ concurrency: this.sftpOpts.sftpConcurrency,
874
+ step: (transferred: number, _chunk: number, total: number) => {
875
+ if (settled) return
876
+ arm()
877
+ tracker.step(transferred, total)
878
+ },
879
+ }, (error) => {
880
+ if (settled) return
881
+ settled = true
882
+ if (timer !== undefined) clearTimeout(timer)
883
+ if (error !== undefined) {
884
+ tracker.fail(error)
885
+ reject(error)
886
+ } else {
887
+ tracker.done()
888
+ resolve()
889
+ }
890
+ })
891
+ } catch (error) {
892
+ settled = true
893
+ if (timer !== undefined) clearTimeout(timer)
894
+ const failure = error instanceof Error ? error : new Error(String(error))
895
+ tracker.fail(failure)
896
+ reject(failure)
897
+ }
898
+ }).finally(() => { this.leave(entry) })
899
+ }
900
+
901
+ fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, initialTotal: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
902
+ const entry = this.enter(sftp)
903
+ return new Promise<void>((resolve, reject) => {
904
+ const tracker = createTransferProgressTracker(src, initialTotal, onProgress)
905
+ const removePartial = (): void => { try { unlinkSync(dst) } catch { /* absent or already removed */ } }
906
+ let settled = false
907
+ let timer: NodeJS.Timeout | undefined
908
+ const arm = (): void => {
909
+ if (timer !== undefined) clearTimeout(timer)
910
+ timer = setTimeout(() => {
911
+ if (settled) return
912
+ settled = true
913
+ const error = new Error(`remote download made no progress for ${this.sftpOpts.sftpTransferIdleTimeoutMs}ms: ${src}`)
914
+ tracker.fail(error)
915
+ removePartial()
916
+ this.retireSharedSubsystem(sftp)
917
+ reject(error)
918
+ }, this.sftpOpts.sftpTransferIdleTimeoutMs)
919
+ timer.unref?.()
920
+ }
921
+ arm()
922
+ try {
923
+ sftp.fastGet(src, dst, {
924
+ concurrency: this.sftpOpts.sftpConcurrency,
925
+ step: (transferred: number, _chunk: number, total: number) => {
926
+ if (settled) return
927
+ arm()
928
+ tracker.step(transferred, total)
929
+ },
930
+ }, (error) => {
931
+ if (settled) return
932
+ settled = true
933
+ if (timer !== undefined) clearTimeout(timer)
934
+ if (error !== undefined) {
935
+ tracker.fail(error)
936
+ removePartial()
937
+ reject(error)
938
+ } else {
939
+ tracker.done()
940
+ resolve()
941
+ }
942
+ })
943
+ } catch (error) {
944
+ settled = true
945
+ if (timer !== undefined) clearTimeout(timer)
946
+ const failure = error instanceof Error ? error : new Error(String(error))
947
+ tracker.fail(failure)
948
+ removePartial()
949
+ reject(failure)
950
+ }
951
+ }).finally(() => { this.leave(entry) })
952
+ }
953
+
954
+ /**
955
+ * Drop one client's cached channel. Wired as the connection pool's
956
+ * onDispose hook, so a torn-down client never keeps a half-open SFTP
957
+ * subsystem behind it.
958
+ */
959
+ onClientDisposed(client: Client, error: Error): void {
960
+ this.cache.get(client)?.cancel(error)
961
+ }
962
+
963
+ /** Drop every cached channel (engine dispose). */
964
+ dispose(error: Error): void {
965
+ for (const entry of [...this.cache.values()]) entry.cancel(error)
966
+ }
967
+ }