@tiphareth/dsh-hardssh 0.1.2 → 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 +1171 -1321
  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 +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +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 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/ssh/routes.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
11
11
  import { unlink } from 'node:fs/promises'
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { pipeline } from 'node:stream/promises'
13
14
  import { basename, join } from 'node:path'
14
15
  import { tmpdir } from 'node:os'
15
16
  import { randomBytes } from 'node:crypto'
@@ -20,6 +21,10 @@ import { SSH_API, type HostPayload, type ApiErrorBody, type TerminalClientFrame,
20
21
  import type { HostStore } from './store.ts'
21
22
  import { HostKeyMismatchError, HostKeyUnknownError, type KnownHostsStore } from './known-hosts.ts'
22
23
  import { NeedsPasswordError } from './engine.ts'
24
+ import { MIN_MASTER_PASSWORD_LENGTH } from './vault.ts'
25
+ // Shared host HTTP boundary (A-05): one loopback fence, one JSON body reader,
26
+ // one JSON writer for every host route family — copies drift apart silently.
27
+ import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyResult, writeJson } from '../host-http.ts'
23
28
 
24
29
  /** Cap on JSON request bodies (host entries and exec payloads are small). */
25
30
  const MAX_JSON_BODY_BYTES = 64 * 1024
@@ -27,13 +32,6 @@ const MAX_JSON_BODY_BYTES = 64 * 1024
27
32
  /** Cap on declared upload bodies (staged to disk before SFTP). */
28
33
  export const DEFAULT_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
29
34
 
30
- /**
31
- * One noServer WebSocket server for terminal upgrades: the browser half uses
32
- * a standards-compliant WebSocket, so the host must speak real RFC 6455
33
- * frames (the webserver hands us the raw upgraded socket).
34
- */
35
- const terminalWss = new WebSocketServer({ noServer: true })
36
-
37
35
  /** Pause the shell when the socket's send buffer exceeds this… */
38
36
  const BACKPRESSURE_HIGH_WATER = 1024 * 1024
39
37
 
@@ -45,10 +43,67 @@ type StageUploadResult =
45
43
  | { ok: true; receivedBytes: number }
46
44
  | { ok: false; reason: 'too-large' | 'aborted' | 'request-error' | 'write-error'; receivedBytes: number; error: Error }
47
45
 
46
+ /**
47
+ * One live terminal upgrade. The session is captured only once openShell
48
+ * resolves; `pending` lets teardown close a shell that is still opening.
49
+ */
50
+ interface TerminalEntry {
51
+ ws: WebSocket
52
+ session: ShellSession | undefined
53
+ pending: Promise<ShellSession>
54
+ }
55
+
48
56
  function toError(error: unknown): Error {
49
57
  return error instanceof Error ? error : new Error(String(error))
50
58
  }
51
59
 
60
+ /** Abort one HTTP operation when its caller disconnects. */
61
+ function requestAbort(req: IncomingMessage, res: ServerResponse): { signal: AbortSignal; dispose: () => void } {
62
+ const controller = new AbortController()
63
+ const abort = (message: string): void => {
64
+ if (controller.signal.aborted) return
65
+ controller.abort(Object.assign(new Error(message), { name: 'AbortError' }))
66
+ }
67
+ const onAborted = (): void => { abort('request aborted by the client') }
68
+ const onRequestClose = (): void => {
69
+ if (!req.complete) abort('request connection closed before completion')
70
+ }
71
+ const onResponseClose = (): void => {
72
+ if (!res.writableFinished) abort('response connection closed before completion')
73
+ }
74
+ req.once('aborted', onAborted)
75
+ req.once('close', onRequestClose)
76
+ res.once('close', onResponseClose)
77
+ return {
78
+ signal: controller.signal,
79
+ dispose: () => {
80
+ req.off('aborted', onAborted)
81
+ req.off('close', onRequestClose)
82
+ res.off('close', onResponseClose)
83
+ },
84
+ }
85
+ }
86
+
87
+ /** Strict runtime decoder for client terminal frames. */
88
+ function terminalClientFrame(value: unknown): TerminalClientFrame | undefined {
89
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
90
+ const frame = value as Record<string, unknown>
91
+ if (frame.type === 'input') {
92
+ if (typeof frame.data !== 'string') return undefined
93
+ if (Object.keys(frame).some(key => key !== 'type' && key !== 'data')) return undefined
94
+ return { type: 'input', data: frame.data }
95
+ }
96
+ if (frame.type === 'resize') {
97
+ if (!Number.isSafeInteger(frame.cols) || !Number.isSafeInteger(frame.rows)) return undefined
98
+ const cols = frame.cols as number
99
+ const rows = frame.rows as number
100
+ if (cols < 2 || cols > 1_000 || rows < 1 || rows > 1_000) return undefined
101
+ if (Object.keys(frame).some(key => key !== 'type' && key !== 'cols' && key !== 'rows')) return undefined
102
+ return { type: 'resize', cols, rows }
103
+ }
104
+ return undefined
105
+ }
106
+
52
107
  /** Idempotent staging-file cleanup (ENOENT is fine; other errors surface). */
53
108
  async function removeStagingFile(path: string): Promise<void> {
54
109
  try {
@@ -162,73 +217,40 @@ function rejectUploadTooLarge(req: IncomingMessage, res: ServerResponse): void {
162
217
  })
163
218
  }
164
219
 
165
- /** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
166
- function isLoopbackRequest(request: IncomingMessage): boolean {
167
- const address = request.socket.remoteAddress
168
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
169
- const host = request.headers.host
170
- if (typeof host !== 'string') return false
171
- let hostUrl: URL
172
- try {
173
- hostUrl = new URL(`http://${host}`)
174
- } catch {
175
- return false
176
- }
177
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
178
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
179
- const origin = request.headers.origin
180
- if (origin === undefined) return true
181
- try {
182
- return new URL(origin).host === hostUrl.host
183
- } catch {
184
- return false
185
- }
186
- }
187
-
188
- /** One JSON response. */
189
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
190
- const payload = JSON.stringify(body)
191
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
192
- res.end(payload)
193
- }
194
-
195
- /** Map a thrown error to a structured ApiErrorBody (host-key errors carry
196
- * machine codes + fingerprints for the GUI's confirm dialog). */
197
- function errorBody(error: unknown): ApiErrorBody {
220
+ /**
221
+ * THE error mapper for the SSH route family (B-14): one status policy and one
222
+ * body shape everywhere, so identical auth/TOFU failures are recognizable by
223
+ * status + code no matter which endpoint produced them.
224
+ *
225
+ * Status policy (a single status per code, never 2xx):
226
+ * - 500 — interactive gates (NEEDS_PASSWORD / HOST_KEY_UNKNOWN /
227
+ * HOST_KEY_MISMATCH), so a client's `readJson` on 2xx can never mis-parse a
228
+ * structured error as a successful result — and every other failure.
229
+ * - 400/404/409/413/423/429 are reserved for the request/vault-specific
230
+ * checks each route performs BEFORE calling the engine.
231
+ */
232
+ function errorBody(error: unknown, redact?: (text: string) => string): ApiErrorBody {
233
+ const safe = (text: string): string => redact?.(text) ?? text
198
234
  if (error instanceof HostKeyUnknownError) {
199
- return { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
235
+ return { error: safe(error.message), code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
200
236
  }
201
237
  if (error instanceof HostKeyMismatchError) {
202
- return { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
238
+ return { error: safe(error.message), code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
203
239
  }
204
240
  if (error instanceof NeedsPasswordError) {
205
- return { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret }
241
+ return { error: safe(error.message), code: 'NEEDS_PASSWORD', secret: error.secret }
206
242
  }
207
- return { error: error instanceof Error ? error.message : String(error) }
243
+ return { error: safe(error instanceof Error ? error.message : String(error)) }
208
244
  }
209
245
 
210
- /** Read a JSON request body (undefined when too large or unparseable). */
246
+ /**
247
+ * Read a JSON request body, collapsing every failure (too large, malformed,
248
+ * non-object) to `undefined` — the call sites only distinguish "no usable
249
+ * body" and keep their own 400 text, matching the historical local reader.
250
+ */
211
251
  async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
212
- const chunks: Buffer[] = []
213
- let size = 0
214
- for await (const chunk of req) {
215
- const buffer = chunk as Buffer
216
- size += buffer.length
217
- if (size > MAX_JSON_BODY_BYTES) return undefined
218
- chunks.push(buffer)
219
- }
220
- try {
221
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
222
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
223
- } catch {
224
- return undefined
225
- }
226
- }
227
-
228
- /** URL query helper (first value, decoded). */
229
- function queryParam(url: URL, name: string): string | undefined {
230
- const value = url.searchParams.get(name)
231
- return value === null ? undefined : value
252
+ const result = await readJsonBodyResult(req, MAX_JSON_BODY_BYTES)
253
+ return result.ok ? result.body : undefined
232
254
  }
233
255
 
234
256
  /** Route family dependencies. */
@@ -241,8 +263,8 @@ export interface SshRoutesDeps {
241
263
  knownHosts?: KnownHostsStore
242
264
  /** Credential vault (optional; enables /api/dsh-ssh/vault endpoints). */
243
265
  vault?: import('./vault.ts').Vault
244
- /** SSH-bound workspace ledger (host-delete reference check). */
245
- ledger?: import('../ledger.ts').SshWorkspaceLedger
266
+ /** SSH-bound workspace record source (host-delete reference check). */
267
+ ledger?: import('../backend.ts').WorkspaceStoreView
246
268
  /** Temp dir for upload/download staging (tests inject a sandbox). */
247
269
  stagingDir?: string
248
270
  /** Hard cap on ACTUAL upload bytes accepted (chunked requests included).
@@ -253,13 +275,30 @@ export interface SshRoutesDeps {
253
275
  onHostInvalidated?: (alias: string) => void
254
276
  }
255
277
 
278
+ /** Every /api/dsh-ssh route plus the terminal upgrade, owned by one call. */
279
+ export interface SshRoutes {
280
+ routes: WebRoute[]
281
+ upgrade: WebUpgradeRoute
282
+ /**
283
+ * Release this instance's terminal resources: refuse new upgrades, close
284
+ * every live terminal session (and its SSH shell), close the WebSocketServer.
285
+ * Idempotent — a second call resolves without touching anything.
286
+ */
287
+ dispose: () => Promise<void>
288
+ }
289
+
256
290
  /**
257
291
  * Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
258
292
  * @param deps - store, engine, staging dir.
259
- * @returns routes and the upgrade route.
293
+ * @returns routes, the upgrade route, and this instance's disposer.
260
294
  */
261
- export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
295
+ export function makeRoutes(deps: SshRoutesDeps): SshRoutes {
262
296
  const { store, engine } = deps
297
+ /** Every route error string passes the engine leak guard. Tolerates engine
298
+ * doubles that predate the redactor (tests inject minimal fakes). */
299
+ const redact = typeof engine.redact === 'function' ? (text: string): string => engine.redact(text) : (text: string): string => text
300
+ const safeMessage = (error: unknown): string => redact(error instanceof Error ? error.message : String(error))
301
+ const safeErrorBody = (error: unknown): ApiErrorBody => errorBody(error, redact)
263
302
  const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
264
303
  const uploadLimitBytes = deps.uploadLimitBytes ?? DEFAULT_MAX_UPLOAD_BYTES
265
304
  if (!Number.isSafeInteger(uploadLimitBytes) || uploadLimitBytes < 0) {
@@ -269,6 +308,45 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
269
308
  // first request (a missing dir would hang the first upload forever).
270
309
  mkdirSync(staging, { recursive: true })
271
310
 
311
+ // ------------------------------------------------- terminal (upgrade)
312
+ // Instance-owned, NOT module-level: a module-level server would outlive its
313
+ // plugin instance, so HMR/re-apply would leave the old terminal sockets and
314
+ // SSH shells alive with no owner able to close them (A-06).
315
+ const terminalWss = new WebSocketServer({ noServer: true })
316
+ /** Live terminals so teardown can close sessions the sockets still own. */
317
+ const terminals = new Set<TerminalEntry>()
318
+ let disposed = false
319
+ let disposePromise: Promise<void> | undefined
320
+
321
+ const dispose = (): Promise<void> => {
322
+ disposePromise ??= (async (): Promise<void> => {
323
+ disposed = true
324
+ const live = [...terminals]
325
+ // A session may still be opening (openShell in flight): close it as
326
+ // soon as it lands so teardown does not leave an orphan SSH shell.
327
+ const opening = await Promise.allSettled(live.map(entry => entry.pending))
328
+ for (const settled of opening) {
329
+ if (settled.status === 'fulfilled') settled.value.close()
330
+ }
331
+ for (const entry of live) {
332
+ const session = entry.session
333
+ entry.session = undefined
334
+ if (session !== undefined) session.close()
335
+ const ws = entry.ws
336
+ try {
337
+ if (ws.readyState === WebSocket.OPEN) ws.close(1001, 'terminal server disposed')
338
+ else if (ws.readyState !== WebSocket.CLOSED) ws.terminate()
339
+ } catch {
340
+ // Socket already gone: nothing left to close.
341
+ }
342
+ }
343
+ await new Promise<void>((resolve) => {
344
+ terminalWss.close(() => resolve())
345
+ })
346
+ })()
347
+ return disposePromise
348
+ }
349
+
272
350
  /** Guard helper: fence + method check. */
273
351
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
274
352
  if (!isLoopbackRequest(req)) {
@@ -310,7 +388,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
310
388
  const entry = await store.create(body as unknown as HostPayload)
311
389
  writeJson(res, 201, { host: store.summarize(entry) })
312
390
  } catch (error) {
313
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
391
+ writeJson(res, 400, { error: safeMessage(error) })
314
392
  }
315
393
  return
316
394
  }
@@ -340,7 +418,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
340
418
  deps.onHostInvalidated?.(alias)
341
419
  writeJson(res, 200, { host: store.summarize(entry) })
342
420
  } catch (error) {
343
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
421
+ writeJson(res, 400, { error: safeMessage(error) })
344
422
  }
345
423
  return
346
424
  }
@@ -349,7 +427,20 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
349
427
  // not be deleted (the workspaces would silently lose their
350
428
  // transport). Return 409 with the referencing workspace titles.
351
429
  if (deps.ledger !== undefined) {
352
- const referenced = (await deps.ledger.list()).filter(record => record.alias === alias)
430
+ let referenced: import('../protocol.ts').SshWorkspaceRecord[]
431
+ try {
432
+ referenced = (await deps.ledger.list()).filter(record => record.alias === alias)
433
+ } catch (error) {
434
+ // The generic workspace store can reject when the runtime failed
435
+ // to initialize (corrupt ledger). Deleting the host then could
436
+ // strand live SSH workspaces, so the guard refuses instead of
437
+ // guessing — fail closed.
438
+ writeJson(res, 409, {
439
+ error: `cannot verify references for alias '${alias}' because the workspace runtime is unavailable: ${safeMessage(error)}`,
440
+ code: 'HOST_IN_USE_GUARD_UNAVAILABLE',
441
+ })
442
+ return
443
+ }
353
444
  if (referenced.length > 0) {
354
445
  writeJson(res, 409, {
355
446
  error: `alias '${alias}' is still referenced by ${referenced.length} SSH workspace(s) (${referenced.map(r => r.title).join('、')}) — delete those workspaces first`,
@@ -370,7 +461,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
370
461
  deps.onHostInvalidated?.(alias)
371
462
  writeJson(res, 200, { ok: true })
372
463
  } catch (error) {
373
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
464
+ writeJson(res, 400, { error: safeMessage(error) })
374
465
  }
375
466
  return
376
467
  }
@@ -383,9 +474,10 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
383
474
  handler: async (req, res) => {
384
475
  if (!guard(req, res, 'POST')) return
385
476
  try {
386
- writeJson(res, 200, { result: store.importFromSshConfig() })
477
+ const result = await store.importFromSshConfig()
478
+ writeJson(res, 200, { result })
387
479
  } catch (error) {
388
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
480
+ writeJson(res, 500, { error: safeMessage(error) })
389
481
  }
390
482
  },
391
483
  },
@@ -434,11 +526,14 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
434
526
  return
435
527
  }
436
528
  try {
437
- if (action === 'trust') knownHosts.trust(alias)
529
+ if (action === 'trust') {
530
+ const entry = store.find(alias)
531
+ knownHosts.trust(alias, entry === undefined ? undefined : { host: entry.host, port: entry.port })
532
+ }
438
533
  else knownHosts.forget(alias)
439
534
  writeJson(res, 200, { ok: true })
440
535
  } catch (error) {
441
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
536
+ writeJson(res, 400, { error: safeMessage(error) })
442
537
  }
443
538
  },
444
539
  },
@@ -474,24 +569,41 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
474
569
  }
475
570
  if (action === 'unlock') {
476
571
  const password = typeof body?.password === 'string' ? body.password : ''
572
+ if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
573
+ writeJson(res, 400, { error: `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters` })
574
+ return
575
+ }
477
576
  await vault.unlock(password)
478
577
  writeJson(res, 200, { ok: true, status: vault.status() })
479
578
  return
480
579
  }
481
580
  if (action === 'rekey') {
482
581
  const password = typeof body?.password === 'string' ? body.password : ''
582
+ if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
583
+ writeJson(res, 400, { error: `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters` })
584
+ return
585
+ }
483
586
  await vault.rekey(password)
484
587
  writeJson(res, 200, { ok: true, status: vault.status() })
485
588
  return
486
589
  }
487
590
  writeJson(res, 400, { error: 'action must be status|unlock|rekey' })
488
591
  } catch (error) {
489
- // Vault errors carry stable codes (VAULT_LOCKED / VAULT_AUTH /
490
- // VAULT_LOCKOUT) + retry info; surface the message + code.
491
- const record = error as { code?: unknown }
492
- writeJson(res, 401, {
493
- error: error instanceof Error ? error.message : String(error),
494
- code: typeof record.code === 'string' ? record.code : undefined,
592
+ // Vault errors carry stable codes + retry info; the HTTP status must
593
+ // distinguish a wrong password (401) from a lockout (429), a locked
594
+ // vault (423), a weak password (400) and an unexpected failure (500).
595
+ const record = error as { code?: unknown; remaining?: unknown; retryAfterMs?: unknown }
596
+ const code = typeof record.code === 'string' ? record.code : undefined
597
+ const status = code === 'VAULT_AUTH' ? 401
598
+ : code === 'VAULT_LOCKOUT' ? 429
599
+ : code === 'VAULT_LOCKED' ? 423
600
+ : code === 'VAULT_PASSWORD_WEAK' ? 400
601
+ : code === undefined ? 500 : 500
602
+ writeJson(res, status, {
603
+ error: safeMessage(error),
604
+ code,
605
+ ...(typeof record.remaining === 'number' ? { remaining: record.remaining } : {}),
606
+ ...(typeof record.retryAfterMs === 'number' ? { retryAfterMs: record.retryAfterMs } : {}),
495
607
  })
496
608
  }
497
609
  },
@@ -545,25 +657,27 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
545
657
  path: SSH_API.test,
546
658
  handler: async (req, res) => {
547
659
  if (!guard(req, res, 'POST')) return
548
- const body = await readJsonBody(req)
549
- const alias = typeof body?.alias === 'string' ? body.alias : ''
550
- if (alias === '') {
551
- writeJson(res, 400, { error: 'alias is required' })
552
- return
553
- }
660
+ const request = requestAbort(req, res)
554
661
  try {
555
- writeJson(res, 200, { result: await engine.test(alias) })
662
+ const body = await readJsonBody(req)
663
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
664
+ if (alias === '') {
665
+ writeJson(res, 400, { error: 'alias is required' })
666
+ return
667
+ }
668
+ writeJson(res, 200, { result: await engine.test(alias, request.signal) })
556
669
  } catch (error) {
557
670
  if (error instanceof NeedsPasswordError) {
558
- // Not a failure: the connection needs a credential the user
559
- // hasn't entered this session yet. Return a structured prompt
560
- // (200) so the GUI can show the password dialog and retry.
561
- writeJson(res, 200, {
562
- result: { ok: false, error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret },
563
- })
671
+ // The ONLY intentional 2xx difference: /test is a diagnostic the
672
+ // GUI polls, so a missing credential is reported as a result the
673
+ // user can act on. The body still comes from the shared mapper
674
+ // (same code/secret fields as every other route's 500).
675
+ writeJson(res, 200, { result: { ok: false, ...safeErrorBody(error) } })
564
676
  return
565
677
  }
566
- writeJson(res, 500, errorBody(error))
678
+ writeJson(res, 500, safeErrorBody(error))
679
+ } finally {
680
+ request.dispose()
567
681
  }
568
682
  },
569
683
  },
@@ -572,18 +686,23 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
572
686
  path: SSH_API.exec,
573
687
  handler: async (req, res) => {
574
688
  if (!guard(req, res, 'POST')) return
575
- const body = await readJsonBody(req)
576
- const alias = typeof body?.alias === 'string' ? body.alias : ''
577
- const command = typeof body?.command === 'string' ? body.command : ''
578
- if (alias === '' || command === '') {
579
- writeJson(res, 400, { error: 'alias and command are required' })
580
- return
581
- }
582
- const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
689
+ const request = requestAbort(req, res)
583
690
  try {
584
- writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
691
+ const body = await readJsonBody(req)
692
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
693
+ const command = typeof body?.command === 'string' ? body.command : ''
694
+ if (alias === '' || command === '') {
695
+ writeJson(res, 400, { error: 'alias and command are required' })
696
+ return
697
+ }
698
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
699
+ // A client disconnect aborts the REMOTE command instead of letting it
700
+ // run out its full budget on the server.
701
+ writeJson(res, 200, { result: await engine.exec(alias, command, { timeoutMs, signal: request.signal }) })
585
702
  } catch (error) {
586
- writeJson(res, 500, errorBody(error))
703
+ writeJson(res, 500, safeErrorBody(error))
704
+ } finally {
705
+ request.dispose()
587
706
  }
588
707
  },
589
708
  },
@@ -592,21 +711,26 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
592
711
  path: SSH_API.cluster,
593
712
  handler: async (req, res) => {
594
713
  if (!guard(req, res, 'POST')) return
595
- const body = await readJsonBody(req)
596
- const command = typeof body?.command === 'string' ? body.command : ''
597
- if (command === '') {
598
- writeJson(res, 400, { error: 'command is required' })
599
- return
600
- }
601
- const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
602
- const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
603
- const environment = typeof body?.environment === 'string' ? body.environment : undefined
604
- const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
605
- const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
714
+ const request = requestAbort(req, res)
606
715
  try {
607
- writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
716
+ const body = await readJsonBody(req)
717
+ const command = typeof body?.command === 'string' ? body.command : ''
718
+ if (command === '') {
719
+ writeJson(res, 400, { error: 'command is required' })
720
+ return
721
+ }
722
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
723
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
724
+ const environment = typeof body?.environment === 'string' ? body.environment : undefined
725
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
726
+ const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
727
+ writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers, signal: request.signal }) })
608
728
  } catch (error) {
609
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
729
+ // Same mapper as /test and /exec: a failure to even start the run
730
+ // must carry the interactive code, not a flattened message.
731
+ writeJson(res, 500, safeErrorBody(error))
732
+ } finally {
733
+ request.dispose()
610
734
  }
611
735
  },
612
736
  },
@@ -616,17 +740,29 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
616
740
  path: SSH_API.ls,
617
741
  handler: async (req, res) => {
618
742
  if (!guard(req, res, 'GET')) return
619
- const url = new URL(req.url ?? '/', 'http://localhost')
620
- const alias = queryParam(url, 'alias')
621
- const path = queryParam(url, 'path') ?? '/'
622
- if (alias === undefined || alias === '') {
623
- writeJson(res, 400, { error: 'alias query parameter is required' })
624
- return
625
- }
743
+ const request = requestAbort(req, res)
626
744
  try {
627
- writeJson(res, 200, { entries: await engine.ls(alias, path) })
745
+ const url = new URL(req.url ?? '/', 'http://localhost')
746
+ const alias = queryParam(url, 'alias')
747
+ const requested = queryParam(url, 'path') ?? '/'
748
+ if (alias === undefined || alias === '') {
749
+ writeJson(res, 400, { error: 'alias query parameter is required' })
750
+ return
751
+ }
752
+ // Same fence as the workspace listing route: an SFTP path is a POSIX
753
+ // absolute path, so NUL, backslashes and relative forms are rejected
754
+ // before they reach the remote filesystem.
755
+ if (requested.includes('\0') || requested.includes('\\') || !requested.startsWith('/')) {
756
+ writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requested}')` })
757
+ return
758
+ }
759
+ writeJson(res, 200, { entries: await engine.ls(alias, requested, request.signal) })
628
760
  } catch (error) {
629
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
761
+ // Was a flattened 400 string, which lost NEEDS_PASSWORD / TOFU codes
762
+ // the GUI needs to prompt; the shared mapper (500) restores them.
763
+ writeJson(res, 500, safeErrorBody(error))
764
+ } finally {
765
+ request.dispose()
630
766
  }
631
767
  },
632
768
  },
@@ -657,7 +793,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
657
793
  })
658
794
  writeJson(res, 200, { tunnel })
659
795
  } catch (error) {
660
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
796
+ writeJson(res, 500, { error: safeMessage(error) })
661
797
  }
662
798
  return
663
799
  }
@@ -697,11 +833,21 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
697
833
  const contentLengthHeader = req.headers['content-length']
698
834
  const declaredBytes = typeof contentLengthHeader === 'string' ? Number(contentLengthHeader) : Number.NaN
699
835
  if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimitBytes) {
700
- writeJson(res, 413, { error: 'upload body too large' })
836
+ // Same policy as the actual-bytes branch (B-13): without
837
+ // Connection: close + socket destroy the sender keeps streaming a
838
+ // body this route will never read, so the endpoint's connection
839
+ // behavior would depend on whether Content-Length was declared.
840
+ rejectUploadTooLarge(req, res)
701
841
  return
702
842
  }
703
843
 
704
- const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
844
+ const suffix = randomBytes(6).toString('hex')
845
+ const tmp = join(staging, `upload-${suffix}`)
846
+ // Upload into a sibling partial first. Only rename publishes the target,
847
+ // giving the route a real commit point and a safe artifact to clean up
848
+ // when the browser disconnects during the long SFTP write.
849
+ const remotePartial = `${remotePath}.dsh-upload-${suffix}.partial`
850
+ const request = requestAbort(req, res)
705
851
 
706
852
  // Stage and validate the WHOLE body before starting the NDJSON 200 —
707
853
  // over-limit bodies get a real HTTP 413, not a failure frame inside
@@ -709,6 +855,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
709
855
  const staged = await stageUploadBody(req, tmp, uploadLimitBytes)
710
856
 
711
857
  if (!staged.ok) {
858
+ request.dispose()
712
859
  if (staged.reason === 'too-large') {
713
860
  if (!res.headersSent) {
714
861
  rejectUploadTooLarge(req, res)
@@ -717,9 +864,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
717
864
  }
718
865
  return
719
866
  }
720
- if (!res.headersSent) {
721
- writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: staged.error.message })
722
- } else {
867
+ if (!res.headersSent && !res.destroyed) {
868
+ writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: redact(staged.error.message) })
869
+ } else if (!res.destroyed) {
723
870
  res.destroy(staged.error)
724
871
  }
725
872
  return
@@ -732,6 +879,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
732
879
  })
733
880
 
734
881
  let responseClosed = false
882
+ let remoteUploadStarted = false
883
+ let commitStarted = false
884
+ let committed = false
735
885
  const emit = (line: unknown): void => {
736
886
  if (responseClosed || res.destroyed || res.writableEnded) return
737
887
  try { res.write(`${JSON.stringify(line)}\n`) } catch { responseClosed = true }
@@ -740,14 +890,44 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
740
890
  res.once('close', onResponseClose)
741
891
 
742
892
  try {
893
+ request.signal.throwIfAborted()
743
894
  emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: staged.receivedBytes, percent: 0 } })
744
- const outcome = await engine.upload(alias, tmp, remotePath, false, progress => emit({ type: 'progress', progress }))
895
+ remoteUploadStarted = true
896
+ const outcome = await engine.upload(alias, tmp, remotePartial, false, progress => {
897
+ emit({ type: 'progress', progress: { ...progress, file: remotePath } })
898
+ }, request.signal)
899
+ request.signal.throwIfAborted()
900
+ // Tell the client that any transport loss after this frame has true
901
+ // result-unknown semantics: rename may publish even if its reply is
902
+ // lost. Before this point the final destination is untouched.
903
+ commitStarted = true
904
+ emit({ type: 'commit' })
905
+ await engine.rename(alias, remotePartial, remotePath, request.signal)
906
+ committed = true
745
907
  emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
746
908
  } catch (error) {
747
- emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
909
+ const body = request.signal.aborted
910
+ ? commitStarted
911
+ ? { error: 'upload connection closed after commit began; the remote result is unknown', code: 'RESULT_UNKNOWN' }
912
+ : { error: 'upload aborted before commit; the remote destination was not published', code: 'ABORTED' }
913
+ : safeErrorBody(error)
914
+ emit({
915
+ type: 'result',
916
+ ok: false,
917
+ error: body.error,
918
+ ...(body.code !== undefined ? { code: body.code } : {}),
919
+ ...('secret' in body && body.secret !== undefined ? { secret: body.secret } : {}),
920
+ ...('hostKeyFingerprint' in body && body.hostKeyFingerprint !== undefined ? { hostKeyFingerprint: body.hostKeyFingerprint } : {}),
921
+ })
748
922
  } finally {
749
- // engine.upload() has settled (read handle released) — safe to unlink.
923
+ // Abort may make engine.upload/rename reject before ssh2's late
924
+ // callback. Retiring the connection plus best-effort removal ensures
925
+ // neither local staging nor a remote partial remains owned by route.
926
+ if (remoteUploadStarted && !committed) {
927
+ await engine.rm(alias, remotePartial, false).catch(() => undefined)
928
+ }
750
929
  await removeStagingFile(tmp).catch(() => undefined)
930
+ request.dispose()
751
931
  res.off('close', onResponseClose)
752
932
  if (!responseClosed && !res.destroyed && !res.writableEnded) {
753
933
  try { res.end() } catch { /* client gone */ }
@@ -769,31 +949,34 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
769
949
  return
770
950
  }
771
951
  const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
952
+ const request = requestAbort(req, res)
772
953
  try {
773
- const outcome = await engine.download(alias, remotePath, tmp)
954
+ const outcome = await engine.download(alias, remotePath, tmp, undefined, request.signal)
955
+ request.signal.throwIfAborted()
774
956
  res.writeHead(200, {
775
957
  'content-type': 'application/octet-stream',
776
958
  'content-length': String(outcome.bytes),
777
959
  'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
778
960
  'referrer-policy': 'no-referrer',
779
961
  })
780
- await new Promise<void>((resolve, reject) => {
781
- const source = createReadStream(tmp)
782
- source.on('error', reject)
783
- res.on('error', reject)
784
- source.pipe(res)
785
- source.on('end', resolve)
786
- })
962
+ // pipeline destroys the local source when the response errors/closes;
963
+ // passing the same signal also interrupts an otherwise idle source.
964
+ await pipeline(createReadStream(tmp), res, { signal: request.signal })
787
965
  } catch (error) {
788
- if (!res.headersSent) {
789
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
790
- } else {
966
+ if (!res.headersSent && !res.destroyed) {
967
+ // Same structured body (code + secret/fingerprint) as the JSON
968
+ // routes, so an interactive gate is still actionable here (B-14).
969
+ writeJson(res, request.signal.aborted ? 499 : 502, request.signal.aborted
970
+ ? { error: 'download aborted by the client', code: 'ABORTED' }
971
+ : safeErrorBody(error))
972
+ } else if (!res.destroyed) {
791
973
  // Mid-stream failure after headers: destroy so the browser does
792
974
  // not hang waiting for the promised content-length bytes.
793
- res.destroy()
975
+ res.destroy(toError(error))
794
976
  }
795
977
  } finally {
796
- await unlink(tmp).catch(() => undefined)
978
+ request.dispose()
979
+ await removeStagingFile(tmp).catch(() => undefined)
797
980
  }
798
981
  },
799
982
  },
@@ -803,6 +986,12 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
803
986
  const upgrade: WebUpgradeRoute = {
804
987
  path: SSH_API.terminal,
805
988
  handler: (req, socket, head) => {
989
+ if (disposed) {
990
+ // Teardown ran: this instance must not own a new terminal session.
991
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
992
+ socket.destroy()
993
+ return
994
+ }
806
995
  if (!isLoopbackRequest(req)) {
807
996
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
808
997
  socket.destroy()
@@ -818,75 +1007,118 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
818
1007
  const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
819
1008
  const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
820
1009
  terminalWss.handleUpgrade(req, socket, head, (ws) => {
1010
+ // Promise.resolve also captures a non-conforming engine double that
1011
+ // throws synchronously from openShell().
1012
+ const pending = Promise.resolve().then(() => engine.openShell(alias, {
1013
+ cols: Number.isFinite(cols) ? cols : 80,
1014
+ rows: Number.isFinite(rows) ? rows : 24,
1015
+ }))
1016
+ const entry: TerminalEntry = { ws, session: undefined, pending }
1017
+ terminals.add(entry)
821
1018
  let session: ShellSession | undefined
822
1019
  let closed = false
823
1020
  let paused = false
1021
+ const closeSession = (): void => {
1022
+ const opened = session
1023
+ session = undefined
1024
+ entry.session = undefined
1025
+ if (opened !== undefined) {
1026
+ try { opened.close() } catch { /* already closed */ }
1027
+ }
1028
+ }
1029
+ const closeSocket = (code: number, reason: string): void => {
1030
+ if (closed) return
1031
+ closed = true
1032
+ closeSession()
1033
+ try {
1034
+ if (ws.readyState === WebSocket.OPEN) ws.close(code, reason)
1035
+ else if (ws.readyState !== WebSocket.CLOSED) ws.terminate()
1036
+ } catch {
1037
+ try { ws.terminate() } catch { /* socket already gone */ }
1038
+ }
1039
+ }
824
1040
  // Resume the shell once the socket's send buffer drains below the
825
1041
  // low-water mark (transport backpressure).
826
1042
  const resume = (): void => {
827
- if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
828
- paused = false
829
- session?.resume()
1043
+ try {
1044
+ if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
1045
+ paused = false
1046
+ session?.resume()
1047
+ }
1048
+ } catch {
1049
+ closeSocket(1011, 'terminal transport failure')
830
1050
  }
831
1051
  }
832
1052
  const sendFrame = (frame: TerminalServerFrame): void => {
833
1053
  if (closed || ws.readyState !== WebSocket.OPEN) return
834
- ws.send(JSON.stringify(frame), resume)
835
- if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
836
- paused = true
837
- session?.pause()
1054
+ try {
1055
+ ws.send(JSON.stringify(frame), resume)
1056
+ if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
1057
+ paused = true
1058
+ session?.pause()
1059
+ }
1060
+ } catch {
1061
+ closeSocket(1011, 'terminal transport failure')
838
1062
  }
839
1063
  }
840
- const closeSession = (): void => {
841
- const opened = session
842
- session = undefined
843
- if (opened !== undefined) opened.close()
844
- }
845
- engine.openShell(alias, {
846
- cols: Number.isFinite(cols) ? cols : 80,
847
- rows: Number.isFinite(rows) ? rows : 24,
848
- }).then((opened) => {
849
- if (ws.readyState !== WebSocket.OPEN) {
1064
+ pending.then((opened) => {
1065
+ if (closed || ws.readyState !== WebSocket.OPEN) {
850
1066
  opened.close()
851
1067
  return
852
1068
  }
853
1069
  session = opened
1070
+ entry.session = opened
854
1071
  sendFrame({ type: 'ready', alias })
855
- opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
1072
+ // PTY output and exit details cross the credential boundary here, so
1073
+ // this route is the single redaction choke point for the terminal
1074
+ // (the service below streams raw bytes for any consumer). Redaction
1075
+ // is exact-match over known secrets: it is best-effort and may change
1076
+ // the displayed byte length, which is an accepted trade-off.
1077
+ opened.onData = (data) => sendFrame({ type: 'output', data: redact(data.toString('utf8')) })
856
1078
  opened.onExit = (code, error) => {
857
- sendFrame({ type: 'exit', code, error })
858
- closed = true
859
- try { ws.close(1000) } catch { /* already closed */ }
1079
+ sendFrame({ type: 'exit', code, error: error === undefined ? undefined : redact(error) })
1080
+ closeSocket(1000, 'terminal exited')
860
1081
  }
861
1082
  }).catch((error) => {
862
- sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
863
- closed = true
864
- try { ws.close(1000) } catch { /* already closed */ }
1083
+ sendFrame({ type: 'exit', code: null, error: safeMessage(error) })
1084
+ closeSocket(1011, 'terminal setup failed')
865
1085
  })
866
- ws.on('message', (data) => {
867
- let frame: TerminalClientFrame
1086
+ ws.on('message', (data, isBinary) => {
1087
+ // The entire EventEmitter callback is a no-throw boundary. Invalid
1088
+ // protocol affects this one socket only; it must never escape to the
1089
+ // host event loop and take down unrelated sessions.
868
1090
  try {
869
- frame = JSON.parse(String(data)) as TerminalClientFrame
1091
+ if (isBinary) {
1092
+ closeSocket(1003, 'binary terminal frames are unsupported')
1093
+ return
1094
+ }
1095
+ const frame = terminalClientFrame(JSON.parse(String(data)) as unknown)
1096
+ if (frame === undefined) {
1097
+ closeSocket(1008, 'invalid terminal protocol frame')
1098
+ return
1099
+ }
1100
+ if (session === undefined) {
1101
+ closeSocket(1008, 'terminal is not ready')
1102
+ return
1103
+ }
1104
+ if (frame.type === 'input') session.send(frame.data)
1105
+ else session.resize(frame.cols, frame.rows)
870
1106
  } catch {
871
- return
872
- }
873
- if (frame.type === 'input') {
874
- session?.send(frame.data)
875
- } else if (frame.type === 'resize') {
876
- session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
1107
+ closeSocket(1008, 'invalid terminal protocol frame')
877
1108
  }
878
1109
  })
879
1110
  ws.on('close', () => {
880
1111
  closed = true
881
1112
  closeSession()
1113
+ terminals.delete(entry)
882
1114
  })
883
1115
  ws.on('error', () => {
884
- closed = true
885
- closeSession()
1116
+ closeSocket(1011, 'terminal socket error')
1117
+ terminals.delete(entry)
886
1118
  })
887
1119
  })
888
1120
  },
889
1121
  }
890
1122
 
891
- return { routes, upgrade }
1123
+ return { routes, upgrade, dispose }
892
1124
  }