@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +36 -11
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -99
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/ssh/vault.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
18
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
19
19
  import { dirname, join } from 'node:path'
20
20
  import { homedir } from 'node:os'
21
21
 
@@ -41,6 +41,8 @@ const LOCKOUT_FLOOR_MS = 30_000
41
41
  const LOCKOUT_CAP_MS = 3_600_000
42
42
  /** Fixed plaintext used to verify the unlock password. */
43
43
  const VERIFIER_TEXT = 'dsh-hardssh:vault:verify:v1'
44
+ /** Minimum non-whitespace master-password length. */
45
+ export const MIN_MASTER_PASSWORD_LENGTH = 8
44
46
 
45
47
  /** Types of secrets the vault stores. */
46
48
  export type VaultPurpose = 'host.password' | 'host.passphrase'
@@ -83,11 +85,64 @@ interface VaultDocument {
83
85
  secrets: VaultEntry[]
84
86
  }
85
87
 
86
- /** Default vault file location: <home>/.dsh/dsh-ssh-vault.json. */
88
+ /**
89
+ * Directory holding the credential vault: `<home>/.dsh/ssh-secrets`.
90
+ *
91
+ * It IS inside `~/.dsh` (which the fs seam declares a local-infrastructure root
92
+ * so skills and plugin config stay readable), so its protection does NOT come
93
+ * from location — it comes from `SwitchFileSystem.deniedRoots` (fs.ts), which
94
+ * refuses this directory on every dispatch path, resolve to write, before any
95
+ * backend sees it.
96
+ *
97
+ * Residual risk, stated plainly: a command that runs on THIS machine as the
98
+ * same user (e.g. the client-side `pwsh` tool) can still read the file. What
99
+ * keeps the credential safe is that the file is encrypted (AES-256-GCM +
100
+ * scrypt) AND that environment auto-unlock is off by default — so a stolen
101
+ * ciphertext is only an offline scrypt target, not a usable credential.
102
+ */
103
+ export function vaultDirectory(): string {
104
+ return join(homedir(), '.dsh', 'ssh-secrets')
105
+ }
106
+
107
+ /** Vault file location: <home>/.dsh/ssh-secrets/dsh-ssh-vault.json. */
87
108
  export function vaultPath(): string {
109
+ return join(vaultDirectory(), 'dsh-ssh-vault.json')
110
+ }
111
+
112
+ /** Pre-relocation vault location (<home>/.dsh/dsh-ssh-vault.json). */
113
+ export function legacyVaultPath(): string {
88
114
  return join(homedir(), '.dsh', 'dsh-ssh-vault.json')
89
115
  }
90
116
 
117
+ /**
118
+ * Move a pre-relocation vault into {@link vaultDirectory} on first use.
119
+ *
120
+ * Never destructive: the new location wins when both exist, and a failed move
121
+ * falls back to copying so the source file is left in place.
122
+ *
123
+ * @param target - destination path (defaults to {@link vaultPath}).
124
+ * @param legacy - source path (defaults to {@link legacyVaultPath}).
125
+ * @returns true when a move/copy happened.
126
+ */
127
+ export function migrateLegacyVault(target: string = vaultPath(), legacy: string = legacyVaultPath()): boolean {
128
+ if (target === legacy) return false
129
+ if (existsSync(target) || !existsSync(legacy)) return false
130
+ try {
131
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
132
+ try {
133
+ renameSync(legacy, target)
134
+ } catch {
135
+ // Cross-device or locked source: copy the bytes and keep the original.
136
+ const contents = readFileSync(legacy)
137
+ writeFileSync(target, contents, { mode: 0o600 })
138
+ }
139
+ return true
140
+ } catch {
141
+ // Best-effort: an unmigratable legacy file still loads from its old path.
142
+ return false
143
+ }
144
+ }
145
+
91
146
  /** AAD for one entry. */
92
147
  function entryAad(purpose: VaultPurpose, alias: string): string {
93
148
  return `dsh-hardssh:v1:${purpose}:${alias}`
@@ -143,35 +198,45 @@ export interface RevealedSecret {
143
198
  */
144
199
  export class Vault {
145
200
  private readonly path: string
146
- private readonly passwordProvider: (() => Promise<string | undefined>) | undefined
147
201
  private document: VaultDocument = emptyDocument()
148
202
  private unlockedKey: Buffer | undefined
149
203
  private lockTimer: ReturnType<typeof setTimeout> | undefined
204
+ private readonly idleLockMs: number
150
205
  private readonly leaked = new Set<string>()
206
+ /** Whether `DSH_CREDENTIAL_PASSWORD` may unlock this instance at load. */
207
+ private readonly allowEnvUnlock: boolean
208
+ /** Single-instance mutation queue. Every operation that can change the
209
+ * in-memory document or persist it runs in invocation order. The tail is
210
+ * always recovered so one rejected mutation never poisons later work. */
211
+ private mutationTail: Promise<void> = Promise.resolve()
151
212
 
152
213
  constructor(
153
214
  filePath?: string,
154
215
  options?: {
155
- /** Session password source (loopback unlock route passes a password). */
156
- passwordProvider?: () => Promise<string | undefined>
157
216
  /** Vault idle auto-lock (ms); 0 disables (default 30 min). */
158
217
  idleLockMs?: number
218
+ /**
219
+ * Allow unlocking from `DSH_CREDENTIAL_PASSWORD` at construction.
220
+ * OFF by default: that variable is visible to anything running as the
221
+ * same user — including an agent session — so auto-unlock is an explicit
222
+ * opt-in (`vaultAutoUnlock: 'env'` in the plugin config), not a default.
223
+ */
224
+ allowEnvUnlock?: boolean
159
225
  },
160
226
  ) {
227
+ if (filePath === undefined) migrateLegacyVault()
161
228
  this.path = filePath ?? vaultPath()
162
- this.passwordProvider = options?.passwordProvider
229
+ this.allowEnvUnlock = options?.allowEnvUnlock === true
163
230
  this.load()
164
- // Auto-unlock from the DSH_CREDENTIAL_PASSWORD environment variable when
165
- // present (headless / agent-friendly deterministic channel).
166
- const env = process.env.DSH_CREDENTIAL_PASSWORD
231
+ // Opt-in auto-unlock from DSH_CREDENTIAL_PASSWORD (headless / unattended
232
+ // agents that deliberately enable it).
233
+ const env = this.allowEnvUnlock ? process.env.DSH_CREDENTIAL_PASSWORD : undefined
167
234
  if (env !== undefined && env !== '') {
168
235
  void this.tryEnvUnlock(env)
169
236
  }
170
237
  const idle = options?.idleLockMs ?? 30 * 60_000
171
- if (idle > 0) {
172
- this.lockTimer = setInterval(() => { this.lock() }, idle)
173
- this.lockTimer.unref?.()
174
- }
238
+ if (!Number.isSafeInteger(idle) || idle < 0) throw new VaultError('VAULT_CONFIG', 'idleLockMs must be a non-negative safe integer')
239
+ this.idleLockMs = idle
175
240
  }
176
241
 
177
242
  // ------------------------------------------------------------- internals
@@ -192,12 +257,19 @@ export class Vault {
192
257
  }
193
258
  }
194
259
 
195
- private save(): void {
260
+ private save(): void { this.saveDocument(this.document) }
261
+
262
+ private saveDocument(document: VaultDocument): void {
196
263
  const dir = dirname(this.path)
197
264
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
198
- const tmp = this.path + '.tmp'
199
- writeFileSync(tmp, JSON.stringify(this.document, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
200
- renameSync(tmp, this.path)
265
+ const tmp = this.path + `.tmp-${process.pid}-${randomBytes(6).toString('hex')}`
266
+ try {
267
+ writeFileSync(tmp, JSON.stringify(document, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
268
+ renameSync(tmp, this.path)
269
+ } catch (error) {
270
+ try { unlinkSync(tmp) } catch { /* preserve the persistence error */ }
271
+ throw error
272
+ }
201
273
  }
202
274
 
203
275
  private deriveKey(password: string, kdf: VaultKdfParams): Promise<Buffer> {
@@ -237,6 +309,10 @@ export class Vault {
237
309
 
238
310
  private async verifyPassword(key: Buffer, password: string): Promise<void> {
239
311
  const doc = this.document
312
+ const previousMeta = { ...doc.meta }
313
+ const persistFailure = (): void => {
314
+ try { this.save() } catch (error) { doc.meta = previousMeta; throw error }
315
+ }
240
316
  const verifier = doc.verifier
241
317
  try {
242
318
  const plain = this.decryptEntryBuffer(key, verifier.aad, verifier.nonceB64, verifier.ciphertextB64, verifier.tagB64)
@@ -247,10 +323,10 @@ export class Vault {
247
323
  if (doc.meta.attempts >= LOCKOUT_THRESHOLD) {
248
324
  const backoff = backoffFor(doc.meta.attempts)
249
325
  doc.meta.lockedUntil = Date.now() + backoff
250
- this.save()
326
+ persistFailure()
251
327
  throw new VaultLockoutError(`vault locked after ${doc.meta.attempts} failed attempts — retry after ${Math.ceil(backoff / 1000)}s`, backoff)
252
328
  }
253
- this.save()
329
+ persistFailure()
254
330
  throw new VaultAuthError(`wrong vault password (${doc.meta.attempts}/${LOCKOUT_THRESHOLD} attempts used)`, LOCKOUT_THRESHOLD - doc.meta.attempts)
255
331
  }
256
332
  }
@@ -283,6 +359,30 @@ export class Vault {
283
359
  return this.unlockedKey
284
360
  }
285
361
 
362
+ private validateMasterPassword(password: string): void {
363
+ if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
364
+ throw new VaultError('VAULT_PASSWORD_WEAK', `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters`)
365
+ }
366
+ }
367
+
368
+ /** Successful credential activity extends the idle lock deadline. */
369
+ private touch(): void {
370
+ if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
371
+ this.lockTimer = undefined
372
+ if (this.unlockedKey === undefined || this.idleLockMs === 0) return
373
+ this.lockTimer = setTimeout(() => { this.lock() }, this.idleLockMs)
374
+ this.lockTimer.unref?.()
375
+ }
376
+
377
+ private enqueueMutation<T>(mutation: () => Promise<T> | T): Promise<T> {
378
+ const result = this.mutationTail.then(mutation)
379
+ this.mutationTail = result.then(
380
+ () => undefined,
381
+ () => undefined,
382
+ )
383
+ return result
384
+ }
385
+
286
386
  private async tryEnvUnlock(password: string): Promise<void> {
287
387
  try {
288
388
  await this.unlock(password)
@@ -298,60 +398,92 @@ export class Vault {
298
398
  const hasEntries = this.document.secrets.length > 0
299
399
  return {
300
400
  locked: this.unlockedKey === undefined || hasEntries === false && this.document.verifier.ciphertextB64 === '',
301
- mode: process.env.DSH_CREDENTIAL_PASSWORD !== undefined ? 'env' : 'password',
401
+ mode: this.allowEnvUnlock && process.env.DSH_CREDENTIAL_PASSWORD !== undefined ? 'env' : 'password',
302
402
  entries: this.document.secrets.length,
303
403
  }
304
404
  }
305
405
 
306
406
  /** Unlock with a password (loopback route / env). Persists lockout on failure. */
307
407
  async unlock(password: string): Promise<void> {
308
- if (this.unlockedKey !== undefined) return
309
- this.checkLockout()
310
- const doc = this.document
311
- if (doc.verifier.ciphertextB64 === '') {
312
- // First unlock: create the verifier with this password.
313
- const salt = randomBytes(SALT_BYTES)
314
- const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
315
- const key = await this.deriveKey(password, kdf)
316
- doc.kdf = kdf
317
- const box = this.encryptEntryBuffer(key, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
318
- doc.verifier = { ...box, aad: entryAad('host.password', 'vault-verify') }
408
+ return this.enqueueMutation(async () => {
409
+ this.validateMasterPassword(password)
410
+ if (this.unlockedKey !== undefined) { this.touch(); return }
411
+ this.checkLockout()
412
+ const doc = this.document
413
+ if (doc.verifier.ciphertextB64 === '') {
414
+ // First unlock: create the verifier with this password.
415
+ const salt = randomBytes(SALT_BYTES)
416
+ const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
417
+ const key = await this.deriveKey(password, kdf)
418
+ const box = this.encryptEntryBuffer(key, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
419
+ const next: VaultDocument = {
420
+ ...doc,
421
+ kdf,
422
+ verifier: { ...box, aad: entryAad('host.password', 'vault-verify') },
423
+ }
424
+ try {
425
+ this.saveDocument(next)
426
+ } catch (error) {
427
+ key.fill(0)
428
+ throw error
429
+ }
430
+ this.document = next
431
+ this.unlockedKey = key
432
+ this.touch()
433
+ return
434
+ }
435
+ const key = await this.deriveKey(password, doc.kdf)
436
+ try {
437
+ await this.verifyPassword(key, password)
438
+ } catch (error) {
439
+ key.fill(0)
440
+ throw error
441
+ }
319
442
  this.unlockedKey = key
320
- this.save()
321
- return
322
- }
323
- const key = await this.deriveKey(password, doc.kdf)
324
- await this.verifyPassword(key, password)
325
- this.unlockedKey = key
443
+ this.touch()
444
+ })
326
445
  }
327
446
 
328
447
  /** Lock: drop the in-memory key. JS strings cannot be zeroized; the Buffer
329
448
  * key is wiped. */
330
449
  lock(): void {
450
+ if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
451
+ this.lockTimer = undefined
331
452
  if (this.unlockedKey !== undefined) {
332
453
  this.unlockedKey.fill(0)
333
454
  this.unlockedKey = undefined
334
455
  }
456
+ // JS strings cannot be zeroized, but dropping every reference bounds the
457
+ // leak-guard plaintext lifetime to one unlocked activity session.
458
+ this.leaked.clear()
335
459
  }
336
460
 
337
461
  /** Store one secret, returning a random ref. Requires unlock. */
338
462
  async store(purpose: VaultPurpose, alias: string, secret: string): Promise<string> {
339
- const key = this.requireKey()
340
- const ref = 'vlt_' + randomBytes(16).toString('hex')
341
- const aad = entryAad(purpose, alias)
342
- const box = this.encryptEntryBuffer(key, aad, Buffer.from(secret, 'utf8'))
343
- const data = `${box.nonceB64}${box.ciphertextB64}${box.tagB64}`
344
- const entry: VaultEntry = {
345
- ref,
346
- purpose,
347
- alias,
348
- ...box,
349
- aad,
350
- sha3: sha3_256Hex(data),
351
- }
352
- this.document.secrets.push(entry)
353
- this.save()
354
- return ref
463
+ return this.enqueueMutation(() => {
464
+ const key = this.requireKey()
465
+ const ref = 'vlt_' + randomBytes(16).toString('hex')
466
+ const aad = entryAad(purpose, alias)
467
+ const box = this.encryptEntryBuffer(key, aad, Buffer.from(secret, 'utf8'))
468
+ const data = `${box.nonceB64}${box.ciphertextB64}${box.tagB64}`
469
+ const entry: VaultEntry = {
470
+ ref,
471
+ purpose,
472
+ alias,
473
+ ...box,
474
+ aad,
475
+ sha3: sha3_256Hex(data),
476
+ }
477
+ this.document.secrets.push(entry)
478
+ try {
479
+ this.save()
480
+ } catch (error) {
481
+ this.document.secrets.pop()
482
+ throw error
483
+ }
484
+ this.touch()
485
+ return ref
486
+ })
355
487
  }
356
488
 
357
489
  /** Reveal one secret (unlock required); registers it for redaction. */
@@ -368,53 +500,75 @@ export class Vault {
368
500
  const text = plain.toString('utf8')
369
501
  plain.fill(0)
370
502
  this.leaked.add(text)
503
+ this.touch()
371
504
  return text
372
505
  }
373
506
 
374
507
  /** Delete one secret (host delete / cleanup). */
375
508
  async remove(ref: string): Promise<void> {
376
- const before = this.document.secrets.length
377
- this.document.secrets = this.document.secrets.filter(entry => entry.ref !== ref)
378
- if (this.document.secrets.length !== before) this.save()
509
+ return this.enqueueMutation(() => {
510
+ this.requireKey()
511
+ const before = this.document.secrets
512
+ const next = before.filter(entry => entry.ref !== ref)
513
+ if (next.length !== before.length) {
514
+ this.document.secrets = next
515
+ try { this.save() } catch (error) { this.document.secrets = before; throw error }
516
+ }
517
+ this.touch()
518
+ })
379
519
  }
380
520
 
381
521
  /** Rekey with a new password: decrypt all, re-encrypt under a fresh KDF. */
382
522
  async rekey(newPassword: string): Promise<void> {
383
- const key = this.requireKey()
384
- const doc = this.document
385
- // Decrypt everything with the current key.
386
- const plaintexts: Array<{ purpose: VaultPurpose; alias: string; value: string }> = []
387
- for (const entry of doc.secrets) {
388
- const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64)
389
- plaintexts.push({ purpose: entry.purpose, alias: entry.alias, value: plain.toString('utf8') })
390
- plain.fill(0)
391
- }
392
- // Fresh KDF + verifier under the new password.
393
- const salt = randomBytes(SALT_BYTES)
394
- const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
395
- const newKey = await this.deriveKey(newPassword, kdf)
396
- doc.kdf = kdf
397
- const box = this.encryptEntryBuffer(newKey, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
398
- doc.verifier = { ...box, aad: entryAad('host.password', 'vault-verify') }
399
- doc.secrets = plaintexts.map(({ purpose, alias, value }, index) => {
400
- const aad = entryAad(purpose, alias)
401
- const b = this.encryptEntryBuffer(newKey, aad, Buffer.from(value, 'utf8'))
402
- const data = `${b.nonceB64}${b.ciphertextB64}${b.tagB64}`
403
- return {
404
- // Preserve the original ref: SshHostEntry.auth.secretRef persists it.
405
- ref: rekeyOriginalRef(this.document.secrets[index]),
406
- purpose,
407
- alias,
408
- ...b,
409
- aad,
410
- sha3: sha3_256Hex(data),
411
- } satisfies VaultEntry
523
+ return this.enqueueMutation(async () => {
524
+ this.validateMasterPassword(newPassword)
525
+ const key = this.requireKey()
526
+ const doc = this.document
527
+ // Decrypt everything with the current key.
528
+ const plaintexts: Array<{ purpose: VaultPurpose; alias: string; value: string }> = []
529
+ for (const entry of doc.secrets) {
530
+ const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64)
531
+ plaintexts.push({ purpose: entry.purpose, alias: entry.alias, value: plain.toString('utf8') })
532
+ plain.fill(0)
533
+ }
534
+ // Fresh KDF + verifier under the new password.
535
+ const salt = randomBytes(SALT_BYTES)
536
+ const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
537
+ const newKey = await this.deriveKey(newPassword, kdf)
538
+ try {
539
+ const box = this.encryptEntryBuffer(newKey, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
540
+ const secrets = plaintexts.map(({ purpose, alias, value }, index) => {
541
+ const aad = entryAad(purpose, alias)
542
+ const b = this.encryptEntryBuffer(newKey, aad, Buffer.from(value, 'utf8'))
543
+ const data = `${b.nonceB64}${b.ciphertextB64}${b.tagB64}`
544
+ return {
545
+ ref: rekeyOriginalRef(doc.secrets[index]),
546
+ purpose,
547
+ alias,
548
+ ...b,
549
+ aad,
550
+ sha3: sha3_256Hex(data),
551
+ } satisfies VaultEntry
552
+ })
553
+ const next: VaultDocument = {
554
+ ...doc,
555
+ kdf,
556
+ verifier: { ...box, aad: entryAad('host.password', 'vault-verify') },
557
+ meta: { attempts: 0, lockedUntil: 0 },
558
+ secrets,
559
+ }
560
+ this.saveDocument(next)
561
+ key.fill(0)
562
+ this.document = next
563
+ this.unlockedKey = newKey
564
+ this.touch()
565
+ } catch (error) {
566
+ newKey.fill(0)
567
+ throw error
568
+ } finally {
569
+ for (const item of plaintexts) item.value = ''
570
+ }
412
571
  })
413
- this.unlockedKey?.fill(0)
414
- this.unlockedKey = newKey
415
- doc.meta.attempts = 0
416
- doc.meta.lockedUntil = 0
417
- this.save()
418
572
  }
419
573
 
420
574
  /** Redact every leaked secret value in `text` (Leak Guard light). */
@@ -429,7 +583,7 @@ export class Vault {
429
583
 
430
584
  /** Dispose: wipe the key and stop the idle timer. */
431
585
  dispose(): void {
432
- if (this.lockTimer !== undefined) clearInterval(this.lockTimer)
586
+ if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
433
587
  this.lock()
434
588
  }
435
589
  }
package/src/subprocess.ts CHANGED
@@ -1,71 +1,88 @@
1
- /**
2
- * The `ctx.subprocess` switch row: provides the workspace-routing subprocess
3
- * facade in the host scope. The local runtime is mounted in an isolated
4
- * child scope; every SSH-bound workspace record gets its OWN remote runtime
5
- * bound to that record's alias + remote root; the facade routes each spawn by
6
- * its cwd. The facade auto-provides `subprocess` here because the plain
7
- * subprocess row is disabled by the profile patch.
8
- *
9
- * Routing and per-record remote runtimes are owned by the SHARED seam state
10
- * (hardsshCore.seams): the row binds its per-record runtime factory and reads
11
- * the state on every spawn. The seam state re-applies the ledger snapshot
12
- * synchronously on every commit, so creating or removing an SSH workspace
13
- * takes effect without a plugin restart and the fs/subprocess seams can never
14
- * disagree. Before the initial ledger load finishes, routing degrades to
15
- * local with a one-time warning for anchor-root paths.
16
- *
17
- * @module dsh-hardssh/subprocess
18
- */
19
-
20
- import type { Context } from '@deepseek-ai/cordis'
21
- import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
22
- import type { HardsshCore } from './core.ts'
23
- import { isPathUnderAnchor } from './ledger.ts'
24
- import type { WorkspaceState } from './protocol.ts'
25
- import { SshSubprocessRuntime } from './remote/remote-subprocess.ts'
26
- import { SwitchSubprocessRuntime } from './switch/switch-subprocess.ts'
27
-
28
- declare module '@deepseek-ai/cordis' {
29
- interface Context {
30
- hardsshCore: HardsshCore
31
- }
32
- }
33
-
34
- /** Stable cordis plugin name. */
35
- export const name = 'hardssh-subprocess'
36
-
37
- /** Services required: the shared workspace core (mode store + engine). */
38
- export const inject = ['hardsshCore']
39
-
40
- /** A remote runtime fixed to one SSH workspace's alias + remote root. */
41
- function fixedRemoteState(alias: string, remoteRoot: string): () => WorkspaceState {
42
- return () => ({ mode: 'remote' as const, alias, remoteRoot })
43
- }
44
-
45
- /** Mount the switching subprocess facade. */
46
- export function apply(ctx: Context): void {
47
- const core = ctx.hardsshCore
48
-
49
- // Local runtime in an isolated scope (its `subprocess` provide shadows only
50
- // below this scope). Construct directly and keep the instance.
51
- const localCtx = ctx.isolate('subprocess')
52
- const localSubprocess = new LocalSubprocessRuntime(localCtx)
53
-
54
- // Bind the per-record remote runtime builder into the shared seam state;
55
- // instances are built lazily on first route and reused across refreshes.
56
- core.seams.bindSub((record) => new SshSubprocessRuntime(ctx.isolate('subprocess'), core.engine, fixedRemoteState(record.alias, record.remoteRoot)))
57
-
58
- const anchorRoot = core.ledger.anchorsRoot()
59
- let warnedUnready = false
60
-
61
- new SwitchSubprocessRuntime(ctx, {
62
- local: localSubprocess,
63
- worldFor: (cwd) => {
64
- if (!core.seams.isReady() && !warnedUnready && cwd !== undefined && isPathUnderAnchor(anchorRoot, cwd)) {
65
- warnedUnready = true
66
- console.warn('[dsh-hardssh] subprocess routing is not ready yet (workspace ledger still loading) running locally until the snapshot is applied')
67
- }
68
- return core.seams.runtimeForSub(cwd) ?? localSubprocess
69
- },
70
- })
71
- }
1
+ /**
2
+ * The `ctx.subprocess` switch row: provides the generic WorkspaceCore-routing
3
+ * subprocess facade in the host scope. The local runtime is mounted in an
4
+ * isolated child scope; each spawn resolves by cwd through the same workspace
5
+ * router used by the filesystem seam.
6
+ *
7
+ * A bound connection supplies `workspace.process`. Before the core is ready,
8
+ * or for an unowned cwd beneath the managed SSH anchor root, spawning fails
9
+ * closed rather than executing on the client machine.
10
+ *
11
+ * @module dsh-hardssh/subprocess
12
+ */
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
16
+ import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
17
+ import { anchorRoot, isPathUnderAnchor } from './ledger.ts'
18
+ import { SwitchSubprocessRuntime } from './switch/switch-subprocess.ts'
19
+ import type { WorkspaceCore } from './runtime/workspace-core.ts'
20
+ import type { WorkspaceRecord } from './base/model.ts'
21
+
22
+ /** Stable cordis plugin name. */
23
+ export const name = 'hardssh-subprocess'
24
+
25
+ /** Workspace routing is exclusively provided by the generic core. */
26
+ export const inject = ['workspaceCore']
27
+
28
+ /** The record behind a router connection (sync snapshot lookup). */
29
+ function genericRecordFor(core: WorkspaceCore, id: string): WorkspaceRecord | undefined {
30
+ return core.ledger.snapshotSync().records.find(record => record.id === id)
31
+ }
32
+
33
+ /** Resolve the subprocess runtime owning a cwd, or undefined when local. */
34
+ export function genericSubprocessFor(
35
+ core: WorkspaceCore,
36
+ cwd: string | undefined,
37
+ reservedAnchorRoots: readonly string[],
38
+ ): SubprocessRuntime | undefined {
39
+ if (!core.isReady()) {
40
+ if (cwd !== undefined && reservedAnchorRoots.some(root => isPathUnderAnchor(root, cwd))) {
41
+ throw new Error('subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
42
+ }
43
+ return undefined
44
+ }
45
+ const connection = core.router.fromAnchor(cwd)
46
+ if (connection === undefined) return undefined
47
+ const record = genericRecordFor(core, connection.workspaceId)
48
+ if (record === undefined) throw new Error(`subprocess-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`)
49
+ const runtime = connection.get('workspace.process') as SubprocessRuntime | undefined
50
+ if (runtime === undefined) throw new Error(`subprocess-workspace: workspace '${record.id}' provides no workspace.process capability`)
51
+ return runtime
52
+ }
53
+
54
+ /** Mount the generic switching subprocess facade. */
55
+ export function apply(ctx: Context): void {
56
+ const localCtx = ctx.isolate('subprocess')
57
+ const localSubprocess = new LocalSubprocessRuntime(localCtx)
58
+ const ws = ctx.workspaceCore
59
+ const anchorRootDir = anchorRoot()
60
+ let warnedUnready = false
61
+
62
+ new SwitchSubprocessRuntime(ctx, {
63
+ local: localSubprocess,
64
+ // `undefined` means LOCAL. Returning the local runtime here instead would
65
+ // force the facade to identity-compare it against `deps.local`, and that
66
+ // comparison is not reliable for a container-provided serviceit once
67
+ // made the client-search refusal fire for local sessions and broke
68
+ // glob/grep in every session.
69
+ worldFor: (cwd) => {
70
+ if (!ws.isReady()) {
71
+ if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
72
+ if (!warnedUnready) {
73
+ warnedUnready = true
74
+ console.warn('[dsh-hardssh] subprocess routing is not ready yet (workspace core still initializing or failed) — refusing to run beneath the workspace anchor root')
75
+ }
76
+ throw new Error('subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
77
+ }
78
+ return undefined
79
+ }
80
+ const runtime = genericSubprocessFor(ws, cwd, [anchorRootDir])
81
+ if (runtime !== undefined) return runtime
82
+ if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
83
+ throw new Error(`subprocess-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
84
+ }
85
+ return undefined
86
+ },
87
+ })
88
+ }