@tiphareth/dsh-hardssh 0.1.2-alpha

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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Host config store: one JSON file (`~/.dsh/dsh-ssh.json`) holding every
3
+ * SSH host entry, written atomically (tmp + rename). Also parses the user's
4
+ * standard `~/.ssh/config` for one-shot import. Secrets (passwords,
5
+ * passphrases) live in this user-owned file in plaintext — same trust model
6
+ * as ssh-skill's annotated ssh-config comments; document it, never log it.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import type { HostPayload, ImportResult, SshAuthKind, SshHostEntry, SshHostSummary } from './protocol.ts'
13
+
14
+ /** File format version. */
15
+ const FORMAT_VERSION = 1
16
+
17
+ /** Store file location: <home>/.dsh/dsh-ssh.json. */
18
+ export function storePath(): string {
19
+ return join(homedir(), '.dsh', 'dsh-ssh.json')
20
+ }
21
+
22
+ /** The user's standard OpenSSH config path. */
23
+ export function sshConfigPath(): string {
24
+ return join(homedir(), '.ssh', 'config')
25
+ }
26
+
27
+ interface StoreFile {
28
+ version: number
29
+ hosts: SshHostEntry[]
30
+ }
31
+
32
+ /** Payload shape the validator enforces: full create vs partial patch. */
33
+ export type HostPayloadValidationMode = 'create' | 'patch'
34
+
35
+ /** Validate one auth block; returns a message or undefined.
36
+ * With `allowSecretless`, a password-kind block may omit the password (and
37
+ * key-kind may omit the keyPath, meaning "use the ssh-agent") — both shapes
38
+ * are produced by SecureHostStore when secrets live elsewhere (vault ref)
39
+ * or are never persisted ('none' mode, VSCode Remote-SSH style). */
40
+ function validateAuth(auth: unknown, allowSecretless: boolean): string | undefined {
41
+ if (typeof auth !== 'object' || auth === null) return 'auth must be an object'
42
+ const a = auth as Record<string, unknown>
43
+ if (a.kind !== 'key' && a.kind !== 'password') return 'auth.kind must be key or password'
44
+ if (a.kind === 'key' && (typeof a.keyPath !== 'string' || a.keyPath.trim() === '') && !allowSecretless) {
45
+ return 'auth.keyPath is required for key auth'
46
+ }
47
+ if (a.kind === 'password' && typeof a.password !== 'undefined' && typeof a.password !== 'string') {
48
+ return 'auth.password must be a string'
49
+ }
50
+ if (!allowSecretless && a.kind === 'password' && (typeof a.password !== 'string' || a.password === '')) {
51
+ return 'auth.password is required for password auth'
52
+ }
53
+ if (a.secretRef !== undefined && (typeof a.secretRef !== 'string' || a.secretRef === '')) {
54
+ return 'auth.secretRef must be a non-empty string'
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ /** Validate the wire shape of a host payload; returns a message or undefined.
60
+ * `mode` picks the required fields: 'create' demands host/user/auth,
61
+ * 'patch' only validates the fields that are present (same rules).
62
+ * `allowSecretless` relaxes the password requirement so SecureHostStore can
63
+ * persist stripped entries (secrets in vault / never persisted). */
64
+ export function validateHostPayload(
65
+ payload: unknown,
66
+ mode: HostPayloadValidationMode = 'create',
67
+ allowSecretless = false,
68
+ ): string | undefined {
69
+ if (typeof payload !== 'object' || payload === null) return 'body must be a JSON object'
70
+ const p = payload as Record<string, unknown>
71
+ if (mode === 'create') {
72
+ if (typeof p.host !== 'string' || p.host.trim() === '') return 'host is required'
73
+ if (typeof p.user !== 'string' || p.user.trim() === '') return 'user is required'
74
+ } else {
75
+ if (p.host !== undefined && (typeof p.host !== 'string' || p.host.trim() === '')) return 'host is required'
76
+ if (p.user !== undefined && (typeof p.user !== 'string' || p.user.trim() === '')) return 'user is required'
77
+ }
78
+ if (p.auth !== undefined) {
79
+ const authError = validateAuth(p.auth, allowSecretless)
80
+ if (authError !== undefined) return authError
81
+ } else if (mode === 'create') {
82
+ return 'auth is required'
83
+ }
84
+ if (p.port !== undefined && (typeof p.port !== 'number' || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
85
+ return 'port must be an integer in 1..65535'
86
+ }
87
+ if (p.proxyJump !== undefined && (!Array.isArray(p.proxyJump) || p.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
88
+ return 'proxyJump must be an array of alias strings'
89
+ }
90
+ if (p.tags !== undefined && (!Array.isArray(p.tags) || p.tags.some(x => typeof x !== 'string'))) {
91
+ return 'tags must be an array of strings'
92
+ }
93
+ return undefined
94
+ }
95
+
96
+ /**
97
+ * Detect a proxyJump cycle reachable from `startAlias` across the full host
98
+ * list (a hop's own chain is followed transitively). Returns a readable
99
+ * path like 'a -> b -> a', or undefined. Dangling hops (alias with no
100
+ * entry) are terminal edges, not cycles.
101
+ */
102
+ function findJumpCycle(startAlias: string, entries: Array<{ alias: string; proxyJump: string[] }>): string | undefined {
103
+ const byAlias = new Map(entries.map(entry => [entry.alias, entry]))
104
+ const walked = new Set<string>()
105
+ const walk = (alias: string, path: string[]): string | undefined => {
106
+ const at = path.indexOf(alias)
107
+ if (at >= 0) return [...path.slice(at), alias].join(' -> ')
108
+ if (walked.has(alias)) return undefined
109
+ walked.add(alias)
110
+ const entry = byAlias.get(alias)
111
+ if (entry === undefined) return undefined
112
+ for (const hop of entry.proxyJump) {
113
+ const cycle = walk(hop, [...path, alias])
114
+ if (cycle !== undefined) return cycle
115
+ }
116
+ return undefined
117
+ }
118
+ return walk(startAlias, [])
119
+ }
120
+
121
+ /** The alias grammar (same as the scaffold rule: lowercase, digits, single hyphens). */
122
+ const ALIAS_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
123
+
124
+ /** Validate an alias for creation. */
125
+ export function validateAlias(alias: string): string | undefined {
126
+ if (!ALIAS_RE.test(alias)) return 'alias must be lowercase letters, digits and single hyphens'
127
+ return undefined
128
+ }
129
+
130
+ /**
131
+ * The host store. Pure file I/O — no cordis dependency, unit-testable.
132
+ */
133
+ export class HostStore {
134
+ /** The JSON file path. */
135
+ readonly path: string
136
+ /** Optional override of the ~/.ssh/config path (tests). */
137
+ private readonly sshConfigOverride: string | undefined
138
+
139
+ /**
140
+ * @param path - store file path (defaults to the standard location).
141
+ * @param sshConfigOverride - ssh config path override (tests only).
142
+ */
143
+ constructor(path?: string, sshConfigOverride?: string) {
144
+ this.path = resolve(path ?? storePath())
145
+ this.sshConfigOverride = sshConfigOverride
146
+ }
147
+
148
+ /** Load all entries (empty store when the file is absent). */
149
+ list(): SshHostEntry[] {
150
+ const file = this.load()
151
+ return file.hosts
152
+ }
153
+
154
+ /** Find one entry by alias. */
155
+ find(alias: string): SshHostEntry | undefined {
156
+ return this.list().find(entry => entry.alias === alias)
157
+ }
158
+
159
+ /** Secret-free projection for the browser and agent surfaces. */
160
+ summarize(entry: SshHostEntry): SshHostSummary {
161
+ let keyReady = true
162
+ if (entry.auth.kind === 'key' && entry.auth.keyPath) {
163
+ keyReady = existsSync(expandHome(entry.auth.keyPath))
164
+ }
165
+ return {
166
+ alias: entry.alias,
167
+ host: entry.host,
168
+ port: entry.port,
169
+ user: entry.user,
170
+ auth: entry.auth.kind,
171
+ keyReady,
172
+ proxyJump: [...entry.proxyJump],
173
+ // Optional fields are spread conditionally: the tool bridge rejects
174
+ // undefined-valued properties as non-lossless JSON.
175
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
176
+ ...(entry.environment !== undefined ? { environment: entry.environment } : {}),
177
+ tags: [...entry.tags],
178
+ ...(entry.location !== undefined ? { location: entry.location } : {}),
179
+ createdAt: entry.createdAt,
180
+ updatedAt: entry.updatedAt,
181
+ }
182
+ }
183
+
184
+ /** Create one entry. Throws on alias collision or invalid payload. */
185
+ create(payload: HostPayload, allowSecretless = false): SshHostEntry {
186
+ const alias = payload.alias?.trim()
187
+ if (!alias) throw new Error('alias is required')
188
+ const aliasError = validateAlias(alias)
189
+ if (aliasError !== undefined) throw new Error(aliasError)
190
+ const bodyError = validateHostPayload(payload, 'create', allowSecretless)
191
+ if (bodyError !== undefined) throw new Error(bodyError)
192
+ const file = this.load()
193
+ if (file.hosts.some(entry => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`)
194
+ const now = Date.now()
195
+ // validateHostPayload (create mode) above guarantees auth presence; this
196
+ // explicit check narrows the type for the entry construction below.
197
+ const auth = payload.auth
198
+ if (auth === undefined) throw new Error('auth is required')
199
+ const entry: SshHostEntry = {
200
+ alias,
201
+ host: payload.host.trim(),
202
+ port: payload.port ?? 22,
203
+ user: payload.user.trim(),
204
+ auth: {
205
+ kind: auth.kind,
206
+ keyPath: auth.kind === 'key' ? (auth.keyPath?.trim() !== '' ? expandHome(auth.keyPath?.trim() ?? '') : undefined) : undefined,
207
+ passphrase: auth.kind === 'key' ? auth.passphrase ?? undefined : undefined,
208
+ password: auth.kind === 'password' ? auth.password : undefined,
209
+ secretRef: auth.secretRef,
210
+ },
211
+ proxyJump: [...(payload.proxyJump ?? [])],
212
+ description: payload.description?.trim() || undefined,
213
+ environment: payload.environment?.trim() || undefined,
214
+ tags: [...(payload.tags ?? [])].map(tag => tag.trim()).filter(tag => tag !== ''),
215
+ location: payload.location?.trim() || undefined,
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ }
219
+ // ProxyJump cycle guard: the new entry may complete a loop through
220
+ // existing hosts (A -> B, B -> A) — reject before persisting.
221
+ const cycle = findJumpCycle(alias, [...file.hosts, entry])
222
+ if (cycle !== undefined) {
223
+ throw new Error(`proxyJump cycle detected: ${cycle}`)
224
+ }
225
+ file.hosts.push(entry)
226
+ this.save(file)
227
+ return entry
228
+ }
229
+
230
+ /** Update the fields present in `patch`; unknown aliases throw. */
231
+ update(alias: string, patch: Partial<HostPayload>, allowSecretless = false): SshHostEntry {
232
+ const file = this.load()
233
+ const entry = file.hosts.find(candidate => candidate.alias === alias)
234
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
235
+ // Partial-patch validation: only the present fields are checked (a
236
+ // caller may update just tags without resending host/user), but every
237
+ // present field follows the same rules as create.
238
+ const patchError = validateHostPayload(patch, 'patch', allowSecretless)
239
+ if (patchError !== undefined) throw new Error(patchError)
240
+ if (patch.host !== undefined) entry.host = patch.host.trim()
241
+ if (patch.port !== undefined) entry.port = patch.port
242
+ if (patch.user !== undefined) entry.user = patch.user.trim()
243
+ if (patch.auth !== undefined) {
244
+ const auth = patch.auth
245
+ // A changed key path with no passphrase means the new key has none;
246
+ // only keep the old passphrase when the key path is unchanged.
247
+ const keyChanged = auth.kind === 'key'
248
+ && auth.keyPath !== undefined
249
+ && expandHome(auth.keyPath.trim()) !== entry.auth.keyPath
250
+ entry.auth = {
251
+ kind: auth.kind,
252
+ keyPath: auth.kind === 'key' ? (auth.keyPath?.trim() !== '' ? expandHome(auth.keyPath?.trim() ?? '') : undefined) : undefined,
253
+ passphrase: auth.kind === 'key'
254
+ ? (auth.passphrase !== undefined ? auth.passphrase : (keyChanged ? undefined : entry.auth.passphrase))
255
+ : undefined,
256
+ password: auth.kind === 'password' ? auth.password : undefined,
257
+ // Keep the existing vault ref unless the patch supplies a new one
258
+ // or the key changed (a plain auth edit shouldn't silently lose
259
+ // credential linkage, but a new key must not reuse the old secret).
260
+ secretRef: auth.secretRef !== undefined ? auth.secretRef : (keyChanged ? undefined : entry.auth.secretRef),
261
+ }
262
+ }
263
+ if (patch.proxyJump !== undefined) {
264
+ entry.proxyJump = [...patch.proxyJump]
265
+ // Guard the full graph with the patched entry in place; a loop through
266
+ // other hosts must reject before the file is written.
267
+ const cycle = findJumpCycle(alias, file.hosts)
268
+ if (cycle !== undefined) {
269
+ throw new Error(`proxyJump cycle detected: ${cycle}`)
270
+ }
271
+ }
272
+ if (patch.description !== undefined) entry.description = patch.description.trim() || undefined
273
+ if (patch.environment !== undefined) entry.environment = patch.environment.trim() || undefined
274
+ if (patch.tags !== undefined) entry.tags = [...patch.tags].map(tag => tag.trim()).filter(tag => tag !== '')
275
+ if (patch.location !== undefined) entry.location = patch.location.trim() || undefined
276
+ entry.updatedAt = Date.now()
277
+ this.save(file)
278
+ return entry
279
+ }
280
+
281
+ /** Remove one entry. */
282
+ delete(alias: string): void {
283
+ const file = this.load()
284
+ const index = file.hosts.findIndex(candidate => candidate.alias === alias)
285
+ if (index < 0) throw new Error(`alias '${alias}' not found`)
286
+ file.hosts.splice(index, 1)
287
+ this.save(file)
288
+ }
289
+
290
+ /**
291
+ * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
292
+ * pattern and a HostName become entries (key auth via IdentityFile, jump
293
+ * hosts via ProxyJump). Existing aliases are skipped.
294
+ * @returns import statistics.
295
+ */
296
+ importFromSshConfig(): ImportResult {
297
+ // Per-run statistics: a later import must not report earlier runs' skips.
298
+ this.skippedNames = new Set<string>()
299
+ const configPath = this.sshConfigOverride ?? sshConfigPath()
300
+ 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
+ }
324
+ 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)
339
+ continue
340
+ }
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
+ try {
360
+ this.create(payload)
361
+ added += 1
362
+ } catch {
363
+ // Unusable entry (bad alias grammar etc.) — count as skipped.
364
+ skip(pattern, this.skippedNames)
365
+ }
366
+ }
367
+ return { parsed: blocks.length, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
368
+ }
369
+
370
+ private skippedNames = new Set<string>()
371
+
372
+ private load(): StoreFile {
373
+ if (!existsSync(this.path)) return { version: FORMAT_VERSION, hosts: [] }
374
+ try {
375
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
376
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
377
+ throw new Error('store file shape invalid')
378
+ }
379
+ return parsed
380
+ } catch {
381
+ // A corrupt store must not brick the plugin — and must not be silently
382
+ // overwritten by the next save: rename it aside for manual recovery
383
+ // (the plugin then starts from an empty list).
384
+ try {
385
+ renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
386
+ } catch { /* best effort */ }
387
+ return { version: FORMAT_VERSION, hosts: [] }
388
+ }
389
+ }
390
+
391
+ private save(file: StoreFile): void {
392
+ const dir = dirname(this.path)
393
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
394
+ const tmp = this.path + '.tmp'
395
+ // Secrets live in this file: keep it readable by the owner only. The
396
+ // tmp file carries the 0600 mode through the rename.
397
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
398
+ renameSync(tmp, this.path)
399
+ }
400
+ }
401
+
402
+ /** Expand a leading `~` in a filesystem path. */
403
+ export function expandHome(path: string): string {
404
+ if (path === '~') return homedir()
405
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
406
+ return path
407
+ }
408
+
409
+ /** Resolved authentication for one connect (vault-aware). */
410
+ export interface ResolvedAuth {
411
+ kind: SshAuthKind
412
+ keyPath?: string
413
+ password?: string
414
+ passphrase?: string
415
+ }
416
+
417
+ /**
418
+ * Secure host store: composes the plaintext HostStore (host/port/user/… +
419
+ * key paths — non-secrets) with the credential handling chosen by
420
+ * `secretStorage`:
421
+ *
422
+ * - **'vault'**: passwords/passphrases are stored encrypted in the
423
+ * credential vault (AES-256-GCM); the hosts file keeps only a `secretRef`,
424
+ * and resolveAuth reveals on demand. For headless agents running password
425
+ * hosts unattended.
426
+ *
427
+ * - **'none' (default, VSCode Remote-SSH style)**: secrets are NEVER
428
+ * 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
431
+ * remains untouched, so existing v1 tests and dual-format reads keep
432
+ * working.
433
+ */
434
+ export class SecureHostStore {
435
+ private readonly inner: HostStore
436
+
437
+ constructor(
438
+ private readonly vault: import('./vault.ts').Vault | undefined,
439
+ path?: string,
440
+ sshConfigOverride?: string,
441
+ private readonly mode: 'none' | 'vault' = 'none',
442
+ ) {
443
+ this.inner = new HostStore(path, sshConfigOverride)
444
+ }
445
+
446
+ list(): SshHostEntry[] { return this.inner.list() }
447
+ find(alias: string): SshHostEntry | undefined { return this.inner.find(alias) }
448
+ summarize(entry: SshHostEntry): SshHostSummary { return this.inner.summarize(entry) }
449
+ 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() }
453
+
454
+ /** Create: secrets are stashed per mode; entry only keeps kind (+ keyPath). */
455
+ async create(payload: HostPayload): Promise<SshHostEntry> {
456
+ // Validate the raw wire input first. In 'none' mode a password-kind host
457
+ // may omit the password (typed at connect time, VSCode-style); 'vault'
458
+ // mode keeps the strict rule so every credential gets stashed.
459
+ const rawError = validateHostPayload(payload, 'create', this.mode === 'none')
460
+ if (rawError !== undefined) throw new Error(rawError)
461
+ const ref = await this.stashSecrets(payload.alias ?? '', payload.auth)
462
+ const entry = this.inner.create(stripSecrets(payload, ref, this.mode), true)
463
+ return entry
464
+ }
465
+
466
+ /** Update: auth present -> re-stash (or strip in none mode); absent → keep. */
467
+ async update(alias: string, patch: Partial<HostPayload>): Promise<SshHostEntry> {
468
+ const existing = this.inner.find(alias)
469
+ if (patch.auth === undefined || existing === undefined) {
470
+ return this.inner.update(alias, patch)
471
+ }
472
+ const rawError = validateHostPayload(patch, 'patch', this.mode === 'none')
473
+ if (rawError !== undefined) {
474
+ // 'vault' mode: a password-kind patch without a password is acceptable
475
+ // when the existing entry already holds a secretRef — the user edited
476
+ // other fields without retyping the credential.
477
+ const keepOldRef = this.mode === 'vault'
478
+ && patch.auth.kind === 'password'
479
+ && (patch.auth.password === undefined || patch.auth.password === '')
480
+ && existing.auth.secretRef !== undefined
481
+ if (!keepOldRef) throw new Error(rawError)
482
+ }
483
+ 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)
486
+ }
487
+
488
+ /** Delete: drop the host (and its vault secrets in vault mode, best-effort). */
489
+ async delete(alias: string): Promise<void> {
490
+ 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
+ this.inner.delete(alias)
495
+ }
496
+
497
+ /** Resolve the authentication for one entry (vault reveal when needed). */
498
+ async resolveAuth(entry: SshHostEntry): Promise<ResolvedAuth> {
499
+ if (this.mode === 'vault' && entry.auth.secretRef !== undefined && this.vault !== undefined) {
500
+ const secret = await this.vault.reveal(entry.auth.secretRef)
501
+ if (entry.auth.kind === 'password') return { kind: 'password', password: secret }
502
+ return { kind: 'key', keyPath: entry.auth.keyPath, passphrase: secret !== '' ? secret : undefined }
503
+ }
504
+ // Plaintext fallback (v1 store / tests) — reachable in none mode only
505
+ // when an entry still carries inline secrets (legacy v1 file).
506
+ return { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
507
+ }
508
+
509
+ private async stashSecrets(alias: string, auth: HostPayload['auth']): Promise<string | undefined> {
510
+ if (this.mode !== 'vault' || this.vault === undefined) return undefined
511
+ if (auth === undefined) return undefined
512
+ if (auth.kind === 'key') {
513
+ if (auth.passphrase !== undefined && auth.passphrase !== '') {
514
+ return this.vault.store('host.passphrase', alias, auth.passphrase)
515
+ }
516
+ return undefined // key without passphrase needs no stash
517
+ }
518
+ if (auth.password !== undefined && auth.password !== '') {
519
+ return this.vault.store('host.password', alias, auth.password)
520
+ }
521
+ return undefined
522
+ }
523
+ }
524
+
525
+ /** Strip secrets from a payload according to mode + stored ref.
526
+ * - vault mode with ref: carry the ref, drop plaintext.
527
+ * - none mode: drop the plaintext (never persist), keep kind + keyPath. */
528
+ function stripSecrets<T extends HostPayload | Partial<HostPayload>>(payload: T, ref: string | undefined, mode: 'none' | 'vault'): T {
529
+ if (payload.auth === undefined) return payload
530
+ const auth = payload.auth
531
+ if (mode === 'vault' && ref !== undefined) {
532
+ return {
533
+ ...payload,
534
+ auth: auth.kind === 'key'
535
+ ? { kind: 'key' as const, keyPath: auth.keyPath, secretRef: ref }
536
+ : { kind: 'password' as const, secretRef: ref },
537
+ } as T
538
+ }
539
+ // none mode (or no ref available): keep kind + keyPath, drop password/passphrase.
540
+ if (auth.kind === 'key') {
541
+ return { ...payload, auth: { kind: 'key' as const, keyPath: auth.keyPath } } as T
542
+ }
543
+ return { ...payload, auth: { kind: 'password' as const } } as T
544
+ }