dsh-dbhub-live 5.0.1 → 5.2.0-dev.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 (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. package/test/zero-knowledge.test.mjs +120 -78
package/lib/config.mjs CHANGED
@@ -2,8 +2,10 @@
2
2
  // resolution. Pure module — every function takes the services it needs as
3
3
  // arguments, so this file never imports a Cordis service.
4
4
 
5
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'
5
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
6
6
  import { join, basename, dirname } from 'node:path'
7
+ // i18n.mjs imports nothing (a leaf), so this cannot create a cycle.
8
+ import { currentT } from './i18n.mjs'
7
9
 
8
10
  export function dshHomeDir() {
9
11
  if (process.env.DSH_HOME) return process.env.DSH_HOME
@@ -14,7 +16,7 @@ export const DATA_DIR = join(dshHomeDir(), 'storages', 'dsh-dbhub-live')
14
16
  export const STORE_PATH = join(DATA_DIR, 'credentials.json')
15
17
  export const RUNTIME_DIR = join(DATA_DIR, 'dbhub-runtime')
16
18
  export const RUNTIME_PATH = join(DATA_DIR, 'runtime.json')
17
- // UI preferences owned by the card (`updateIntervalDays`, `showSidebarEntry`).
19
+ // UI preferences owned by the card (`updateIntervalDays`).
18
20
  // On dsh >= 0.1.7 there is no settings namespace to keep them in, and the
19
21
  // profile entry id needed to write the plugin's Config form is deployment
20
22
  // owned, so the plugin persists them itself; on 0.1.x the settings document
@@ -116,8 +118,11 @@ export function normalizeStore(raw) {
116
118
 
117
119
  /**
118
120
  * List every persisted workspace environment as flat rows for the card.
121
+ * Each row also carries the environment's options: `ro` (read-only) and `ssh`
122
+ * (the normalized tunnel config, SECRETS INCLUDED — this is host-side data and
123
+ * every model/UI-facing copy goes through `describeEnvOptions`).
119
124
  * @param store - the (normalized) credentials store.
120
- * @returns rows [{ wsPath, env, dsn, source?, updatedAt?, persisted: true }].
125
+ * @returns rows [{ wsPath, env, dsn, source?, updatedAt?, ro, ssh, persisted: true }].
121
126
  */
122
127
  export function listWorkspaceEnvironments(store) {
123
128
  const rows = []
@@ -132,6 +137,8 @@ export function listWorkspaceEnvironments(store) {
132
137
  dsn: envEntry.dsn.trim(),
133
138
  ...(typeof envEntry.source === 'string' && envEntry.source ? { source: envEntry.source } : {}),
134
139
  ...(typeof envEntry.updatedAt === 'number' && Number.isFinite(envEntry.updatedAt) ? { updatedAt: envEntry.updatedAt } : {}),
140
+ ro: readOnlyOf(envEntry),
141
+ ssh: sshOf(envEntry),
135
142
  persisted: true,
136
143
  })
137
144
  }
@@ -139,12 +146,253 @@ export function listWorkspaceEnvironments(store) {
139
146
  return rows
140
147
  }
141
148
 
149
+ // ── environment options: read-only + SSH tunnel (store model v3) ──────────
150
+ //
151
+ // v3 keeps the exact v2 layout and merely adds two optional fields to an
152
+ // environment entry:
153
+ // { dsn, source, updatedAt, readOnly?: true, ssh?: {…} }
154
+ // Compatibility is structural, not versioned: `normalizeStore` already
155
+ // preserves unknown env fields (so an older build reading a v3 file keeps the
156
+ // new keys instead of stripping them), and a v2 file simply reads back as
157
+ // "not read-only, no tunnel".
158
+ //
159
+ // SSH secrets (`password`, `passphrase`) sit next to `dsn` and share its trust
160
+ // model (same file, same 0600-by-user-directory guarantee).
161
+
162
+ /** Longest accepted values (a hand-edited file cannot inject a huge blob). */
163
+ const SSH_LIMITS = { host: 255, user: 128, keyPath: 1024, secret: 512, proxyJump: 1024 }
164
+
165
+ function sshText(value, max) {
166
+ const raw = String(value === undefined || value === null ? '' : value)
167
+ .replace(/[\u0000-\u001f\u007f]/g, '')
168
+ .trim()
169
+ if (!raw) return ''
170
+ return raw.slice(0, max)
171
+ }
172
+
173
+ /** Integer port in 1..65535, or the default. */
174
+ function sshPort(value, fallback) {
175
+ const n = Number(String(value === undefined || value === null ? '' : value).trim())
176
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback
177
+ return n
178
+ }
179
+
180
+ /**
181
+ * Number of hops a ProxyJump list names. dbhub's `ssh_proxy_jump` is a
182
+ * comma-separated ProxyJump list that SHARES one credential set across every
183
+ * hop, so a real multi-hop route (different credentials per jump host) cannot
184
+ * be expressed — this plugin supports single-hop only and says so.
185
+ */
186
+ export function sshProxyJumpHops(value) {
187
+ const raw = sshText(value, SSH_LIMITS.proxyJump)
188
+ if (!raw) return 0
189
+ return raw.split(',').map((s) => s.trim()).filter(Boolean).length
190
+ }
191
+
192
+ /**
193
+ * Normalize one SSH tunnel block. Returns `null` for anything that cannot be
194
+ * used (missing host/user, unknown auth kind, a missing credential for the
195
+ * chosen kind, a multi-hop ProxyJump, …) — the caller turns that into a
196
+ * localized, actionable message.
197
+ *
198
+ * Secrets are carried verbatim: they were either typed in the UI dialog or are
199
+ * being preserved from the stored entry.
200
+ *
201
+ * @param raw - `{ host, port?, user, auth, keyPath?, password?, passphrase?, proxyJump? }`.
202
+ * @returns the normalized block, or `null`.
203
+ */
204
+ export function normalizeSshOptions(raw) {
205
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
206
+ const host = sshText(raw.host, SSH_LIMITS.host)
207
+ const user = sshText(raw.user, SSH_LIMITS.user)
208
+ if (!host || !user) return null
209
+ const auth = raw.auth === 'password' ? 'password' : raw.auth === 'key' ? 'key' : ''
210
+ if (!auth) return null
211
+ const out = { host, port: sshPort(raw.port, 22), user, auth }
212
+ const proxyJump = sshText(raw.proxyJump, SSH_LIMITS.proxyJump)
213
+ if (sshProxyJumpHops(proxyJump) > 1) return null
214
+ if (proxyJump) out.proxyJump = proxyJump
215
+ if (auth === 'key') {
216
+ const keyPath = sshText(raw.keyPath, SSH_LIMITS.keyPath)
217
+ if (!keyPath) return null
218
+ out.keyPath = keyPath
219
+ const passphrase = String(raw.passphrase === undefined || raw.passphrase === null ? '' : raw.passphrase)
220
+ if (passphrase) out.passphrase = passphrase.slice(0, SSH_LIMITS.secret)
221
+ } else {
222
+ const password = String(raw.password === undefined || raw.password === null ? '' : raw.password)
223
+ if (!password) return null
224
+ out.password = password.slice(0, SSH_LIMITS.secret)
225
+ }
226
+ return out
227
+ }
228
+
229
+ /** The stored read-only flag of one environment entry (`false` when unset). */
230
+ export function readOnlyOf(envEntry) {
231
+ return !!(envEntry && typeof envEntry === 'object' && envEntry.readOnly === true)
232
+ }
233
+
234
+ /** The stored, normalized SSH block of one environment entry (or null). */
235
+ export function sshOf(envEntry) {
236
+ if (!envEntry || typeof envEntry !== 'object') return null
237
+ return normalizeSshOptions(envEntry.ssh)
238
+ }
239
+
240
+ /**
241
+ * The identity file an SSH client falls back to when none is given — the same
242
+ * value the UI shows as the key-path placeholder.
243
+ */
244
+ export const DEFAULT_SSH_KEY_PATH = '~/.ssh/id_ed25519'
245
+
246
+ /**
247
+ * Fill the conventional identity path into a key-auth block that names none.
248
+ *
249
+ * Without this a key-auth tunnel with a blank path is simply UNUSABLE
250
+ * (`normalizeSshOptions` rejects it), so the placeholder the field shows would be
251
+ * a promise the plugin never keeps. Applied at the last step of a merge and right
252
+ * before an SSH-layer test, never to a block that already names a path.
253
+ * @returns the same object when nothing was filled (identity check is meaningful).
254
+ */
255
+ export function withDefaultSshKey(block) {
256
+ if (!block || typeof block !== 'object' || Array.isArray(block)) return block
257
+ if (block.auth !== 'key') return block
258
+ const named = block.keyPath !== undefined && block.keyPath !== null && String(block.keyPath).trim() !== ''
259
+ if (named) return block
260
+ return Object.assign({}, block, { keyPath: DEFAULT_SSH_KEY_PATH })
261
+ }
262
+
263
+ /**
264
+ * Merge an incoming tunnel block with the stored one, implementing the
265
+ * "blank secret = keep the stored value" rule for the BROWSER path.
266
+ *
267
+ * The UI can never echo a secret back (the mirror only carries
268
+ * `hasPassword`/`hasPassphrase`/`keyReady`), so a user who edits only the host —
269
+ * or presses "test the tunnel" on a saved row — sends a block without a
270
+ * password. Normalizing that directly would reject it, which is why the COMMAND
271
+ * layer merges first (the model path does the same in `tools.mjs`).
272
+ *
273
+ * Non-secret fields overwrite (a blank one falls back to the stored value so the
274
+ * merge cannot produce an unusable block); switching the auth kind drops the
275
+ * other kind's secret.
276
+ *
277
+ * @param incoming - the block from the card (secrets possibly blank/omitted).
278
+ * @param storedRaw - the stored raw block (or null).
279
+ * @returns the merged raw block (still to be normalized/validated).
280
+ */
281
+ export function mergeSshOptions(incoming, storedRaw) {
282
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming
283
+ const prev = normalizeSshOptions(storedRaw)
284
+ const filled = (v) => v !== undefined && v !== null && String(v).trim() !== ''
285
+ const auth = incoming.auth === 'password' || incoming.auth === 'key'
286
+ ? incoming.auth
287
+ : (prev ? prev.auth : '')
288
+ const merged = {
289
+ host: filled(incoming.host) ? incoming.host : (prev ? prev.host : ''),
290
+ port: filled(incoming.port) ? incoming.port : (prev ? prev.port : 22),
291
+ user: filled(incoming.user) ? incoming.user : (prev ? prev.user : ''),
292
+ auth,
293
+ proxyJump: incoming.proxyJump !== undefined && incoming.proxyJump !== null
294
+ ? incoming.proxyJump
295
+ : (prev ? prev.proxyJump || '' : ''),
296
+ }
297
+ if (auth === 'key') {
298
+ const sameKind = !!(prev && prev.auth === 'key')
299
+ const keyPath = filled(incoming.keyPath) ? incoming.keyPath : (sameKind ? prev.keyPath : '')
300
+ if (keyPath) merged.keyPath = keyPath
301
+ const passphrase = filled(incoming.passphrase) ? incoming.passphrase : (sameKind ? prev.passphrase : '')
302
+ if (passphrase) merged.passphrase = passphrase
303
+ } else if (auth === 'password') {
304
+ const sameKind = !!(prev && prev.auth === 'password')
305
+ const password = filled(incoming.password) ? incoming.password : (sameKind ? prev.password : '')
306
+ if (password) merged.password = password
307
+ }
308
+ return withDefaultSshKey(merged)
309
+ }
310
+
311
+ /** Expand a leading `~/` the way dbhub does, for local key-existence checks. */
312
+ export function expandHome(p) {
313
+ const raw = String(p || '')
314
+ if (!raw.startsWith('~/') && !raw.startsWith('~\\')) return raw
315
+ const home = process.env.HOME || process.env.USERPROFILE || ''
316
+ if (!home) return raw
317
+ return join(home, raw.slice(2))
318
+ }
319
+
320
+ /**
321
+ * Secret-free description of an environment's options. This is the ONLY shape
322
+ * the browser mirror and the model-facing surface may see: booleans for "does a
323
+ * secret exist" and never the secret itself, and never the key PATH either
324
+ * (only whether it resolves to a readable file).
325
+ */
326
+ export function describeEnvOptions(envEntry) {
327
+ const ro = readOnlyOf(envEntry)
328
+ const ssh = sshOf(envEntry)
329
+ if (!ssh) return { ro, ssh: null }
330
+ let keyReady = false
331
+ if (ssh.auth === 'key') {
332
+ try {
333
+ keyReady = !!ssh.keyPath && existsSync(expandHome(ssh.keyPath))
334
+ } catch (e) {
335
+ keyReady = false
336
+ }
337
+ }
338
+ return {
339
+ ro,
340
+ ssh: {
341
+ host: ssh.host,
342
+ port: ssh.port,
343
+ user: ssh.user,
344
+ authKind: ssh.auth,
345
+ hasPassword: !!ssh.password,
346
+ hasPassphrase: !!ssh.passphrase,
347
+ keyReady,
348
+ proxyJump: ssh.proxyJump || '',
349
+ },
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Copy the previous entry's option fields onto a freshly built entry, applying
355
+ * an explicit patch when one is given.
356
+ *
357
+ * Semantics (D4): an OMITTED patch preserves whatever the entry had — a plain
358
+ * `dbhub_configure` DSN update must never silently drop the read-only flag or
359
+ * the tunnel. An explicit `false` / `null` DELETES the field.
360
+ */
361
+ function applyEnvOptions(target, previous, opts) {
362
+ if (!opts || typeof opts !== 'object') {
363
+ if (previous && previous.readOnly === true) target.readOnly = true
364
+ const ssh = sshOf(previous)
365
+ if (ssh) target.ssh = ssh
366
+ return target
367
+ }
368
+ if (opts.readOnly === true) target.readOnly = true
369
+ else if (opts.readOnly === undefined && previous && previous.readOnly === true) target.readOnly = true
370
+ if (opts.ssh !== undefined) {
371
+ // `withDefaultSshKey` first: a key-auth block with a blank path means the
372
+ // conventional identity file (what the field's placeholder shows), and it must
373
+ // SAVE the same way it TESTS — otherwise a successful tunnel test is followed
374
+ // by a silent save that drops the tunnel.
375
+ const ssh = opts.ssh === null ? null : normalizeSshOptions(withDefaultSshKey(opts.ssh))
376
+ if (ssh) target.ssh = ssh
377
+ } else {
378
+ const ssh = sshOf(previous)
379
+ if (ssh) target.ssh = ssh
380
+ }
381
+ return target
382
+ }
383
+
142
384
  /**
143
385
  * Persist one workspace environment (creates or overwrites). The workspace
144
- * entry becomes `{ environments: { <env>: { dsn, source, updatedAt } } }`.
386
+ * entry becomes `{ environments: { <env>: { dsn, source, updatedAt, … } } }`.
387
+ *
388
+ * `opts` = `{ readOnly?: boolean|null, ssh?: object|null }`. When it is
389
+ * OMITTED the entry's existing read-only / tunnel options are PRESERVED (a
390
+ * DSN-only update must not clear a user's safety switch); `readOnly: false` and
391
+ * `ssh: null` delete the field.
392
+ *
145
393
  * @returns the stored environment name ('default' when blank).
146
394
  */
147
- export function setWorkspaceEnv(wsPath, env, dsn, source) {
395
+ export function setWorkspaceEnv(wsPath, env, dsn, source, opts) {
148
396
  const entry = store[wsPath] && typeof store[wsPath] === 'object' && !Array.isArray(store[wsPath])
149
397
  ? store[wsPath]
150
398
  : {}
@@ -154,12 +402,53 @@ export function setWorkspaceEnv(wsPath, env, dsn, source) {
154
402
  const name = normalizeEnvName(env)
155
403
  const cleanDsn = String(dsn).trim()
156
404
  if (!cleanDsn) throw new TypeError('DSN 不能为空')
157
- environments[name] = { dsn: cleanDsn, source: String(source || 'user'), updatedAt: Date.now() }
405
+ const previous = environments[name]
406
+ const next = { dsn: cleanDsn, source: String(source || 'user'), updatedAt: Date.now() }
407
+ applyEnvOptions(next, previous, opts)
408
+ environments[name] = next
409
+ store[wsPath] = { environments }
410
+ saveStore(store)
411
+ return name
412
+ }
413
+
414
+ /**
415
+ * Change ONLY the options of an existing environment — the DSN, its source and
416
+ * its updatedAt are left exactly as they were (the UI's "toggle read-only" and
417
+ * "configure the tunnel" writes have no connection data to offer, and requiring
418
+ * one would make the user re-type a password to flip a checkbox).
419
+ *
420
+ * @param patch - `{ readOnly?: boolean, ssh?: object|null }` (only the keys
421
+ * present are touched).
422
+ * @returns the environment name, or `undefined` when no such persisted row
423
+ * exists (the caller decides whether to promote an auto-discovered row).
424
+ */
425
+ export function setWorkspaceEnvOptions(wsPath, env, patch) {
426
+ const entry = store[wsPath]
427
+ if (!entry || typeof entry !== 'object' || !entry.environments || typeof entry.environments !== 'object') return undefined
428
+ const name = normalizeEnvName(env)
429
+ const current = entry.environments[name]
430
+ if (!current || typeof current.dsn !== 'string' || !current.dsn.trim()) return undefined
431
+ const environments = { ...entry.environments }
432
+ const next = { ...current, updatedAt: Date.now() }
433
+ if (patch && typeof patch === 'object') {
434
+ if (patch.readOnly === true) next.readOnly = true
435
+ else if (patch.readOnly === false) delete next.readOnly
436
+ if (patch.ssh !== undefined) {
437
+ if (patch.ssh === null) delete next.ssh
438
+ else {
439
+ const ssh = normalizeSshOptions(withDefaultSshKey(patch.ssh))
440
+ if (!ssh) return undefined
441
+ next.ssh = ssh
442
+ }
443
+ }
444
+ }
445
+ environments[name] = next
158
446
  store[wsPath] = { environments }
159
447
  saveStore(store)
160
448
  return name
161
449
  }
162
450
 
451
+
163
452
  /**
164
453
  * Rename one persisted environment IN PLACE: the DSN, its source provenance
165
454
  * and the connection itself move with the name, so no credential is ever
@@ -293,13 +582,26 @@ export function normalizePrefs(raw) {
293
582
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
294
583
  const days = raw.updateIntervalDays
295
584
  if (typeof days === 'number' && Number.isFinite(days) && days >= 0) defineOwn(out, 'updateIntervalDays', days)
585
+ // The user's own "hide the sidebar entry" preference. It is INDEPENDENT of the
586
+ // plugin's enabled state: hiding the shortcut does not turn the plugin off,
587
+ // and the client ANDs the two (a disabled plugin contributes no entry at all).
296
588
  if (typeof raw.showSidebarEntry === 'boolean') defineOwn(out, 'showSidebarEntry', raw.showSidebarEntry)
589
+ // The user's own "announce the database-access policy to the model" preference.
590
+ // Turning it off removes one system-prompt section; no tool changes behavior.
591
+ if (typeof raw.announceAccessPolicy === 'boolean') defineOwn(out, 'announceAccessPolicy', raw.announceAccessPolicy)
297
592
  return out
298
593
  }
299
594
 
300
595
  /** Load the persisted UI options; a missing or unreadable file is `{}`. */
301
596
  export function loadPrefs() {
302
- return normalizePrefs(readJsonFile(PREFS_PATH, {}))
597
+ const raw = readJsonFile(PREFS_PATH, {})
598
+ const normalized = normalizePrefs(raw)
599
+ // One-time migration, mirroring loadStore/loadRuntime: a legacy key (or a
600
+ // hand-edited value that fails its validator) is dropped from DISK on the
601
+ // first boot, so the file never keeps advertising an option this build does
602
+ // not have — a removed `showSidebarEntry` must not look configurable again.
603
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) writeJsonFile(PREFS_PATH, normalized)
604
+ return normalized
303
605
  }
304
606
 
305
607
  /** Persist the UI options atomically; returns whether it succeeded. */
@@ -405,7 +707,7 @@ export function describeConn(dsn) {
405
707
  */
406
708
  export function connLabel(dsn) {
407
709
  const c = describeConn(dsn)
408
- if (!c.type) return String(dsn || '').trim() || '(未知连接)'
710
+ if (!c.type) return String(dsn || '').trim() || currentT('result.unknownConn')
409
711
  if (c.type === 'sqlite' && c.database) {
410
712
  return 'sqlite:///' + c.database.replace(/\\/g, '/')
411
713
  }
@@ -448,19 +750,35 @@ export function dsnUser(dsn) {
448
750
 
449
751
  /**
450
752
  * Defense-in-depth scrubber for text that originates from dbhub (results,
451
- * stderr tails, error messages): replaces the known DSN's password and full
452
- * DSN occurrences, plus generic `password=` / `passwd=` tokens, so a leak from
453
- * the MCP server can never reach the model. Passwords and usernames never
454
- * cross.
753
+ * stderr tails, error messages): replaces the known DSN's password and the
754
+ * userinfo of every URL-shaped DSN occurrence, plus generic `password=` /
755
+ * `passwd=` tokens, so a leak from the MCP server can never reach the model.
756
+ * Passwords AND usernames never cross.
757
+ *
758
+ * dbhub's own startup banner prints the DSN with only the password masked
759
+ * (`mysql://root:****@host:3306/db`), which is exactly the text that lands in a
760
+ * connection-test diagnostic — hence the userinfo is collapsed as a whole, not
761
+ * just the password.
762
+ *
763
+ * `extraSecrets` carries the secrets a generated TOML put in the child
764
+ * environment (the SSH login password / key passphrase): dbhub may echo them in
765
+ * its own diagnostics, so they are replaced like the DSN password.
455
766
  */
456
- export function scrubSecrets(text, dsn) {
767
+ export function scrubSecrets(text, dsn, extraSecrets) {
457
768
  let out = String(text || '')
458
769
  const pass = dsnPassword(dsn)
459
- if (pass && pass !== '****') {
460
- out = out.split(pass).join('****')
461
- out = out.replace(/(:\/\/[^:/@]+:)[^@]+(@)/g, '$1****$2')
770
+ if (pass && pass !== '****') out = out.split(pass).join('****')
771
+ const extras = Array.isArray(extraSecrets) ? extraSecrets : []
772
+ for (const secret of extras) {
773
+ const s = String(secret === undefined || secret === null ? '' : secret)
774
+ if (!s) continue
775
+ out = out.split(s).join('****')
462
776
  }
463
777
  out = out.replace(/(password|passwd|pwd)\s*(:|=)\s*["']?[^"'\s,;]+/gi, '$1$2****')
778
+ // Userinfo of a URL-shaped connection string: `://user:pass@` and `://user@`.
779
+ // Runs LAST so it also covers a DSN that arrived through an `extraSecrets`
780
+ // replacement, and it is unconditional because the username is a secret too.
781
+ out = out.replace(/(:\/\/)[^/@\s]+@/g, '$1****:****@')
464
782
  return out
465
783
  }
466
784
 
@@ -640,17 +958,39 @@ export async function runMiseEnv(subprocess, cwd, signal, timeoutMs) {
640
958
  // `mise env` — which may be slow, or need the timeout above — for the same
641
959
  // directory over and over was the dominant cost of both. A config file edited by
642
960
  // hand is picked up again after at most AUTO_DSN_TTL_MS.
961
+ //
962
+ // The cache stores RESULTS, so an in-flight probe must ALSO be de-duplicated:
963
+ // the card's publish, the model's list and a connection test can all request the
964
+ // same directory within the same second, and each one used to spawn its own
965
+ // `mise env`. A rejection is deliberately NOT cached — the next caller must be
966
+ // able to really retry.
967
+ const autoDsnInFlight = new Map()
968
+
643
969
  async function resolveAutoDsn(subprocess, fs, wsPath, signal) {
644
970
  const cached = autoDsnCache.get(wsPath)
645
971
  if (cached && Date.now() - cached.at < AUTO_DSN_TTL_MS) return cached.value
646
- const value = await probeAutoDsn(subprocess, fs, wsPath, signal)
647
- autoDsnCache.set(wsPath, { at: Date.now(), value })
648
- return value
972
+ const inFlight = autoDsnInFlight.get(wsPath)
973
+ if (inFlight) return inFlight
974
+ const run = probeAutoDsn(subprocess, fs, wsPath, signal).then(
975
+ (value) => {
976
+ autoDsnCache.set(wsPath, { at: Date.now(), value })
977
+ return value
978
+ },
979
+ )
980
+ autoDsnInFlight.set(wsPath, run)
981
+ try {
982
+ return await run
983
+ } finally {
984
+ // Clear the in-flight handle on BOTH paths: a cached rejection would turn
985
+ // every later call into "the connection was never discovered".
986
+ if (autoDsnInFlight.get(wsPath) === run) autoDsnInFlight.delete(wsPath)
987
+ }
649
988
  }
650
989
 
651
990
  /** Forget every cached auto-discovery result (tests, and explicit rescans). */
652
991
  export function resetAutoDsnCache() {
653
992
  autoDsnCache.clear()
993
+ autoDsnInFlight.clear()
654
994
  }
655
995
 
656
996
  async function probeAutoDsn(subprocess, fs, wsPath, signal) {
@@ -686,12 +1026,21 @@ export async function resolveWorkspaceEnvs(subprocess, fs, wsPath, signal) {
686
1026
  const out = []
687
1027
  for (const [env, envEntry] of Object.entries(persisted)) {
688
1028
  if (envEntry && typeof envEntry.dsn === 'string' && envEntry.dsn.trim()) {
689
- out.push({ env, dsn: envEntry.dsn.trim(), source: 'persisted(' + (envEntry.source || 'user') + ')' })
1029
+ out.push({
1030
+ env,
1031
+ dsn: envEntry.dsn.trim(),
1032
+ source: 'persisted(' + (envEntry.source || 'user') + ')',
1033
+ ro: readOnlyOf(envEntry),
1034
+ ssh: sshOf(envEntry),
1035
+ })
690
1036
  }
691
1037
  }
692
1038
  if (!Object.prototype.hasOwnProperty.call(persisted, 'default')) {
693
1039
  const auto = await resolveAutoDsn(subprocess, fs, wsPath, signal)
694
- if (auto) out.push({ env: 'default', dsn: auto.dsn, source: auto.source })
1040
+ // An auto-discovered row has no store entry, therefore no options: it is
1041
+ // never read-only and never tunnelled until the user (or the model) sets an
1042
+ // option, which promotes it to a persisted row (D10).
1043
+ if (auto) out.push({ env: 'default', dsn: auto.dsn, source: auto.source, ro: false, ssh: null })
695
1044
  }
696
1045
  return out
697
1046
  }