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/adhoc.mjs CHANGED
@@ -1,79 +1,455 @@
1
1
  // dsh-dbhub-live: ad-hoc temporary connections.
2
2
  //
3
3
  // EVERY tool execution goes through here: each call spawns a fresh throwaway
4
- // `dbhub --transport stdio --dsn <dsn>`, runs one MCP tools/call, then kills
5
- // it. Independent per call, so parallel calls can hit different databases at
6
- // once and a hung/failing process can never take anything else down. Nothing
7
- // is persisted and there is no resident server to maintain. Results and error
8
- // text are scrubbed (scrubSecrets) and labeled with metadata only
9
- // (connLabel) — a password can never reach the model through this layer.
10
-
11
- import { DATA_DIR, connLabel, scrubSecrets } from './config.mjs'
4
+ // `dbhub --transport stdio`, runs one MCP tools/call, then kills it.
5
+ // Independent per call, so parallel calls can hit different databases at once
6
+ // and a hung/failing process can never take anything else down. Nothing is
7
+ // persisted and there is no resident server to maintain. Results and error text
8
+ // are scrubbed (scrubSecrets) and labeled with metadata only (connLabel) — a
9
+ // password can never reach the model through this layer.
10
+ //
11
+ // Two spawn shapes, decided per call:
12
+ // · plain connection → `--dsn <dsn>` (unchanged since 5.0.1, the DB
13
+ // password rides argv exactly as before);
14
+ // · read-only and/or tunnel → a ONE-SHOT generated TOML (`--config <tmp>`),
15
+ // because `--dsn` and `--config` are mutually
16
+ // exclusive and only the TOML dialect carries
17
+ // `[[tools]] readonly` / the `ssh_*` fields. The
18
+ // file holds placeholders only; the real secrets
19
+ // ride the CHILD's environment (see toml.mjs).
20
+
21
+ import { DATA_DIR, connLabel, scrubSecrets, normalizeSshOptions, expandHome, withDefaultSshKey, DEFAULT_SSH_KEY_PATH } from './config.mjs'
12
22
  import { buildSpawnArgv, resolveDbhubExe } from './runtime.mjs'
13
23
  import { createMcpClient, disabledMessage } from './mcp.mjs'
24
+ import { buildDbhubToml, writeTempToml, removeTempToml } from './toml.mjs'
25
+ import { detectDbhubCapabilities } from './capability.mjs'
14
26
  import { currentT } from './i18n.mjs'
27
+ import { existsSync } from 'node:fs'
28
+ import { connect } from 'node:net'
15
29
  import * as state from './state.mjs'
16
30
 
31
+ // ── connection descriptor ─────────────────────────────────────────────────
32
+ //
33
+ // `conn` = `{ dsn, ro, ssh }` is the execution-side view of one environment: the
34
+ // real DSN plus its options. A bare DSN string is still accepted (and means "no
35
+ // options"), so an unchanged call site keeps working.
36
+
37
+ /** Coerce a bare DSN or a descriptor into the descriptor shape. */
38
+ export function normalizeConn(conn) {
39
+ if (typeof conn === 'string') return { dsn: conn.trim(), ro: false, ssh: null }
40
+ const c = conn && typeof conn === 'object' ? conn : {}
41
+ return {
42
+ dsn: typeof c.dsn === 'string' ? c.dsn.trim() : '',
43
+ ro: c.ro === true,
44
+ ssh: c.ssh && typeof c.ssh === 'object' ? c.ssh : null,
45
+ }
46
+ }
47
+
48
+ /** True when this connection must run through a generated TOML config. */
49
+ export function needsToml(conn) {
50
+ const c = normalizeConn(conn)
51
+ return c.ro === true || !!c.ssh
52
+ }
53
+
54
+ /** The secret VALUES a spawned dbhub received (never logged, only scrubbed). */
55
+ export function secretsOf(conn) {
56
+ const c = normalizeConn(conn)
57
+ const out = []
58
+ if (c.ssh) {
59
+ if (c.ssh.password) out.push(String(c.ssh.password))
60
+ if (c.ssh.passphrase) out.push(String(c.ssh.passphrase))
61
+ }
62
+ return out
63
+ }
64
+
65
+ // ── probe budgets ─────────────────────────────────────────────────────────
66
+ //
67
+ // A tunnel probe is slower than a direct one (SSH handshake + forwarded
68
+ // connect), so the host-side cap has two tiers. Both stay comfortably BELOW the
69
+ // browser watchdog (client.js), which is asserted by test/adhoc-toml.test.mjs.
70
+ export const PROBE_TIMEOUT_MS = 25000
71
+ export const PROBE_TIMEOUT_SSH_MS = 45000
72
+
73
+ /** Host-side probe cap for one connection (tunnelled connections get more). */
74
+ export function hostTimeoutFor(conn) {
75
+ return needsToml(conn) && normalizeConn(conn).ssh ? PROBE_TIMEOUT_SSH_MS : PROBE_TIMEOUT_MS
76
+ }
77
+
78
+ // dbhub's own read-only rejection (keyword classifier + database read-only mode).
79
+ const READONLY_RE = /READONLY_VIOLATION|Read-only mode is enabled/i
80
+
81
+ /** True when dbhub refused a statement because the environment is read-only. */
82
+ export function readOnlyViolation(text) {
83
+ return READONLY_RE.test(String(text || ''))
84
+ }
85
+
86
+ // ── failure attribution on a diagnostic tail ──────────────────────────────
87
+ //
88
+ // dbhub prints a startup banner FIRST ("Configuration source: …", "Connecting to
89
+ // N database source(s)…", the masked DSN) and the actual fatal line LAST. Taking
90
+ // the head of that output therefore shows the user everything EXCEPT the reason —
91
+ // which is exactly the "I can't tell whether it was the SSH or the DSN" report.
92
+ // Keep the END, keep the line structure, and say which layer failed.
93
+
94
+ /** Characters of dbhub output kept for a failure report. */
95
+ export const DIAG_TAIL_MAX = 600
96
+
17
97
  /**
18
- * Connectivity probe for one real DSN: a throwaway dbhub + `SELECT 1`.
98
+ * The informative END of a multi-line diagnostic: trailing whitespace trimmed,
99
+ * blank runs collapsed, indentation kept so the lines stay readable in the UI.
100
+ *
101
+ * Stack-frame lines are DROPPED: dbhub's fatal error is followed by ~10 ` at …`
102
+ * frames, and a plain tail-of-N-chars then keeps the frames while cutting the very
103
+ * line that names the reason (measured on dbhub 1.4.0, whose output is
104
+ * `Fatal error: Error: SSH connection error: …` + frames + its tunnel-error
105
+ * property). desensitize:allow (that property NAME is dbhub's, not a credential)
106
+ * @returns a string of at most `max` characters (leading ellipsis when cut).
107
+ */
108
+ export function diagnosticTail(text, max) {
109
+ const cap = Number.isFinite(max) && max > 0 ? max : DIAG_TAIL_MAX
110
+ const lines = String(text || '')
111
+ .replace(/\r/g, '')
112
+ .split('\n')
113
+ .map((line) => line.replace(/[ \t]+$/, ''))
114
+ // ` at …` frames, plus a frame tail left over when a long frame line was
115
+ // wrapped (it no longer starts with `at`, but still ends in `file.js:L:C)`).
116
+ .filter((line) => !/^\s*at\s/.test(line) && !/\.(?:js|mjs|cjs):\d+:\d+\)?\s*$/.test(line))
117
+ while (lines.length > 0 && !lines[0].trim()) lines.shift()
118
+ while (lines.length > 0 && !lines[lines.length - 1].trim()) lines.pop()
119
+ let out = lines.join('\n')
120
+ if (out.length > cap) {
121
+ // Cut from the front, but never leave HALF a line: when the cut lands inside
122
+ // a line, start at the next one (a dangling fragment reads as corruption).
123
+ const at = out.length - cap
124
+ let start = at
125
+ if (out[at - 1] !== '\n') {
126
+ const nl = out.indexOf('\n', at)
127
+ start = nl === -1 ? at : nl + 1
128
+ }
129
+ out = '…' + out.slice(start)
130
+ }
131
+ return out
132
+ }
133
+
134
+ // Errors dbhub emits BEFORE it can even try the tunnel / the database: they are
135
+ // dbhub's own validation of what we generated, so they belong to no layer. Matched
136
+ // in BOTH locales: these strings come from i18n, so the classifier must not depend
137
+ // on the active language.
138
+ const PLUGIN_FAIL_RE = /(启动临时 dbhub 失败|生成临时 dbhub 配置失败|无法获取 dbhub|已禁用|Failed to start the temporary dbhub|Failed to generate the temporary dbhub|Cannot fetch dbhub|is disabled)/
139
+ // Signatures that prove the SSH LAYER itself did not come up. The first two are
140
+ // MEASURED against dbhub 1.4.0 (real run: `Fatal error: Error: SSH connection
141
+ // error: connect ECONNREFUSED …` plus an `__dbhubSSHTunnelError: true` property on
142
+ // the error object); the rest are ssh2's own messages. desensitize:allow (this is
143
+ // dbhub's error property name, not a credential)
144
+ const SSH_LAYER_RE = /(SSH connection error|__dbhubSSHTunnelError|All configured authentication methods failed|authentication methods failed|Cannot parse privateKey|Encrypted private key|no passphrase|handshake|ssh_host|ssh_user|SSH tunnel requires|proxy ?jump|jump host|ssh2)/i
145
+ // Signatures that prove the connection got PAST the tunnel and failed at the
146
+ // DATABASE: the driver only speaks after the forwarded connection exists.
147
+ // MEASURED (second real run): a tunnel that opens and then forwards to a closed
148
+ // target produces `SSH tunnel closed` at teardown plus
149
+ // `Fatal error: Error: Connection lost: The server closed the connection.` with
150
+ // `code: 'PROTOCOL_CONNECTION_LOST'` — and NOTHING naming the target, which is why
151
+ // a target-name test alone reported "cannot classify" for a perfectly good tunnel.
152
+ const DB_LAYER_RE = /(PROTOCOL_CONNECTION_LOST|Connection lost|server closed the connection|Access denied|Unknown database|ER_[A-Z_]{3,}|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|SSH tunnel closed)/i
153
+
154
+ /**
155
+ * Which layer does this failure belong to? Only meaningful when a tunnel is
156
+ * configured; `''` means "no attribution".
157
+ * 'internal' — the plugin could not even start/configure dbhub;
158
+ * 'ssh' — the SSH layer (handshake / authentication / key) failed;
159
+ * 'db' — no SSH signature: the database step failed (the tunnel may well
160
+ * be up — `probeSshTunnel` is what proves that).
161
+ * @returns 'internal' | 'ssh' | 'db' | ''
162
+ */
163
+ export function failureLayerOf(text, conn) {
164
+ const c = normalizeConn(conn)
165
+ const s = String(text || '')
166
+ if (PLUGIN_FAIL_RE.test(s)) return 'internal'
167
+ if (c.ssh) return SSH_LAYER_RE.test(s) ? 'ssh' : 'db'
168
+ return ''
169
+ }
170
+
171
+ /** Localized one-line prefix naming the failing layer ('' when unknown). */
172
+ export function failureLayerLabel(text, conn) {
173
+ const layer = failureLayerOf(text, conn)
174
+ if (layer === 'ssh') return currentT('result.layerSsh')
175
+ if (layer === 'db') return currentT('result.layerDb')
176
+ if (layer === 'internal') return currentT('result.layerInternal')
177
+ return ''
178
+ }
179
+
180
+ /**
181
+ * Connectivity probe for one real connection: a throwaway dbhub + `SELECT 1`.
19
182
  * Always resolves to a compact, already-localized `{ ok, message }` report
20
183
  * suitable for transient UI feedback (the settings card's connection test).
21
184
  * @param subprocess - the host subprocess service.
22
- * @param dsn - the REAL (unmasked) DSN to probe.
23
- * @param timeoutMs - hard cap for the whole probe (default 30s; the signal
24
- * also kills the spawned process when it fires).
25
- * @returns `{ ok, message }` — message is single-line and length-capped.
185
+ * @param conn - the connection descriptor `{ dsn, ro, ssh }` (or a bare DSN).
186
+ * @param timeoutMs - hard cap for the whole probe (the signal also kills the
187
+ * spawned process when it fires).
188
+ * @returns `{ ok, message }` — message is length-capped and scrubbed.
26
189
  */
27
- export async function probeConnection(subprocess, dsn, timeoutMs) {
28
- const cap = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 30000
190
+ export async function probeConnection(subprocess, conn, timeoutMs) {
191
+ const cap = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : hostTimeoutFor(conn)
29
192
  const started = Date.now()
30
193
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
31
194
  ? AbortSignal.timeout(cap)
32
195
  : new AbortController().signal
33
196
  let res
34
197
  try {
35
- res = await runAdhoc(subprocess, dsn, 'execute_sql', { sql: 'SELECT 1' }, { signal })
198
+ res = await runAdhoc(subprocess, conn, 'execute_sql', { sql: 'SELECT 1' }, { signal })
36
199
  } catch (e) {
37
200
  res = { ok: false, text: String((e && e.message) || e) }
38
201
  }
39
202
  const ms = Date.now() - started
40
203
  if (res && res.ok) return { ok: true, message: currentT('result.testOk', { ms: String(ms) }) }
41
- // Collapse the (possibly multi-KB stderr tail) failure into one short line.
42
- let detail = String((res && res.text) || '').replace(/\s+/g, ' ').trim()
43
- if (detail.length > 180) detail = detail.slice(0, 180) + '…'
44
- if (!detail) detail = currentT('result.testNoDetail')
204
+ const tail = diagnosticTail(res && res.text)
205
+ const layer = failureLayerLabel(res && res.text, conn)
206
+ let detail = tail || currentT('result.testNoDetail')
207
+ if (layer) detail = layer + detail
45
208
  return { ok: false, message: currentT('result.testFail', { msg: detail, ms: String(ms) }) }
46
209
  }
47
210
 
48
- export async function runAdhoc(subprocess, dsn, rawName, mcpArgs, exec, label) {
211
+ // ── SSH-layer-only probe ──────────────────────────────────────────────────
212
+ //
213
+ // "I can't tell whether the SSH config or the DSN is wrong" is the question this
214
+ // answers, in two steps that need no extra dependency:
215
+ //
216
+ // 1. a plain TCP connect to `ssh_host:ssh_port` from the plugin host — which
217
+ // separates DNS / firewall / closed-port from everything else, and
218
+ // (for key auth) a local check that the private-key file exists;
219
+ // 2. a deliberate dbhub connect THROUGH the tunnel to a port that cannot be
220
+ // listening (`127.0.0.1:1`). If what fails is the DATABASE step, the SSH
221
+ // handshake and authentication must have succeeded — the tunnel carried the
222
+ // connection. An SSH-specific error means the tunnel itself is the problem.
223
+ //
224
+ // Step 2 is not a full SSH session test with a live database behind it, and the
225
+ // report says so.
226
+
227
+ /** The target port step 2 aims at: guaranteed closed, so only the SSH layer matters. */
228
+ export const SSH_PROBE_DSN = 'mysql://probe:CHANGE_ME@127.0.0.1:1/probe'
229
+ /** The forwarded target the probe's DSN points at (matched inside dbhub's error). */
230
+ const SSH_PROBE_TARGET_RE = /127\.0\.0\.1:1(?![0-9])/
231
+ /** TCP reachability budget for the bastion. */
232
+ export const SSH_TCP_TIMEOUT_MS = 5000
233
+
234
+ /** A bare TCP connect (no SSH protocol): resolves `{ ok, code }`. */
235
+ export function tcpReachable(host, port, timeoutMs) {
236
+ const cap = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SSH_TCP_TIMEOUT_MS
237
+ return new Promise((resolve) => {
238
+ let settled = false
239
+ let timer = null
240
+ let socket = null
241
+ const finish = (value) => {
242
+ if (settled) return
243
+ settled = true
244
+ // The budget timer stays REFERENCED while it matters and is cleared on both
245
+ // paths, so a fast answer never leaves a live handle behind (CI-visible).
246
+ if (timer) clearTimeout(timer)
247
+ try {
248
+ if (socket) socket.destroy()
249
+ } catch (e) {
250
+ /* the socket may already be gone */
251
+ }
252
+ resolve(value)
253
+ }
254
+ try {
255
+ socket = connect({ host, port })
256
+ } catch (e) {
257
+ resolve({ ok: false, code: String((e && e.code) || (e && e.message) || 'ECONNFAIL') })
258
+ return
259
+ }
260
+ timer = setTimeout(() => finish({ ok: false, code: 'ETIMEDOUT' }), cap)
261
+ socket.once('connect', () => finish({ ok: true, code: '' }))
262
+ socket.once('error', (e) => finish({ ok: false, code: String((e && e.code) || 'ECONNFAIL') }))
263
+ socket.once('timeout', () => finish({ ok: false, code: 'ETIMEDOUT' }))
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Which fields of a raw tunnel block are missing? Names them for the user instead
269
+ * of a generic "incomplete" (R64 requires saying WHAT is missing).
270
+ * @returns a localized list ('' when nothing is missing).
271
+ */
272
+ export function missingSshFields(raw) {
273
+ const s = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
274
+ const has = (v) => v !== undefined && v !== null && String(v).trim() !== ''
275
+ const missing = []
276
+ if (!has(s.host)) missing.push(currentT('field.host'))
277
+ if (!has(s.user)) missing.push(currentT('field.user'))
278
+ if (s.auth !== 'password' && s.auth !== 'key') missing.push(currentT('field.authKind'))
279
+ else if (s.auth === 'key' && !has(s.keyPath)) missing.push(currentT('field.keyPath'))
280
+ else if (s.auth === 'password' && !has(s.password)) missing.push(currentT('field.password'))
281
+ return missing.join('、')
282
+ }
283
+
284
+ /**
285
+ * The "no path was given, so the default identity file was tried" note — or `''`
286
+ * when the block named a path itself.
287
+ *
288
+ * `original` is what the card/model sent and `effective` is what will actually be
289
+ * tried (after a merge that fills the default), so the note survives that merge:
290
+ * the command layer merges BEFORE the probe, and without this the user would never
291
+ * be told which key was used.
292
+ * @returns a localized note starting with a newline, or ''.
293
+ */
294
+ export function defaultKeyNoteOf(original, effective) {
295
+ const eff = effective && typeof effective === 'object' ? effective : null
296
+ if (!eff || eff.auth !== 'key') return ''
297
+ if (eff.keyPath !== DEFAULT_SSH_KEY_PATH) return ''
298
+ const orig = original && typeof original === 'object' ? original : {}
299
+ const named = orig.keyPath !== undefined && orig.keyPath !== null && String(orig.keyPath).trim() !== ''
300
+ if (named) return ''
301
+ return currentT('result.sshTestDefaultKey', { path: DEFAULT_SSH_KEY_PATH })
302
+ }
303
+
304
+ /**
305
+ * Validate ONLY the SSH layer of one tunnel configuration.
306
+ * @param subprocess - the host subprocess service.
307
+ * @param ssh - the (possibly unsaved) tunnel block.
308
+ * @param timeoutMs - cap for step 2 (step 1 has its own 5 s budget).
309
+ * @returns `{ ok, layer, message }` with an already-localized, scrubbed message.
310
+ */
311
+ export async function probeSshTunnel(subprocess, ssh, timeoutMs) {
312
+ // A key-auth block with a blank path means "the usual identity file" — the very
313
+ // value the field shows as its placeholder, so what the user sees is what is
314
+ // tried (and what a save stores).
315
+ const requested = withDefaultSshKey(ssh)
316
+ const keyNote = defaultKeyNoteOf(ssh, requested)
317
+ const withNote = (message) => message + keyNote
318
+ const s = normalizeSshOptions(requested)
319
+ if (!s) {
320
+ const missing = missingSshFields(requested)
321
+ // Nothing missing but still rejected ⇒ the only other reason
322
+ // `normalizeSshOptions` says no: a multi-hop ProxyJump.
323
+ return {
324
+ ok: false,
325
+ layer: 'config',
326
+ message: missing ? currentT('result.sshTestBadConfig', { missing }) : currentT('result.sshTestMultiHop'),
327
+ }
328
+ }
329
+ const where = { host: s.host, port: String(s.port) }
330
+ // 1) can we even reach the bastion's port?
331
+ const reach = await tcpReachable(s.host, s.port, SSH_TCP_TIMEOUT_MS)
332
+ if (!reach.ok) {
333
+ return { ok: false, layer: 'reach', message: currentT('result.sshTestUnreachable', { host: s.host, port: String(s.port), code: reach.code }) }
334
+ }
335
+ // 2) for key auth, the key file must exist on THIS host (no secret echoed)
336
+ if (s.auth === 'key') {
337
+ let ok = false
338
+ try {
339
+ ok = existsSync(expandHome(s.keyPath))
340
+ } catch (e) {
341
+ ok = false
342
+ }
343
+ if (!ok) return { ok: false, layer: 'key', message: withNote(currentT('result.sshTestKeyMissing', { path: s.keyPath })) }
344
+ }
345
+ // 3) let dbhub bring the tunnel up against a closed port
346
+ const cap = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : PROBE_TIMEOUT_SSH_MS
347
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
348
+ ? AbortSignal.timeout(cap)
349
+ : new AbortController().signal
350
+ let res
351
+ try {
352
+ res = await runAdhoc(subprocess, { dsn: SSH_PROBE_DSN, ro: false, ssh: s }, 'execute_sql', { sql: 'SELECT 1' }, { signal })
353
+ } catch (e) {
354
+ res = { ok: false, text: String((e && e.message) || e) }
355
+ }
356
+ const text = String((res && res.text) || '')
357
+ const layer = failureLayerOf(text, { dsn: SSH_PROBE_DSN, ssh: s })
358
+ if (res && res.ok) {
359
+ // The closed port answered?! Nothing to attribute — report the tunnel as up.
360
+ return { ok: true, layer: 'tunnel', message: currentT('result.sshTestOk', where) }
361
+ }
362
+ if (layer === 'internal') {
363
+ return { ok: false, layer: 'internal', message: currentT('result.sshTestInternal', { msg: diagnosticTail(text) }) }
364
+ }
365
+ // ORDER MATTERS. An SSH-layer signature is emitted by dbhub/ssh2 only for a
366
+ // tunnel-level problem, so it decides the verdict on its own. The
367
+ // "names the forwarded target" test can only be consulted SECOND, because the
368
+ // startup banner names that target in EVERY run (`- default: mysql://…@target`)
369
+ // — consulting it first made a real SSH failure look like a working tunnel
370
+ // (found by review against the measured capture).
371
+ const namesTarget = SSH_PROBE_TARGET_RE.test(text)
372
+ if (layer === 'ssh') {
373
+ return { ok: false, layer: 'ssh', message: withNote(currentT('result.sshTestAuthFail', { host: s.host, port: String(s.port), msg: diagnosticTail(text) })) }
374
+ }
375
+ if (DB_LAYER_RE.test(text) || namesTarget) {
376
+ // Either the driver already spoke to a database (so the tunnel carried the
377
+ // connection) or the failure names the forwarded target: the SSH layer is up.
378
+ return { ok: true, layer: 'tunnel', message: withNote(currentT('result.sshTestOk', where)) }
379
+ }
380
+ // No signature either way: say so instead of guessing (the tail is included).
381
+ return { ok: false, layer: 'unknown', message: withNote(currentT('result.sshTestUnknown', { host: s.host, port: String(s.port), msg: diagnosticTail(text) })) }
382
+ }
383
+
384
+ /**
385
+ * Run ONE MCP tool call against a disposable dbhub process.
386
+ * @param subprocess - host subprocess service.
387
+ * @param conn - `{ dsn, ro, ssh }` (a bare DSN string means "no options").
388
+ * @param rawName - the MCP tool name (`execute_sql` / `search_objects`).
389
+ * @param mcpArgs - the tool arguments.
390
+ * @param exec - `{ signal, agent? }`.
391
+ * @param label - optional pre-localized header line.
392
+ * @returns `{ ok, text }` (already scrubbed and metadata-labelled).
393
+ */
394
+ export async function runAdhoc(subprocess, conn, rawName, mcpArgs, exec, label) {
395
+ const c = normalizeConn(conn)
49
396
  if (!state.isEnabled()) {
50
397
  return { ok: false, text: disabledMessage() }
51
398
  }
52
- if (!dsn || typeof dsn !== 'string' || !dsn.trim()) {
399
+ if (!c.dsn) {
53
400
  return { ok: false, text: currentT('result.dsnMissing') }
54
401
  }
55
402
  let exe
56
403
  try {
57
404
  exe = await resolveDbhubExe(subprocess, exec.signal)
58
405
  } catch (e) {
59
- return { ok: false, text: '无法获取 dbhub: ' + String((e && e.message) || e) }
406
+ return { ok: false, text: currentT('result.dbhubFetchFailed', { msg: String((e && e.message) || e) }) }
407
+ }
408
+ const useToml = needsToml(c)
409
+ let tomlPath = ''
410
+ let argv
411
+ let childEnv
412
+ if (useToml) {
413
+ // Version guard (D9/R2): an old dbhub either rejects the generated dialect
414
+ // outright or — much worse — silently IGNORES it, which would look like
415
+ // "read-only is on" while every write still goes through. Refuse loudly.
416
+ const cap = detectDbhubCapabilities(exe)
417
+ const wanted = c.ro ? 'readonlyTools' : 'ssh'
418
+ if (cap && cap[wanted] === false) {
419
+ return { ok: false, text: currentT('result.dbhubTooOld', { version: cap.version || 'unknown' }) }
420
+ }
421
+ try {
422
+ const built = buildDbhubToml({ dsn: c.dsn, ro: c.ro, ssh: c.ssh })
423
+ tomlPath = writeTempToml(built.text)
424
+ // Secrets travel in the CHILD's environment; the file only holds `${VAR}`.
425
+ childEnv = { ...process.env, ...built.env }
426
+ argv = ['--transport', 'stdio', '--config', tomlPath]
427
+ } catch (e) {
428
+ removeTempToml(tomlPath)
429
+ return { ok: false, text: currentT('result.tomlGenFailed', { msg: String((e && e.message) || e) }) }
430
+ }
431
+ } else {
432
+ argv = ['--transport', 'stdio', '--dsn', c.dsn]
60
433
  }
434
+ const extraSecrets = secretsOf(c)
61
435
  let handle
62
436
  try {
63
437
  handle = subprocess.spawn({
64
- argv: buildSpawnArgv(subprocess, exe, ['--transport', 'stdio', '--dsn', dsn.trim()]),
438
+ argv: buildSpawnArgv(subprocess, exe, argv),
65
439
  cwd: DATA_DIR,
66
440
  stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
67
441
  graceMs: 3000,
68
442
  signal: exec.signal,
443
+ ...(childEnv ? { env: childEnv } : {}),
69
444
  })
70
445
  } catch (e) {
71
- return { ok: false, text: '启动临时 dbhub 失败: ' + String((e && e.message) || e) }
446
+ removeTempToml(tomlPath)
447
+ return { ok: false, text: currentT('result.dbhubSpawnFailed', { msg: String((e && e.message) || e) }) }
72
448
  }
73
449
  let stderrTail = ''
74
450
  try {
75
- const client = createMcpClient(handle, (c) => {
76
- stderrTail = (stderrTail + String(c)).slice(-2000)
451
+ const client = createMcpClient(handle, (chunk) => {
452
+ stderrTail = (stderrTail + String(chunk)).slice(-2000)
77
453
  })
78
454
  await client.request('initialize', {
79
455
  protocolVersion: '2025-03-26',
@@ -92,19 +468,19 @@ export async function runAdhoc(subprocess, dsn, rawName, mcpArgs, exec, label) {
92
468
  }
93
469
  // Zero-knowledge surface: the header carries metadata only (host/port/db),
94
470
  // and every line — including dbhub's own error text — is scrubbed before
95
- // it can reach the model.
471
+ // it can reach the model (the SSH password / passphrase included).
96
472
  const prefix = (label !== undefined && label !== null
97
473
  ? String(label)
98
- : currentT('label.adhocConn', { dsn: connLabel(dsn) })) + '\n'
99
- if (res && res.isError) return { ok: false, text: prefix + scrubSecrets(textOf(res), dsn) }
474
+ : currentT('label.adhocConn', { dsn: connLabel(c.dsn) })) + '\n'
475
+ if (res && res.isError) return { ok: false, text: prefix + scrubSecrets(textOf(res), c.dsn, extraSecrets) }
100
476
  if (res && res.structuredContent !== undefined) {
101
- return { ok: true, text: prefix + scrubSecrets(JSON.stringify(res.structuredContent, null, 2), dsn) }
477
+ return { ok: true, text: prefix + scrubSecrets(JSON.stringify(res.structuredContent, null, 2), c.dsn, extraSecrets) }
102
478
  }
103
- return { ok: true, text: prefix + scrubSecrets(textOf(res), dsn) }
479
+ return { ok: true, text: prefix + scrubSecrets(textOf(res), c.dsn, extraSecrets) }
104
480
  } catch (e) {
105
481
  let detail = String((e && e.message) || e)
106
482
  if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
107
- return { ok: false, text: scrubSecrets(detail, dsn) }
483
+ return { ok: false, text: scrubSecrets(detail, c.dsn, extraSecrets) }
108
484
  } finally {
109
485
  try {
110
486
  handle.terminate()
@@ -116,5 +492,7 @@ export async function runAdhoc(subprocess, dsn, rawName, mcpArgs, exec, label) {
116
492
  } catch (e) {
117
493
  /* ignore */
118
494
  }
495
+ // Best effort: the temp file holds no secret, but it must not accumulate.
496
+ removeTempToml(tomlPath)
119
497
  }
120
- }
498
+ }