@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
@@ -3,6 +3,7 @@
3
3
  * access path the panel components use — plain fetch/WebSocket, same origin.
4
4
  */
5
5
 
6
+ import { HttpApiError, buildQuery, readJson, throwHttpError, type HttpErrorBody } from '../../client-http.ts'
6
7
  import {
7
8
  SSH_API,
8
9
  type ClusterResult,
@@ -14,7 +15,6 @@ import {
14
15
  type RemoteDirEntry,
15
16
  type SshHostSummary,
16
17
  type TerminalClientFrame,
17
- type TerminalServerFrame,
18
18
  type TestResult,
19
19
  type TransferProgress,
20
20
  type TransferStreamLine,
@@ -24,49 +24,44 @@ import {
24
24
  /** Minimal File System Access API surface (not in all lib.dom versions). */
25
25
  interface WindowWithFileSystemAccess {
26
26
  showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
27
- createWritable: () => Promise<{ write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> }>
27
+ createWritable: () => Promise<{
28
+ write: (data: Uint8Array) => Promise<void>
29
+ close: () => Promise<void>
30
+ /** Discard the browser-managed temporary file instead of publishing it. */
31
+ abort?: (reason?: unknown) => Promise<void>
32
+ }>
28
33
  }>
29
34
  }
30
35
 
31
- /** Error carrying the route's JSON error message. */
32
- export class SshApiError extends Error {
33
- /** Stable machine code from the route error body (HOST_KEY_*, HOST_IN_USE, …). */
34
- readonly code: string | undefined
35
- readonly hostKeyFingerprint: string | undefined
36
- constructor(message: string, code?: string, hostKeyFingerprint?: string) {
37
- super(message)
38
- this.name = 'SshApiError'
39
- this.code = code
40
- this.hostKeyFingerprint = hostKeyFingerprint
41
- }
42
- }
36
+ /**
37
+ * This family's error name for the ONE shared transport error
38
+ * (`HttpApiError`): same class at runtime, so it carries the HTTP status and
39
+ * the parsed body (code / secret / hostKeyFingerprint / hostKeyMismatch /
40
+ * remaining / retryAfterMs) and existing `instanceof SshApiError` checks keep
41
+ * working. Parsing lives in ./client-http.ts — never re-implemented here.
42
+ */
43
+ export { HttpApiError as SshApiError }
43
44
 
44
- /** Parse a JSON response or throw an SshApiError. */
45
- async function readJson<T>(response: Response): Promise<T> {
46
- let body: unknown
47
- try {
48
- body = await response.json()
49
- } catch {
50
- throw new SshApiError(`HTTP ${response.status}: invalid JSON response`)
51
- }
52
- if (!response.ok) {
53
- const record = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {}
54
- const message = typeof record.error === 'string' ? record.error : `HTTP ${response.status}`
55
- const code = typeof record.code === 'string' ? record.code : undefined
56
- const hostKeyFingerprint = typeof record.hostKeyFingerprint === 'string' ? record.hostKeyFingerprint : undefined
57
- throw new SshApiError(message, code, hostKeyFingerprint)
58
- }
59
- return body as T
60
- }
45
+ /** Query-string helper (shared transport helper, no local copy). */
46
+ const query = buildQuery
61
47
 
62
- /** Query-string helper. */
63
- function query(params: Record<string, string | number | undefined>): string {
64
- const search = new URLSearchParams()
65
- for (const [key, value] of Object.entries(params)) {
66
- if (value !== undefined && value !== '') search.set(key, String(value))
48
+ /**
49
+ * Structured fields of one streamed (`application/x-ndjson`) failure frame.
50
+ * The upload route currently sends only `error`, so the interactive-gate
51
+ * fields are picked up when present and simply absent otherwise — the same
52
+ * shape the JSON routes produce, so callers handle one error type.
53
+ */
54
+ function frameFailure(frame: TransferStreamLine): HttpErrorBody {
55
+ const record = frame as TransferStreamLine & Partial<HttpErrorBody>
56
+ const secret = record.secret
57
+ return {
58
+ ...(typeof record.error === 'string' ? { error: record.error } : {}),
59
+ ...(typeof record.code === 'string' ? { code: record.code } : {}),
60
+ ...(secret === 'password' || secret === 'passphrase' ? { secret } : {}),
61
+ ...(typeof record.hostKeyFingerprint === 'string' ? { hostKeyFingerprint: record.hostKeyFingerprint } : {}),
62
+ ...(typeof record.remaining === 'number' ? { remaining: record.remaining } : {}),
63
+ ...(typeof record.retryAfterMs === 'number' ? { retryAfterMs: record.retryAfterMs } : {}),
67
64
  }
68
- const text = search.toString()
69
- return text === '' ? '' : '?' + text
70
65
  }
71
66
 
72
67
  /** One open terminal connection (WebSocket JSON frames). */
@@ -221,45 +216,80 @@ export class SshApi {
221
216
  alias: string,
222
217
  remotePath: string,
223
218
  onProgress?: (progress: TransferProgress) => void,
219
+ signal?: AbortSignal,
224
220
  ): Promise<{ transferredBytes: number }> {
225
221
  const response = await fetch(SSH_API.upload + query({ alias, remotePath }), {
226
222
  method: 'POST',
227
223
  body: file,
224
+ signal,
228
225
  })
229
- if (!response.ok || response.body === null) {
230
- throw new SshApiError(`upload failed: HTTP ${response.status}`)
231
- }
226
+ // Errors here are JSON too (the route maps them through errorBody), so the
227
+ // shared transport parses them instead of flattening the body to a string.
228
+ if (!response.ok) await throwHttpError(response, 'upload failed')
229
+ if (response.body === null) throw new HttpApiError('upload failed: the response carried no body stream', response.status)
232
230
  const reader = response.body.getReader()
233
231
  const decoder = new TextDecoder()
234
232
  let buffer = ''
235
- let finalError: string | undefined
233
+ let failure: HttpErrorBody | undefined
236
234
  let sawResult = false
235
+ let commitStarted = false
236
+ let readerDone = false
237
237
  let transferredBytes = 0
238
- for (;;) {
239
- const { done, value } = await reader.read()
240
- if (done) break
241
- buffer += decoder.decode(value, { stream: true })
242
- const lines = buffer.split('\n')
243
- buffer = lines.pop() ?? ''
244
- for (const line of lines) {
245
- if (line.trim() === '') continue
246
- let parsed: TransferStreamLine
247
- try {
248
- parsed = JSON.parse(line) as TransferStreamLine
249
- } catch {
250
- continue
238
+ try {
239
+ for (;;) {
240
+ const { done, value } = await reader.read()
241
+ if (done) {
242
+ readerDone = true
243
+ break
251
244
  }
252
- if (parsed.type === 'progress') {
253
- onProgress?.(parsed.progress)
254
- } else if (parsed.type === 'result') {
255
- sawResult = true
256
- if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
257
- finalError = parsed.ok ? undefined : parsed.error ?? 'upload failed'
245
+ buffer += decoder.decode(value, { stream: true })
246
+ const lines = buffer.split('\n')
247
+ buffer = lines.pop() ?? ''
248
+ for (const line of lines) {
249
+ if (line.trim() === '') continue
250
+ let parsed: TransferStreamLine
251
+ try {
252
+ parsed = JSON.parse(line) as TransferStreamLine
253
+ } catch {
254
+ continue
255
+ }
256
+ if (parsed.type === 'progress') {
257
+ onProgress?.(parsed.progress)
258
+ } else if (parsed.type === 'commit') {
259
+ commitStarted = true
260
+ } else if (parsed.type === 'result') {
261
+ sawResult = true
262
+ if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
263
+ else failure = frameFailure(parsed)
264
+ }
258
265
  }
259
266
  }
267
+ } catch (error) {
268
+ if (commitStarted) {
269
+ throw new HttpApiError(
270
+ 'upload connection closed after commit began; the remote result is unknown',
271
+ response.status,
272
+ { code: 'RESULT_UNKNOWN' },
273
+ )
274
+ }
275
+ throw error
276
+ } finally {
277
+ if (!readerDone) await reader.cancel().catch(() => undefined)
278
+ }
279
+ // A failed transfer is a typed transport error too: the interactive gates
280
+ // (NEEDS_PASSWORD / HOST_KEY_*) must be able to prompt from this path.
281
+ if (failure !== undefined) {
282
+ throw new HttpApiError(typeof failure.error === 'string' ? failure.error : 'upload failed', response.status, failure)
283
+ }
284
+ if (!sawResult) {
285
+ throw new HttpApiError(
286
+ commitStarted
287
+ ? 'upload connection closed after commit began; the remote result is unknown'
288
+ : 'upload ended without a result frame — the remote destination was not published',
289
+ response.status,
290
+ commitStarted ? { code: 'RESULT_UNKNOWN' } : { code: 'ABORTED' },
291
+ )
260
292
  }
261
- if (finalError !== undefined) throw new SshApiError(finalError)
262
- if (!sawResult) throw new SshApiError('upload ended without a result frame — the transfer did not complete')
263
293
  return { transferredBytes }
264
294
  }
265
295
 
@@ -272,12 +302,14 @@ export class SshApi {
272
302
  alias: string,
273
303
  remotePath: string,
274
304
  onProgress?: (progress: TransferProgress) => void,
305
+ signal?: AbortSignal,
275
306
  ): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
276
- const response = await fetch(SSH_API.download + query({ alias, remotePath }))
277
- if (!response.ok || response.body === null) {
278
- const text = await response.text().catch(() => '')
279
- throw new SshApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
280
- }
307
+ const response = await fetch(SSH_API.download + query({ alias, remotePath }), { signal })
308
+ // The success path is a raw byte stream, but the error path is the same
309
+ // JSON body as every other route — the shared transport parses it, so the
310
+ // caller keeps code/status instead of a flattened JSON string.
311
+ if (!response.ok) await throwHttpError(response, 'download failed')
312
+ if (response.body === null) throw new HttpApiError('download failed: the response carried no body stream', response.status)
281
313
  const total = Number(response.headers.get('content-length') ?? '0')
282
314
  const disposition = response.headers.get('content-disposition') ?? ''
283
315
  const match = /filename="([^"]+)"/.exec(disposition)
@@ -287,9 +319,15 @@ export class SshApi {
287
319
  ? (window as WindowWithFileSystemAccess).showSaveFilePicker
288
320
  : undefined
289
321
  let streamed = false
290
- let writable: { write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> } | undefined
322
+ let writable: {
323
+ write: (data: Uint8Array) => Promise<void>
324
+ close: () => Promise<void>
325
+ abort?: (reason?: unknown) => Promise<void>
326
+ } | undefined
291
327
  const chunks: Uint8Array<ArrayBuffer>[] = []
292
328
  let received = 0
329
+ let readerDone = false
330
+ let writableClosed = false
293
331
  const progress = (): void => {
294
332
  onProgress?.({
295
333
  phase: 'transferring',
@@ -300,32 +338,52 @@ export class SshApi {
300
338
  })
301
339
  }
302
340
  try {
303
- if (picker !== undefined) {
304
- const handle = await picker.call(window, { suggestedName: filename })
305
- writable = await handle.createWritable()
306
- streamed = true
341
+ try {
342
+ if (picker !== undefined) {
343
+ const handle = await picker.call(window, { suggestedName: filename })
344
+ signal?.throwIfAborted()
345
+ writable = await handle.createWritable()
346
+ streamed = true
347
+ }
348
+ } catch (error) {
349
+ // A user-cancelled picker falls back to Blob. An operation abort must
350
+ // not continue into memory or later trigger a browser save.
351
+ if (signal?.aborted === true) throw error
307
352
  }
308
- } catch {
309
- // User cancelled the save dialog or the API is unavailable: fall back.
310
- }
311
- for (;;) {
312
- const { done, value } = await reader.read()
313
- if (done) break
353
+ for (;;) {
354
+ signal?.throwIfAborted()
355
+ const { done, value } = await reader.read()
356
+ if (done) {
357
+ readerDone = true
358
+ break
359
+ }
360
+ if (writable !== undefined) {
361
+ await writable.write(value as Uint8Array)
362
+ } else {
363
+ chunks.push(value as Uint8Array<ArrayBuffer>)
364
+ }
365
+ received += value.length
366
+ progress()
367
+ }
368
+ signal?.throwIfAborted()
314
369
  if (writable !== undefined) {
315
- await writable.write(value as Uint8Array)
316
- } else {
317
- chunks.push(value as Uint8Array<ArrayBuffer>)
370
+ await writable.close()
371
+ writableClosed = true
318
372
  }
319
- received += value.length
320
- progress()
321
- }
322
- if (writable !== undefined) await writable.close()
323
- onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
324
- return {
325
- blob: streamed ? undefined : new Blob(chunks),
326
- filename,
327
- streamed,
328
- bytes: received,
373
+ onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
374
+ return {
375
+ blob: streamed ? undefined : new Blob(chunks),
376
+ filename,
377
+ streamed,
378
+ bytes: received,
379
+ }
380
+ } catch (error) {
381
+ chunks.length = 0
382
+ if (!readerDone) await reader.cancel(error).catch(() => undefined)
383
+ if (writable !== undefined && !writableClosed) {
384
+ await writable.abort?.(error).catch(() => undefined)
385
+ }
386
+ throw error
329
387
  }
330
388
  }
331
389
 
@@ -335,37 +393,116 @@ export class SshApi {
335
393
  const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
336
394
  const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
337
395
  const socket = new WebSocket(url)
396
+ const MAX_PENDING_INPUT = 64 * 1024
397
+ let ready = false
398
+ let finalized = false
399
+ let queuedInput = ''
400
+ let queuedResize: { cols: number; rows: number } | undefined
401
+ let readyListener: (() => void) | undefined
402
+ let readyDelivered = false
403
+ let outputListener: ((data: string) => void) | undefined
404
+ let exitListener: ((code: number | null, error?: string) => void) | undefined
405
+ let exitRecord: { code: number | null; error?: string } | undefined
406
+ let exitDelivered = false
407
+ const deliverReady = (): void => {
408
+ if (!ready || readyDelivered || readyListener === undefined) return
409
+ readyDelivered = true
410
+ readyListener()
411
+ }
412
+ const deliverExit = (): void => {
413
+ if (exitRecord === undefined || exitDelivered || exitListener === undefined) return
414
+ exitDelivered = true
415
+ exitListener(exitRecord.code, exitRecord.error)
416
+ }
417
+ const finalize = (code: number | null, error?: string): void => {
418
+ if (finalized) return
419
+ finalized = true
420
+ queuedInput = ''
421
+ queuedResize = undefined
422
+ exitRecord = error === undefined ? { code } : { code, error }
423
+ deliverExit()
424
+ }
425
+ const sendFrame = (frame: TerminalClientFrame): void => {
426
+ if (finalized || !ready || socket.readyState !== WebSocket.OPEN) return
427
+ socket.send(JSON.stringify(frame))
428
+ }
338
429
  const connection: TerminalConnection = {
339
- onReady: undefined,
340
- onOutput: undefined,
341
- onExit: undefined,
430
+ get onReady() { return readyListener },
431
+ set onReady(listener) {
432
+ readyListener = listener
433
+ deliverReady()
434
+ },
435
+ get onOutput() { return outputListener },
436
+ set onOutput(listener) { outputListener = listener },
437
+ get onExit() { return exitListener },
438
+ set onExit(listener) {
439
+ exitListener = listener
440
+ deliverExit()
441
+ },
342
442
  send: (data) => {
343
- if (socket.readyState === WebSocket.OPEN) {
344
- socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
443
+ if (finalized) return
444
+ if (!ready || socket.readyState !== WebSocket.OPEN) {
445
+ if (queuedInput.length + data.length > MAX_PENDING_INPUT) {
446
+ finalize(null, 'terminal input buffer exceeded before ready')
447
+ try { socket.close(1008, 'terminal input buffer exceeded') } catch { /* already closed */ }
448
+ return
449
+ }
450
+ queuedInput += data
451
+ return
345
452
  }
453
+ sendFrame({ type: 'input', data })
346
454
  },
347
- resize: (cols, rows) => {
348
- if (socket.readyState === WebSocket.OPEN) {
349
- socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
455
+ resize: (nextCols, nextRows) => {
456
+ if (finalized) return
457
+ const frame = { type: 'resize', cols: nextCols, rows: nextRows } satisfies TerminalClientFrame
458
+ if (!ready || socket.readyState !== WebSocket.OPEN) {
459
+ // Resize is state, not a sequence: one latest value is the complete
460
+ // bounded buffer needed before the server reports shell readiness.
461
+ queuedResize = { cols: nextCols, rows: nextRows }
462
+ return
350
463
  }
464
+ sendFrame(frame)
351
465
  },
352
466
  close: () => {
353
467
  try { socket.close() } catch { /* already closed */ }
354
468
  },
355
469
  }
356
470
  socket.onmessage = (event: MessageEvent<string>) => {
357
- let frame: TerminalServerFrame
358
471
  try {
359
- frame = JSON.parse(event.data) as TerminalServerFrame
472
+ const value: unknown = JSON.parse(event.data)
473
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('invalid terminal frame')
474
+ const frame = value as Record<string, unknown>
475
+ if (frame.type === 'ready' && typeof frame.alias === 'string') {
476
+ if (ready || finalized) return
477
+ ready = true
478
+ deliverReady()
479
+ if (queuedResize !== undefined) {
480
+ sendFrame({ type: 'resize', ...queuedResize })
481
+ queuedResize = undefined
482
+ }
483
+ if (queuedInput !== '') {
484
+ sendFrame({ type: 'input', data: queuedInput })
485
+ queuedInput = ''
486
+ }
487
+ return
488
+ }
489
+ if (frame.type === 'output' && typeof frame.data === 'string') {
490
+ outputListener?.(frame.data)
491
+ return
492
+ }
493
+ if (frame.type === 'exit' && (typeof frame.code === 'number' || frame.code === null) && (frame.error === undefined || typeof frame.error === 'string')) {
494
+ finalize(frame.code as number | null, frame.error as string | undefined)
495
+ try { socket.close(1000) } catch { /* already closed */ }
496
+ return
497
+ }
498
+ throw new Error('invalid terminal frame')
360
499
  } catch {
361
- return
500
+ finalize(null, 'invalid terminal server frame')
501
+ try { socket.close(1008, 'invalid terminal server frame') } catch { /* already closed */ }
362
502
  }
363
- if (frame.type === 'ready') connection.onReady?.()
364
- else if (frame.type === 'output') connection.onOutput?.(frame.data)
365
- else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
366
503
  }
367
- socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
368
- socket.onerror = () => { connection.onExit?.(null, 'connection error') }
504
+ socket.onclose = () => { finalize(null, 'connection closed') }
505
+ socket.onerror = () => { finalize(null, 'connection error') }
369
506
  return connection
370
507
  }
371
508
 
@@ -1,21 +1,22 @@
1
1
  /**
2
2
  * SSH operations browser surfaces, hosted inside dsh-hardssh's client bundle
3
- * (migrated from the legacy dsh-ssh package): the 'dsh-ssh'
4
- * locale dictionaries, the sidebar entry row, and the SSH operations panel
5
- * in the center column. Mounted from src/client/index.ts alongside the
6
- * workspace surfaces; a DOM failure here degrades the SSH panel only, never
7
- * the GUI.
3
+ * (migrated from the legacy dsh-ssh package): the 'dsh-ssh' locale
4
+ * dictionaries and the SSH operations console mounted as a RIGHT-Sidebar page
5
+ * tab.
6
+ *
7
+ * Nothing here touches the DOM. The old build injected a sidebar row and a
8
+ * panel container by selector, which the 0.1.5 shell no longer renders — the
9
+ * surfaces are standard plugin extension points now (see ./ops-tab.tsx).
8
10
  */
9
11
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
10
12
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
11
13
  import type {} from '@deepseek-ai/dsh-client-locale/client'
12
14
  // Type-only: pulls the LocaleNamespaceMap merge table.
13
15
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
- import { SshApi } from './api.ts'
16
+ import type { SshApi } from './api.ts'
15
17
  import { en, zh } from './locales.ts'
16
- import { mountPanel } from './mount.tsx'
17
- import { PanelController } from './panel/controller.ts'
18
- import { mountSidebarEntry } from './sidebar-entry.ts'
18
+ import { registerOperationsTab } from './ops-tab.tsx'
19
+ import type { SessionSshTargetSource } from './session-target.ts'
19
20
 
20
21
  /** Locale namespace this capability owns (kept as 'dsh-ssh'). */
21
22
  export const NS = 'dsh-ssh'
@@ -28,23 +29,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
28
29
  }
29
30
 
30
31
  /**
31
- * Mount the SSH operations surfaces (sidebar entry + operations panel).
32
- * @param ctx - client root context (locale service).
32
+ * Mount the SSH operations surfaces (locale dictionaries + the right-Sidebar tab).
33
+ * @param ctx - client root context (locale service, slot registry, tab registry).
34
+ * @param api - shared SSH API client.
35
+ * @param target - selected Session's fixed SSH target, or null for local Sessions.
33
36
  */
34
- export function mountSshOperations(ctx: ClientContext): void {
35
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
36
-
37
- const controller = new PanelController()
38
- const api = new SshApi()
39
- const disposers: Array<() => void> = []
37
+ export function mountSshOperations(
38
+ ctx: ClientContext,
39
+ api: SshApi,
40
+ target: SessionSshTargetSource,
41
+ ): void {
40
42
  try {
41
- disposers.push(mountSidebarEntry(controller))
42
- disposers.push(mountPanel(controller, api))
43
+ registerOperationsTab(ctx, api, target)
43
44
  } catch (error) {
44
- // DOM failures degrade the panel, never the GUI.
45
- console.warn('[dsh-ssh] mount failed:', error)
45
+ // A wiring failure degrades the SSH operations tab only, never the GUI.
46
+ console.warn('[dsh-ssh] operations tab registration failed:', error)
46
47
  }
47
- ctx.effect(() => () => {
48
- for (const dispose of disposers.splice(0)) dispose()
49
- }, 'dsh-ssh: ui mounts')
48
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
50
49
  }
@@ -11,6 +11,13 @@ export const zh = {
11
11
  'tab.transfer': '传输',
12
12
  'tab.tunnels': '隧道',
13
13
  'tab.cluster': '集群',
14
+ 'session.server': '当前服务器',
15
+ 'session.target': '跟随当前会话:{alias} · {remoteRoot}',
16
+ 'session.localTitle': '当前会话位于本地工作区',
17
+ 'session.localUnavailable': 'SSH 操作台仅适用于 SSH 工作区会话。请切换到远程会话后使用。',
18
+ 'connectionError.title': '无法连接服务器 {alias}',
19
+ 'connectionError.intro': 'SSH 连接未建立,请检查服务器地址、网络和认证配置。',
20
+ 'connectionError.close': '知道了',
14
21
  // hosts
15
22
  'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。',
16
23
  'hosts.add': '新增主机',
@@ -68,7 +75,7 @@ export const zh = {
68
75
  'terminal.selectHost': '选择主机',
69
76
  'terminal.connect': '连接',
70
77
  'terminal.disconnect': '断开',
71
- 'terminal.placeholder': '选择主机后点击「连接」打开远程终端。',
78
+ 'terminal.placeholder': '点击「连接」打开当前会话服务器的远程终端。',
72
79
  'terminal.connecting': '正在连接…',
73
80
  'terminal.ready': '终端已连接({alias})',
74
81
  'terminal.exited': '终端已退出({alias})',
@@ -168,6 +175,13 @@ export const en: Record<keyof typeof zh, string> = {
168
175
  'tab.transfer': 'Transfer',
169
176
  'tab.tunnels': 'Tunnels',
170
177
  'tab.cluster': 'Cluster',
178
+ 'session.server': 'Current server',
179
+ 'session.target': 'Following current session: {alias} · {remoteRoot}',
180
+ 'session.localTitle': 'This session uses a local workspace',
181
+ 'session.localUnavailable': 'The SSH console is available only for SSH workspace sessions. Switch to a remote session to use it.',
182
+ 'connectionError.title': 'Unable to connect to {alias}',
183
+ 'connectionError.intro': 'The SSH connection was not established. Check the server address, network, and authentication settings.',
184
+ 'connectionError.close': 'OK',
171
185
  'hosts.empty': 'No hosts configured. Click "Add host" or import from ~/.ssh/config.',
172
186
  'hosts.add': 'Add host',
173
187
  'hosts.import': 'Import ~/.ssh/config',
@@ -222,7 +236,7 @@ export const en: Record<keyof typeof zh, string> = {
222
236
  'terminal.selectHost': 'Select host',
223
237
  'terminal.connect': 'Connect',
224
238
  'terminal.disconnect': 'Disconnect',
225
- 'terminal.placeholder': 'Select a host and click "Connect" to open the remote terminal.',
239
+ 'terminal.placeholder': 'Click "Connect" to open a terminal on the current session server.',
226
240
  'terminal.connecting': 'Connecting…',
227
241
  'terminal.ready': 'Terminal connected ({alias})',
228
242
  'terminal.exited': 'Terminal exited ({alias})',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The SSH operations surface as a RIGHT-Sidebar page tab.
3
+ *
4
+ * Registration follows the same public two-stage path any shipped tab type
5
+ * uses:
6
+ * 1. `ctx.sidebarRightTabs.register(...)` declares the type — its `id` is the
7
+ * key its body and chip title register under, its `kind` is what
8
+ * `ctx.sidebarRight.openTab(kind)` names.
9
+ * 2. the body goes into the keyed `sidebar.right.pane.tab` seat, the chip title
10
+ * into `sidebar.right.pane.tab.title`, both under that `id`.
11
+ *
12
+ * It is a PAGE type (no `patterns`): it recognizes no resource address, so it is
13
+ * opened by kind from the right sidebar's own add control or from its guide
14
+ * entry — the plugin never forces the column open.
15
+ *
16
+ * The left sidebar's workspace manager is a separate, GLOBAL surface; host
17
+ * CRUD lives there, so this console keeps only the per-session operations:
18
+ * terminal, transfer, tunnels, cluster.
19
+ */
20
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
+ // Type-only: pulls the SidebarRightTabDefinition type and the `sidebar.right.*`
22
+ // SlotMap augmentations.
23
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
24
+ // Type-only: pulls the LocaleNamespaceMap augmentation.
25
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
26
+ import { tt } from '../text.ts'
27
+ import { SshPanel } from './panel/SshPanel.tsx'
28
+ import type { SshApi } from './api.ts'
29
+ import type { SessionSshTargetSource } from './session-target.ts'
30
+
31
+ /** This implementation's identity in the tab system; also the body/title key. */
32
+ export const OPS_TAB_ID = '@tiphareth/dsh-hardssh/operations'
33
+
34
+ /** The page kind `ctx.sidebarRight.openTab(kind)` names. */
35
+ export const OPS_TAB_KIND = 'hardssh-operations'
36
+
37
+ /**
38
+ * Register the regression type, its body, and its chip title.
39
+ * @param ctx - client root context carrying the tab registry and slot registry.
40
+ * @param api - the SSH API client the panel's tabs operate through.
41
+ * @param target - live target inherited from the selected Session.
42
+ */
43
+ export function registerOperationsTab(
44
+ ctx: ClientContext,
45
+ api: SshApi,
46
+ target: SessionSshTargetSource,
47
+ ): void {
48
+ ctx.effect(() => ctx.sidebarRightTabs.register({
49
+ id: OPS_TAB_ID,
50
+ kind: OPS_TAB_KIND,
51
+ // The chip's initial text is captured into the layout record at open time;
52
+ // a thunk keeps it following the active language without re-registering.
53
+ title: () => tt('ops.tab'),
54
+ // An entry box on the right sidebar's empty-state guide, so the surface is
55
+ // discoverable without a plugin-owned button.
56
+ guide: [{
57
+ order: 100,
58
+ title: () => tt('ops.tab'),
59
+ description: () => tt('ops.guide'),
60
+ }],
61
+ }), 'dsh-hardssh: operations tab type')
62
+
63
+ // The body: receives every tab of this kind, in every pane, docked or floating.
64
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
65
+ { name: 'sidebar.right.pane.tab', key: OPS_TAB_ID, inject: () => ({ api, target }) },
66
+ SshPanel,
67
+ ))
68
+
69
+ // The chip title in the strip and a floating panel's header.
70
+ ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
71
+ { name: 'sidebar.right.pane.tab.title', key: OPS_TAB_ID },
72
+ OpsTabTitle,
73
+ ))
74
+ }
75
+
76
+ /** The tab chip and floating-panel header text. */
77
+ function OpsTabTitle(): string {
78
+ return tt('ops.tab')
79
+ }