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/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
|
|
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]
|
|
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
|
-
|
|
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
|
|
452
|
-
* DSN
|
|
453
|
-
* the MCP server can never reach the model.
|
|
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
|
-
|
|
461
|
-
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
}
|