@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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 +77 -50
  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 +333 -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 +42 -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 +59 -26
  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 +171 -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/store.ts CHANGED
@@ -29,6 +29,75 @@ interface StoreFile {
29
29
  hosts: SshHostEntry[]
30
30
  }
31
31
 
32
+ /** One parsed Host block from `~/.ssh/config`. */
33
+ interface SshConfigBlock {
34
+ pattern: string
35
+ props: Record<string, string>
36
+ }
37
+
38
+ /** Parse `~/.ssh/config` into importable candidate payloads. Pure (no store
39
+ * writes): the caller applies each candidate through ITS OWN create, so the
40
+ * plaintext `HostStore` and the secret-aware `SecureHostStore` share one
41
+ * parser while differing only in how an entry's secrets are stored. */
42
+ function parseSshConfigToImport(configPath: string): {
43
+ candidates: HostPayload[]
44
+ /** Wildcard / no-HostName patterns (reported as skipped, not importable). */
45
+ invalidNames: string[]
46
+ /** Total Host blocks parsed (including non-importable ones). */
47
+ totalBlocks: number
48
+ } {
49
+ const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
50
+ const blocks: SshConfigBlock[] = []
51
+ let current: SshConfigBlock | undefined
52
+ for (const raw of lines) {
53
+ const line = raw.trim()
54
+ if (line === '' || line.startsWith('#')) continue
55
+ const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
56
+ if (match === null) continue
57
+ const key = match[1].toLowerCase()
58
+ const value = match[2].trim()
59
+ if (key === 'host') {
60
+ current = { pattern: value, props: {} }
61
+ blocks.push(current)
62
+ } else if (current !== undefined) {
63
+ current.props[key] = value
64
+ }
65
+ }
66
+ const candidates: HostPayload[] = []
67
+ const invalidNames: string[] = []
68
+ for (const block of blocks) {
69
+ const pattern = block.pattern.split(/\s+/)[0]
70
+ if (pattern.includes('*') || pattern.includes('?')) {
71
+ if (pattern !== '') invalidNames.push(pattern)
72
+ continue
73
+ }
74
+ const hostName = block.props.hostname
75
+ if (hostName === undefined || hostName === '') {
76
+ if (pattern !== '') invalidNames.push(pattern)
77
+ continue
78
+ }
79
+ candidates.push({
80
+ alias: pattern,
81
+ host: hostName,
82
+ port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
83
+ user: block.props.user ?? process.env.USER ?? 'root',
84
+ auth: {
85
+ kind: block.props.identityfile !== undefined ? 'key' : 'password',
86
+ keyPath: block.props.identityfile,
87
+ password: block.props.password,
88
+ },
89
+ proxyJump: block.props.proxyjump !== undefined
90
+ ? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
91
+ : [],
92
+ description: block.props.description,
93
+ environment: block.props.environment,
94
+ tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
95
+ location: block.props.location,
96
+ })
97
+ }
98
+ return { candidates, invalidNames, totalBlocks: blocks.length }
99
+ }
100
+
32
101
  /** Payload shape the validator enforces: full create vs partial patch. */
33
102
  export type HostPayloadValidationMode = 'create' | 'patch'
34
103
 
@@ -151,6 +220,11 @@ export class HostStore {
151
220
  return file.hosts
152
221
  }
153
222
 
223
+ /** The ssh-config path this store reads (override or the default). */
224
+ get sshConfigOverridePath(): string | undefined {
225
+ return this.sshConfigOverride
226
+ }
227
+
154
228
  /** Find one entry by alias. */
155
229
  find(alias: string): SshHostEntry | undefined {
156
230
  return this.list().find(entry => entry.alias === alias)
@@ -290,7 +364,8 @@ export class HostStore {
290
364
  /**
291
365
  * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
292
366
  * pattern and a HostName become entries (key auth via IdentityFile, jump
293
- * hosts via ProxyJump). Existing aliases are skipped.
367
+ * hosts via ProxyJump). Existing aliases are skipped. Each entry is created
368
+ * through this store's own `create`, which keeps plaintext secrets inline.
294
369
  * @returns import statistics.
295
370
  */
296
371
  importFromSshConfig(): ImportResult {
@@ -298,73 +373,24 @@ export class HostStore {
298
373
  this.skippedNames = new Set<string>()
299
374
  const configPath = this.sshConfigOverride ?? sshConfigPath()
300
375
  if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
301
- const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
302
- const blocks: { pattern: string; props: Record<string, string> }[] = []
303
- let current: { pattern: string; props: Record<string, string> } | undefined
304
- const skip = (name: string, seen: Set<string>): void => {
305
- if (name !== '' && !seen.has(name)) {
306
- seen.add(name)
307
- this.skippedNames.add(name)
308
- }
309
- }
310
- for (const raw of lines) {
311
- const line = raw.trim()
312
- if (line === '' || line.startsWith('#')) continue
313
- const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
314
- if (match === null) continue
315
- const key = match[1].toLowerCase()
316
- const value = match[2].trim()
317
- if (key === 'host') {
318
- current = { pattern: value, props: {} }
319
- blocks.push(current)
320
- } else if (current !== undefined) {
321
- current.props[key] = value
322
- }
323
- }
376
+ const { candidates, invalidNames, totalBlocks } = parseSshConfigToImport(configPath)
377
+ for (const name of invalidNames) this.skippedNames.add(name)
324
378
  let added = 0
325
- for (const block of blocks) {
326
- const pattern = block.pattern.split(/\s+/)[0]
327
- if (pattern.includes('*') || pattern.includes('?')) {
328
- skip(pattern, this.skippedNames)
329
- continue
330
- }
331
- const hostName = block.props.hostname
332
- if (hostName === undefined || hostName === '') {
333
- skip(pattern, this.skippedNames)
334
- continue
335
- }
336
- const existing = this.list().some(entry => entry.alias === pattern)
337
- if (existing) {
338
- skip(pattern, this.skippedNames)
379
+ for (const payload of candidates) {
380
+ const alias = payload.alias ?? ''
381
+ if (alias !== '' && this.list().some(entry => entry.alias === alias)) {
382
+ this.skippedNames.add(alias)
339
383
  continue
340
384
  }
341
- const payload: HostPayload = {
342
- alias: pattern,
343
- host: hostName,
344
- port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
345
- user: block.props.user ?? process.env.USER ?? 'root',
346
- auth: {
347
- kind: block.props.identityfile !== undefined ? 'key' : 'password',
348
- keyPath: block.props.identityfile,
349
- password: block.props.password,
350
- },
351
- proxyJump: block.props.proxyjump !== undefined
352
- ? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
353
- : [],
354
- description: block.props.description,
355
- environment: block.props.environment,
356
- tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
357
- location: block.props.location,
358
- }
359
385
  try {
360
386
  this.create(payload)
361
387
  added += 1
362
388
  } catch {
363
389
  // Unusable entry (bad alias grammar etc.) — count as skipped.
364
- skip(pattern, this.skippedNames)
390
+ if (alias !== '') this.skippedNames.add(alias)
365
391
  }
366
392
  }
367
- return { parsed: blocks.length, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
393
+ return { parsed: totalBlocks, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
368
394
  }
369
395
 
370
396
  private skippedNames = new Set<string>()
@@ -426,13 +452,15 @@ export interface ResolvedAuth {
426
452
  *
427
453
  * - **'none' (default, VSCode Remote-SSH style)**: secrets are NEVER
428
454
  * persisted. create/update strip the password/passphrase from the entry
429
- * (kind + keyPath stay); the engine prompts once per session and holds the
430
- * credential in-memory (session password table). The plaintext HostStore
455
+ * (kind + keyPath stay); the engine prompts once and holds the credential
456
+ * in-memory for that CONNECTION's lifetime (the pool's retirement drops it).
457
+ * The plaintext HostStore
431
458
  * remains untouched, so existing v1 tests and dual-format reads keep
432
459
  * working.
433
460
  */
434
461
  export class SecureHostStore {
435
462
  private readonly inner: HostStore
463
+ private readonly pendingSecretCleanup = new Set<string>()
436
464
 
437
465
  constructor(
438
466
  private readonly vault: import('./vault.ts').Vault | undefined,
@@ -447,20 +475,71 @@ export class SecureHostStore {
447
475
  find(alias: string): SshHostEntry | undefined { return this.inner.find(alias) }
448
476
  summarize(entry: SshHostEntry): SshHostSummary { return this.inner.summarize(entry) }
449
477
  get path(): string { return this.inner.path }
450
- /** Legacy ssh-config import (delegates; passwords imported stay inline
451
- * a follow-up may route them through the vault). */
452
- importFromSshConfig(): ImportResult { return this.inner.importFromSshConfig() }
478
+ /** The ssh-config path the underlying store reads (override or default). */
479
+ get sshConfigOverridePath(): string | undefined { return this.inner.sshConfigOverridePath }
480
+
481
+ /** ssh-config import routed through THIS store's `create`, so every imported
482
+ * credential is handled per `secretStorage` (never persisted in 'none' mode,
483
+ * vault-encrypted in 'vault' mode) instead of being stored inline. */
484
+ async importFromSshConfig(): Promise<ImportResult> {
485
+ const configPath = this.inner.sshConfigOverridePath ?? sshConfigPath()
486
+ if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
487
+ const { candidates, invalidNames, totalBlocks } = parseSshConfigToImport(configPath)
488
+ const skipped = new Set<string>(invalidNames.filter(name => name !== ''))
489
+ let added = 0
490
+ for (const payload of candidates) {
491
+ const alias = payload.alias ?? ''
492
+ if (alias !== '' && this.inner.list().some(entry => entry.alias === alias)) {
493
+ skipped.add(alias)
494
+ continue
495
+ }
496
+ try {
497
+ await this.create(payload)
498
+ added += 1
499
+ } catch {
500
+ if (alias !== '') skipped.add(alias)
501
+ }
502
+ }
503
+ return { parsed: totalBlocks, added, skipped: skipped.size, skippedNames: [...skipped] }
504
+ }
505
+
506
+ /** Deferred best-effort cleanup queue (diagnostics/tests; contains no plaintext). */
507
+ pendingSecretCleanupRefs(): string[] { return [...this.pendingSecretCleanup] }
508
+
509
+ private async removeSecret(ref: string): Promise<unknown | undefined> {
510
+ if (this.vault === undefined) return undefined
511
+ try {
512
+ await this.vault.remove(ref)
513
+ this.pendingSecretCleanup.delete(ref)
514
+ return undefined
515
+ } catch (error) {
516
+ this.pendingSecretCleanup.add(ref)
517
+ return error
518
+ }
519
+ }
520
+
521
+ private async retryPendingCleanup(): Promise<void> {
522
+ for (const ref of [...this.pendingSecretCleanup]) await this.removeSecret(ref)
523
+ }
453
524
 
454
- /** Create: secrets are stashed per mode; entry only keeps kind (+ keyPath). */
525
+ /** Create: secrets are staged, then rolled back if host persistence fails. */
455
526
  async create(payload: HostPayload): Promise<SshHostEntry> {
456
527
  // Validate the raw wire input first. In 'none' mode a password-kind host
457
528
  // may omit the password (typed at connect time, VSCode-style); 'vault'
458
529
  // mode keeps the strict rule so every credential gets stashed.
459
530
  const rawError = validateHostPayload(payload, 'create', this.mode === 'none')
460
531
  if (rawError !== undefined) throw new Error(rawError)
532
+ await this.retryPendingCleanup()
461
533
  const ref = await this.stashSecrets(payload.alias ?? '', payload.auth)
462
- const entry = this.inner.create(stripSecrets(payload, ref, this.mode), true)
463
- return entry
534
+ try {
535
+ return this.inner.create(stripSecrets(payload, ref, this.mode), true)
536
+ } catch (error) {
537
+ if (ref !== undefined) {
538
+ const cleanupError = await this.removeSecret(ref)
539
+ if (cleanupError !== undefined) throw new AggregateError([error, cleanupError], 'host create failed and staged vault secret rollback failed')
540
+ }
541
+ throw error
542
+ }
464
543
  }
465
544
 
466
545
  /** Update: auth present -> re-stash (or strip in none mode); absent → keep. */
@@ -480,18 +559,36 @@ export class SecureHostStore {
480
559
  && existing.auth.secretRef !== undefined
481
560
  if (!keepOldRef) throw new Error(rawError)
482
561
  }
562
+ await this.retryPendingCleanup()
483
563
  const ref = await this.stashSecrets(alias, patch.auth)
484
- const effectiveRef = ref ?? (this.mode === 'vault' ? existing.auth.secretRef : undefined)
485
- return this.inner.update(alias, stripSecrets(patch, effectiveRef, this.mode), true)
564
+ const sameCredential = patch.auth.kind === existing.auth.kind
565
+ && (patch.auth.kind === 'password'
566
+ || (patch.auth.keyPath === undefined || expandHome(patch.auth.keyPath.trim()) === existing.auth.keyPath))
567
+ const effectiveRef = ref ?? (this.mode === 'vault' && sameCredential ? existing.auth.secretRef : undefined)
568
+ let updated: SshHostEntry
569
+ try {
570
+ updated = this.inner.update(alias, stripSecrets(patch, effectiveRef, this.mode), true)
571
+ } catch (error) {
572
+ if (ref !== undefined) {
573
+ const cleanupError = await this.removeSecret(ref)
574
+ if (cleanupError !== undefined) throw new AggregateError([error, cleanupError], 'host update failed and staged vault secret rollback failed')
575
+ }
576
+ throw error
577
+ }
578
+ if (existing.auth.secretRef !== undefined && existing.auth.secretRef !== effectiveRef) {
579
+ // Host persistence is committed. Cleanup failure is deferred and must not
580
+ // make callers retry an already-applied host update.
581
+ await this.removeSecret(existing.auth.secretRef)
582
+ }
583
+ return updated
486
584
  }
487
585
 
488
- /** Delete: drop the host (and its vault secrets in vault mode, best-effort). */
586
+ /** Delete the host first; vault cleanup can then only leave an orphan. */
489
587
  async delete(alias: string): Promise<void> {
588
+ await this.retryPendingCleanup()
490
589
  const entry = this.inner.find(alias)
491
- if (entry !== undefined && entry.auth.secretRef !== undefined && this.vault !== undefined) {
492
- await this.vault.remove(entry.auth.secretRef).catch(() => undefined)
493
- }
494
590
  this.inner.delete(alias)
591
+ if (entry?.auth.secretRef !== undefined) await this.removeSecret(entry.auth.secretRef)
495
592
  }
496
593
 
497
594
  /** Resolve the authentication for one entry (vault reveal when needed). */
@@ -0,0 +1,177 @@
1
+ import type { Client, ClientChannel } from 'ssh2'
2
+ import type { ExecSession, ShellSession } from '../engine.ts'
3
+
4
+ /** A fresh SSH channel (shell/exec) must open within this budget; a dead or
5
+ * half-open connection would otherwise leave the open promise hanging. */
6
+ const CHANNEL_OPEN_TIMEOUT_MS = 10_000
7
+
8
+ /**
9
+ * Narrow connection dependency used by the standalone terminal transports.
10
+ * `connectStandalone` must reach the store itself (the not-found wording is
11
+ * this component's own contract) and must always return a PRIVATE connection
12
+ * — never a pooled one.
13
+ */
14
+ export interface TerminalConnectionAccess {
15
+ connectStandalone(alias: string): Promise<{ client: Client; hops: Client[] }>
16
+ }
17
+
18
+ /**
19
+ * Owns the standalone PTY-shell / streaming-exec transports: one exclusive
20
+ * connection per session (closing a shell can therefore never tear down a
21
+ * pooled exec or tunnel sharing the alias), the channel-open deadline,
22
+ * idempotent teardown and session assembly.
23
+ */
24
+ export class TerminalService {
25
+ /** Teardowns of live standalone transports, so dispose() can close sessions
26
+ * that the caller never closed explicitly (openShell/openExec own their
27
+ * whole connection and would otherwise outlive the engine). */
28
+ private readonly activeTeardowns = new Set<() => void>()
29
+
30
+ constructor(private readonly connection: TerminalConnectionAccess) {}
31
+
32
+ /**
33
+ * Open one standalone channel on its own connection (never a pooled one).
34
+ * Shared by openShell/openExec: alias lookup, jump chain, channel-open
35
+ * timeout, idempotent teardown, and late-callback cleanup live here, so the
36
+ * two public methods keep only their session-specific assembly.
37
+ */
38
+ private async openStandaloneChannel(
39
+ alias: string,
40
+ open: (client: Client, callback: (error: Error | undefined, stream?: ClientChannel) => void) => void,
41
+ ): Promise<{ stream: ClientChannel; teardown: () => void }> {
42
+ const { client, hops } = await this.connection.connectStandalone(alias)
43
+ return await new Promise<{ stream: ClientChannel; teardown: () => void }>((resolve, reject) => {
44
+ let settled = false
45
+ let tornDown = false
46
+ const teardown = (): void => {
47
+ if (tornDown) return
48
+ tornDown = true
49
+ this.activeTeardowns.delete(teardown)
50
+ try { client.end() } catch { /* closed */ }
51
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
52
+ }
53
+ this.activeTeardowns.add(teardown)
54
+ const timer = setTimeout(() => {
55
+ if (settled) return
56
+ settled = true
57
+ teardown()
58
+ reject(new Error(`channel on '${alias}' did not open within ${CHANNEL_OPEN_TIMEOUT_MS} ms`))
59
+ }, CHANNEL_OPEN_TIMEOUT_MS)
60
+ timer.unref?.()
61
+ open(client, (error, stream) => {
62
+ if (settled) {
63
+ // Late arrival after the timeout: the connection is being torn
64
+ // down; close any channel that finally opened instead of leaking it.
65
+ if (stream !== undefined) {
66
+ try { stream.close() } catch { /* already closed */ }
67
+ }
68
+ teardown()
69
+ return
70
+ }
71
+ settled = true
72
+ clearTimeout(timer)
73
+ if (error !== undefined) {
74
+ teardown()
75
+ reject(error)
76
+ return
77
+ }
78
+ if (stream === undefined) {
79
+ teardown()
80
+ reject(new Error(`channel on '${alias}' opened without a stream`))
81
+ return
82
+ }
83
+ resolve({ stream, teardown })
84
+ })
85
+ })
86
+ }
87
+
88
+ /** Open a PTY shell session for the web terminal (standalone connection). */
89
+ async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
90
+ // The shell is a long-lived exclusive stream: use its own connection so
91
+ // closing it can never tear down a pooled exec/tunnel sharing the alias.
92
+ const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
93
+ client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, callback)
94
+ })
95
+ const session: ShellSession = {
96
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
97
+ resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
98
+ signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
99
+ close: () => {
100
+ try { stream.close() } catch { /* channel gone */ }
101
+ teardown()
102
+ },
103
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
104
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
105
+ }
106
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
107
+ stream.on('close', (code: number | null) => {
108
+ teardown()
109
+ session.onExit?.(code)
110
+ })
111
+ stream.on('error', (streamError: Error) => {
112
+ teardown()
113
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
114
+ })
115
+ return session
116
+ }
117
+
118
+ /**
119
+ * Open a streaming exec channel (no PTY) for the remote subprocess seam.
120
+ * Like the PTY shell, the channel rides its own connection so closing it
121
+ * can never tear down a pooled exec/tunnel sharing the alias.
122
+ */
123
+ async openExec(alias: string, command: string): Promise<ExecSession> {
124
+ const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
125
+ client.exec(command, callback)
126
+ })
127
+ let exitListener: ExecSession['onExit']
128
+ let exitRecord: { code: number | null; error?: string } | undefined
129
+ let exitDelivered = false
130
+ const deliverExit = (): void => {
131
+ if (exitDelivered || exitRecord === undefined || exitListener === undefined) return
132
+ exitDelivered = true
133
+ exitListener(exitRecord.code, exitRecord.error)
134
+ }
135
+ const finish = (code: number | null, error?: string): void => {
136
+ if (exitRecord !== undefined) return
137
+ exitRecord = error === undefined ? { code } : { code, error }
138
+ teardown()
139
+ deliverExit()
140
+ }
141
+ const session: ExecSession = {
142
+ stdin: stream,
143
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
144
+ end: (data) => {
145
+ try {
146
+ if (data !== undefined && data !== '') stream.write(data)
147
+ stream.end()
148
+ } catch { /* channel gone */ }
149
+ },
150
+ get onExit() { return exitListener },
151
+ set onExit(listener) {
152
+ exitListener = listener
153
+ deliverExit()
154
+ },
155
+ resize: () => { /* exec channels have no PTY */ },
156
+ signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
157
+ close: () => {
158
+ try { stream.close() } catch { /* channel gone */ }
159
+ teardown()
160
+ },
161
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
162
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
163
+ }
164
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
165
+ stream.stderr.on('data', (chunk: Buffer) => { session.onErrData?.(chunk) })
166
+ stream.on('close', (code: number | null) => { finish(code) })
167
+ stream.on('error', (streamError: Error) => {
168
+ finish(null, streamError instanceof Error ? streamError.message : String(streamError))
169
+ })
170
+ return session
171
+ }
172
+
173
+ /** Close every standalone transport this service still owns. */
174
+ dispose(): void {
175
+ for (const teardown of [...this.activeTeardowns]) teardown()
176
+ }
177
+ }
package/src/ssh/tools.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
15
15
  import type { SshEngine } from './engine.ts'
16
+ import { secureLocalTransferPath } from './local-transfer-policy.ts'
16
17
  import type { ClusterResult, ExecResult, SshHostSummary, TunnelInfo } from './protocol.ts'
17
18
 
18
19
  /**
@@ -23,15 +24,23 @@ export type ToolEnvelope<T extends object> =
23
24
  | ({ ok: true } & T)
24
25
  | { ok: false; error: string }
25
26
 
26
- /** Run one tool operation and fold every thrown error into a failure envelope. */
27
+ /** Run one tool operation and fold every thrown error into a failure envelope.
28
+ *
29
+ * The message passes the engine's leak guard before it reaches the model: a
30
+ * rejected connect/resolve can embed a credential, and this envelope is the
31
+ * one place every tool's failure text is produced. */
27
32
  async function captureToolResult<T extends object>(
33
+ engine: Pick<SshEngine, 'redact'>,
28
34
  operation: () => T | Promise<T>,
29
35
  ): Promise<ToolEnvelope<T>> {
30
36
  try {
31
37
  const value = await operation()
32
38
  return { ok: true, ...value }
33
39
  } catch (error) {
34
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
40
+ const message = error instanceof Error ? error.message : String(error)
41
+ // Tolerate engine doubles that predate the redactor (tests inject minimal
42
+ // fakes); the production engine always provides it.
43
+ return { ok: false, error: typeof engine.redact === 'function' ? engine.redact(message) : message }
35
44
  }
36
45
  }
37
46
 
@@ -135,7 +144,7 @@ export function sshListTool(engine: SshEngine) {
135
144
  },
136
145
  },
137
146
  async execute(args) {
138
- return captureToolResult(() => ({ hosts: engine.list(args.query) }))
147
+ return captureToolResult(engine, () => ({ hosts: engine.list(args.query) }))
139
148
  },
140
149
  })
141
150
  }
@@ -149,7 +158,7 @@ export function sshExecTool(engine: SshEngine) {
149
158
  parameters: {
150
159
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
151
160
  command: { type: 'string', required: true, description: 'The shell command to run remotely.' },
152
- timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000).' },
161
+ timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000). Enforced on the server as well (coreutils timeout), so a timed-out command is stopped instead of left running.' },
153
162
  },
154
163
  output: {
155
164
  schema: {
@@ -172,7 +181,7 @@ export function sshExecTool(engine: SshEngine) {
172
181
  },
173
182
  },
174
183
  async execute(args) {
175
- return captureToolResult(() => engine.exec(args.alias, args.command, args.timeoutMs))
184
+ return captureToolResult(engine, () => engine.exec(args.alias, args.command, args.timeoutMs))
176
185
  },
177
186
  })
178
187
  }
@@ -181,11 +190,11 @@ export function sshExecTool(engine: SshEngine) {
181
190
  export function sshUploadTool(engine: SshEngine) {
182
191
  return defineTool({
183
192
  name: 'ssh_upload',
184
- description: 'Upload a local file to a configured SSH host. The local path is on THIS machine (the dsh host). ' +
193
+ description: 'Upload a local file from the invoking session workspace to a configured SSH host. Local paths outside that workspace are rejected. ' +
185
194
  'Triggers: upload file to server, deploy artifact, copy config to server.',
186
195
  parameters: {
187
196
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
188
- localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
197
+ localPath: { type: 'string', required: true, description: 'Absolute source path inside the invoking session workspace.' },
189
198
  remotePath: { type: 'string', required: true, description: 'Destination path on the remote host (parent dirs are created).' },
190
199
  },
191
200
  output: {
@@ -204,9 +213,10 @@ export function sshUploadTool(engine: SshEngine) {
204
213
  return text(`uploaded ${value.files ?? 1} file(s), ${value.transferredBytes ?? 0} bytes`)
205
214
  },
206
215
  },
207
- async execute(args) {
208
- return captureToolResult(async () => {
209
- const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, false)
216
+ async execute(args, exec) {
217
+ return captureToolResult(engine, async () => {
218
+ const source = secureLocalTransferPath(exec, args.localPath, 'upload-source')
219
+ const outcome = await engine.upload(args.alias, source, args.remotePath, false)
210
220
  return { transferredBytes: outcome.bytes, files: outcome.files }
211
221
  })
212
222
  },
@@ -217,12 +227,12 @@ export function sshUploadTool(engine: SshEngine) {
217
227
  export function sshDownloadTool(engine: SshEngine) {
218
228
  return defineTool({
219
229
  name: 'ssh_download',
220
- description: 'Download a remote FILE from a configured SSH host to a local path on this machine. Directory download is not supported — download files individually. ' +
230
+ description: 'Download a remote FILE into the invoking session workspace. Local paths outside that workspace are rejected; directory download is unsupported. ' +
221
231
  'Triggers: download file from server, fetch remote log/artifact.',
222
232
  parameters: {
223
233
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
224
234
  remotePath: { type: 'string', required: true, description: 'Remote file path.' },
225
- localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
235
+ localPath: { type: 'string', required: true, description: 'Absolute destination path inside the invoking session workspace.' },
226
236
  },
227
237
  output: {
228
238
  schema: {
@@ -239,9 +249,10 @@ export function sshDownloadTool(engine: SshEngine) {
239
249
  return text(`downloaded ${value.bytes ?? 0} bytes`)
240
250
  },
241
251
  },
242
- async execute(args) {
243
- return captureToolResult(async () => {
244
- const outcome = await engine.download(args.alias, args.remotePath, args.localPath)
252
+ async execute(args, exec) {
253
+ return captureToolResult(engine, async () => {
254
+ const destination = secureLocalTransferPath(exec, args.localPath, 'download-destination')
255
+ const outcome = await engine.download(args.alias, args.remotePath, destination)
245
256
  return { bytes: outcome.bytes }
246
257
  })
247
258
  },
@@ -315,7 +326,7 @@ export function sshTunnelTool(engine: SshEngine) {
315
326
  },
316
327
  async execute(args) {
317
328
  if (args.action === 'list') {
318
- return captureToolResult(() => ({ tunnels: engine.listTunnels() }))
329
+ return captureToolResult(engine, () => ({ tunnels: engine.listTunnels() }))
319
330
  }
320
331
  if (args.action === 'start') {
321
332
  const alias = args.alias
@@ -323,7 +334,7 @@ export function sshTunnelTool(engine: SshEngine) {
323
334
  if (alias === undefined || remotePort === undefined) {
324
335
  return { ok: false, error: 'alias and remotePort are required for start' }
325
336
  }
326
- return captureToolResult(async () => ({
337
+ return captureToolResult(engine, async () => ({
327
338
  tunnel: await engine.startTunnel(alias, {
328
339
  remotePort,
329
340
  remoteHost: args.remoteHost,
@@ -336,14 +347,14 @@ export function sshTunnelTool(engine: SshEngine) {
336
347
  if (tunnelId === undefined) {
337
348
  return { ok: false, error: 'tunnelId is required for stop' }
338
349
  }
339
- return captureToolResult(() => {
350
+ return captureToolResult(engine, () => {
340
351
  const stopped = engine.stopTunnel(tunnelId)
341
352
  if (!stopped) throw new Error(`tunnel '${tunnelId}' not found`)
342
353
  return { stopped: 1 }
343
354
  })
344
355
  }
345
356
  if (args.action === 'stop-all') {
346
- return captureToolResult(() => ({ stopped: engine.stopAllTunnels(args.alias) }))
357
+ return captureToolResult(engine, () => ({ stopped: engine.stopAllTunnels(args.alias) }))
347
358
  }
348
359
  return { ok: false, error: `unknown action '${String(args.action)}'` }
349
360
  },
@@ -384,6 +395,14 @@ export function sshClusterTool(engine: SshEngine) {
384
395
  stderr: { type: 'string' },
385
396
  durationMs: { type: 'integer' },
386
397
  error: { type: 'string' },
398
+ // Typed per-host failure detail (B-14): additive optional
399
+ // fields, so an agent can retry by machine code instead of
400
+ // parsing a message. Never carries a credential value.
401
+ code: { type: 'string', enum: ['NEEDS_PASSWORD', 'HOST_KEY_UNKNOWN', 'HOST_KEY_MISMATCH', 'ALIAS_NOT_FOUND', 'ABORTED', 'TIMEOUT', 'ERROR'] },
402
+ secret: { type: 'string', enum: ['password', 'passphrase'] },
403
+ hostKeyFingerprint: { type: 'string' },
404
+ expected: { type: 'string' },
405
+ actual: { type: 'string' },
387
406
  },
388
407
  },
389
408
  },
@@ -396,7 +415,10 @@ export function sshClusterTool(engine: SshEngine) {
396
415
  },
397
416
  },
398
417
  async execute(args) {
399
- return captureToolResult(() => engine.cluster(args))
418
+ // The engine returns a bare array; the envelope (and this tool's own
419
+ // output schema) requires a `results` field. Spreading the array
420
+ // produced numeric keys instead, so the payload was unusable.
421
+ return captureToolResult(engine, async () => ({ results: await engine.cluster(args) }))
400
422
  },
401
423
  })
402
424
  }