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.
- package/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- 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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
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
|
|
23
|
-
* @param timeoutMs - hard cap for the whole probe (
|
|
24
|
-
*
|
|
25
|
-
* @returns `{ ok, message }` — message is
|
|
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,
|
|
28
|
-
const cap = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs :
|
|
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,
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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,
|
|
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
|
-
|
|
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, (
|
|
76
|
-
stderrTail = (stderrTail + String(
|
|
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
|
+
}
|