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
@@ -0,0 +1,270 @@
1
+ // Unit tests for the plugin self-update check (lib/update.mjs): npm-first with a
2
+ // GitHub fallback, prerelease-aware hasUpdate, error sanitization, the 30 min
3
+ // throttle, `force`, concurrent-call dedupe and the per-request timeout.
4
+ //
5
+ // Everything is driven through an injected fetch stub and an injected clock, so
6
+ // these tests never touch the network and never wait on a real timer.
7
+
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import {
12
+ DEFAULT_THROTTLE_MS,
13
+ GITHUB_LATEST_URL,
14
+ NPM_LATEST_URL,
15
+ createUpdateChecker,
16
+ versionFromGithubBody,
17
+ versionFromNpmBody,
18
+ } from '../lib/update.mjs'
19
+
20
+ // ── helpers ───────────────────────────────────────────────────────────────
21
+
22
+ /** A fetch stub that answers from a per-URL table: value = body | {status}. */
23
+ function stubFetch(routes) {
24
+ const calls = []
25
+ const impl = async (url) => {
26
+ calls.push(url)
27
+ const route = routes[url]
28
+ if (!route) throw new Error('unexpected url ' + url)
29
+ if (route instanceof Error) throw route
30
+ if (typeof route === 'function') return route(url)
31
+ const status = typeof route.status === 'number' ? route.status : 200
32
+ return {
33
+ ok: status >= 200 && status < 300,
34
+ status,
35
+ json: async () => route.body,
36
+ }
37
+ }
38
+ impl.calls = calls
39
+ impl.byUrl = (needle) => calls.filter((url) => url.includes(needle)).length
40
+ return impl
41
+ }
42
+
43
+ const ok = (body) => ({ status: 200, body })
44
+ const fail = (status) => ({ status })
45
+
46
+ function checker(overrides = {}) {
47
+ return createUpdateChecker({
48
+ currentVersion: '5.1.1-dev.1',
49
+ now: () => 1000,
50
+ ...overrides,
51
+ })
52
+ }
53
+
54
+ const NPM_OK = { [NPM_LATEST_URL]: ok({ version: '5.2.0' }) }
55
+ const GH_OK = { [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) }
56
+
57
+ // ── pure body extractors ──────────────────────────────────────────────────
58
+
59
+ test('versionFromNpmBody accepts objects and raw JSON strings, rejects junk', () => {
60
+ assert.equal(versionFromNpmBody({ version: '5.2.0' }), '5.2.0')
61
+ assert.equal(versionFromNpmBody({ version: ' 5.2.0-dev.1 ' }), '5.2.0-dev.1')
62
+ assert.equal(versionFromNpmBody('{"version":"5.2.0"}'), '5.2.0')
63
+ assert.equal(versionFromNpmBody({ version: 'latest' }), '')
64
+ assert.equal(versionFromNpmBody({ version: '' }), '')
65
+ assert.equal(versionFromNpmBody({}), '')
66
+ assert.equal(versionFromNpmBody(null), '')
67
+ assert.equal(versionFromNpmBody('not json'), '')
68
+ })
69
+
70
+ test('versionFromGithubBody strips the release tag v prefix', () => {
71
+ assert.equal(versionFromGithubBody({ tag_name: 'v5.2.0' }), '5.2.0')
72
+ assert.equal(versionFromGithubBody({ tag_name: '5.2.0' }), '5.2.0')
73
+ assert.equal(versionFromGithubBody({ tag_name: 'V5.2.0-dev.3' }), '5.2.0-dev.3')
74
+ assert.equal(versionFromGithubBody('{"tag_name":"v5.2.0"}'), '5.2.0')
75
+ assert.equal(versionFromGithubBody({ tag_name: 'vnext' }), '')
76
+ assert.equal(versionFromGithubBody({ name: 'v5.2.0' }), '')
77
+ assert.equal(versionFromGithubBody(null), '')
78
+ })
79
+
80
+ // ── happy path + fallback ─────────────────────────────────────────────────
81
+
82
+ test('npm success produces the full snapshot', async () => {
83
+ const fetchImpl = stubFetch(NPM_OK)
84
+ const update = checker({ fetchImpl, now: () => 4242 })
85
+ assert.deepEqual(update.snapshot(), {
86
+ latest: '',
87
+ latestSpec: '',
88
+ hasUpdate: false,
89
+ checkedAt: 0,
90
+ source: '',
91
+ error: '',
92
+ }, 'nothing is claimed before the first check')
93
+ const snap = await update.check()
94
+ assert.deepEqual(snap, {
95
+ latest: '5.2.0',
96
+ latestSpec: 'dsh-dbhub-live@5.2.0',
97
+ hasUpdate: true,
98
+ checkedAt: 4242,
99
+ source: 'npm',
100
+ error: '',
101
+ })
102
+ assert.deepEqual(fetchImpl.calls, [NPM_LATEST_URL], 'github is not asked when npm answered')
103
+ // No rejected promise, no throw: check() resolves on every path.
104
+ assert.deepEqual(update.snapshot(), snap)
105
+ })
106
+
107
+ test('npm failure falls back to the GitHub release tag', async () => {
108
+ const fetchImpl = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) })
109
+ const snap = await checker({ fetchImpl }).check()
110
+ assert.equal(snap.source, 'github')
111
+ assert.equal(snap.latest, '5.2.0')
112
+ assert.equal(snap.latestSpec, 'dsh-dbhub-live@5.2.0')
113
+ assert.equal(snap.hasUpdate, true)
114
+ assert.equal(snap.error, '', 'a successful fallback clears the npm error')
115
+ })
116
+
117
+ test('an invalid npm payload also falls back instead of claiming a version', async () => {
118
+ const fetchImpl = stubFetch({
119
+ [NPM_LATEST_URL]: ok({ version: 'latest' }),
120
+ [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }),
121
+ })
122
+ const snap = await checker({ fetchImpl }).check()
123
+ assert.equal(snap.latest, '5.2.0')
124
+ assert.equal(snap.source, 'github')
125
+ assert.equal(snap.error, '')
126
+ })
127
+
128
+ test('both sources failing yields a short error and KEEPS the previous result', async () => {
129
+ let clock = 1000
130
+ let current = stubFetch(NPM_OK)
131
+ // One checker; the stub it calls is swapped between runs (like a network
132
+ // that worked once and then went down).
133
+ const flaky = createUpdateChecker({
134
+ currentVersion: '5.1.1-dev.1',
135
+ now: () => clock,
136
+ throttleMs: 0,
137
+ fetchImpl: (url, init) => current(url, init),
138
+ })
139
+ const good = await flaky.check()
140
+ assert.equal(good.latest, '5.2.0')
141
+ assert.equal(good.error, '')
142
+
143
+ current = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) })
144
+ clock = 60_000
145
+ const bad = await flaky.check(true)
146
+ assert.equal(bad.latest, '5.2.0', 'stale latest preserved')
147
+ assert.equal(bad.latestSpec, 'dsh-dbhub-live@5.2.0')
148
+ assert.equal(bad.source, 'npm')
149
+ assert.equal(bad.hasUpdate, true)
150
+ assert.equal(bad.checkedAt, 60_000, 'checkedAt advances even on failure')
151
+ assert.equal(bad.error, 'npm: HTTP 503; github: HTTP 500')
152
+
153
+ // A brand-new checker has nothing to preserve: a failure leaves it empty.
154
+ const fresh = createUpdateChecker({
155
+ currentVersion: '5.1.1-dev.1',
156
+ now: () => clock,
157
+ fetchImpl: stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) }),
158
+ })
159
+ const empty = await fresh.check(true)
160
+ assert.equal(empty.latest, '')
161
+ assert.equal(empty.latestSpec, '')
162
+ assert.equal(empty.hasUpdate, false)
163
+ assert.equal(empty.source, '')
164
+ assert.equal(empty.error, 'npm: HTTP 503; github: HTTP 500')
165
+ })
166
+
167
+ test('hasUpdate is strictly-newer and prerelease-aware', async () => {
168
+ const cases = [
169
+ // current, latest, expected hasUpdate
170
+ ['5.1.1-dev.1', '5.2.0', true, 'a newer release'],
171
+ ['5.2.0-dev.1', '5.2.0', true, 'the release supersedes its own dev build'],
172
+ ['5.2.0', '5.2.0-dev.2', false, 'a dev build is older than the release'],
173
+ ['5.2.0', '5.2.0', false, 'equal versions'],
174
+ ['5.2.0', '5.1.0', false, 'an older release'],
175
+ ['5.1.0', '5.2.0-dev.1', true, 'a newer dev build'],
176
+ ['', '5.2.0', false, 'unknown current version never claims an update'],
177
+ ]
178
+ for (const [currentVersion, latest, expected, why] of cases) {
179
+ const fetchImpl = stubFetch({ [NPM_LATEST_URL]: ok({ version: latest }) })
180
+ const snap = await createUpdateChecker({ currentVersion, fetchImpl, now: () => 1 }).check(true)
181
+ assert.equal(snap.hasUpdate, expected, currentVersion + ' -> ' + latest + ' (' + why + ')')
182
+ assert.equal(snap.latest, latest)
183
+ }
184
+ })
185
+
186
+ // ── throttle / force / dedupe ─────────────────────────────────────────────
187
+
188
+ test('a second check inside the throttle window makes no request', async () => {
189
+ let clock = 10_000
190
+ const fetchImpl = stubFetch(NPM_OK)
191
+ const update = createUpdateChecker({ fetchImpl, now: () => clock })
192
+ const first = await update.check()
193
+ assert.equal(fetchImpl.calls.length, 1)
194
+
195
+ clock = 10_000 + DEFAULT_THROTTLE_MS - 1
196
+ const reused = await update.check()
197
+ assert.deepEqual(reused, first, 'the snapshot is reused verbatim')
198
+ assert.equal(fetchImpl.calls.length, 1, 'no new request inside the throttle window')
199
+
200
+ // `force` always refetches, even immediately.
201
+ const forced = await update.check(true)
202
+ assert.equal(fetchImpl.calls.length, 2)
203
+ assert.equal(forced.checkedAt, clock)
204
+ })
205
+
206
+ test('concurrent checks share one in-flight request', async () => {
207
+ let release
208
+ const gate = new Promise((resolve) => { release = resolve })
209
+ const fetchImpl = stubFetch({
210
+ [NPM_LATEST_URL]: async () => {
211
+ await gate
212
+ return { ok: true, status: 200, json: async () => ({ version: '5.2.0' }) }
213
+ },
214
+ })
215
+ const update = createUpdateChecker({ fetchImpl, now: () => 7 })
216
+ const first = update.check(true)
217
+ const second = update.check(true)
218
+ assert.equal(first, second, 'the same promise is handed to every concurrent caller')
219
+ release()
220
+ const [a, b] = await Promise.all([first, second])
221
+ assert.deepEqual(a, b)
222
+ assert.equal(fetchImpl.calls.length, 1, 'one request burst, not one per caller')
223
+ })
224
+
225
+ test('a failing check never rejects and error text is sanitized', async () => {
226
+ const fetchImpl = stubFetch({
227
+ [NPM_LATEST_URL]: new Error('boom https://registry.npmjs.org/dsh-dbhub-live/latest?token=abc123'),
228
+ [GITHUB_LATEST_URL]: new Error('fetch failed'),
229
+ })
230
+ const snap = await createUpdateChecker({ fetchImpl, now: () => 5 }).check(true)
231
+ assert.equal(snap.latest, '')
232
+ assert.equal(snap.source, '')
233
+ assert.match(snap.error, /^npm: /)
234
+ assert.match(snap.error, /github: /)
235
+ assert.ok(!snap.error.includes('http'), 'no URL survives into the view: ' + snap.error)
236
+ assert.ok(!snap.error.includes('token='), 'no query token survives into the view: ' + snap.error)
237
+ assert.ok(snap.error.length <= 200, 'the message stays short')
238
+ })
239
+
240
+ test('a request that outlives the budget is aborted and reported as timeout', async () => {
241
+ const fetchImpl = (url, init) => new Promise((resolve, reject) => {
242
+ const signal = init && init.signal
243
+ if (!signal) {
244
+ reject(new Error('no abort signal was passed'))
245
+ return
246
+ }
247
+ const abort = () => {
248
+ const error = new Error('The operation was aborted')
249
+ error.name = 'AbortError'
250
+ reject(error)
251
+ }
252
+ if (signal.aborted) abort()
253
+ else signal.addEventListener('abort', abort)
254
+ })
255
+ const snap = await createUpdateChecker({ fetchImpl, now: () => 1, timeoutMs: 20 }).check(true)
256
+ assert.equal(snap.latest, '')
257
+ assert.equal(snap.source, '')
258
+ assert.equal(snap.error, 'npm: timeout; github: timeout')
259
+ assert.equal(snap.checkedAt, 1)
260
+ })
261
+
262
+ test('snapshot() returns a detached copy', async () => {
263
+ const update = checker({ fetchImpl: stubFetch(NPM_OK) })
264
+ await update.check(true)
265
+ const snap = update.snapshot()
266
+ snap.latest = 'tampered'
267
+ snap.hasUpdate = false
268
+ assert.equal(update.snapshot().latest, '5.2.0')
269
+ assert.equal(update.snapshot().hasUpdate, true)
270
+ })
@@ -106,6 +106,23 @@ test('scrubSecrets removes the DSN password and generic password= tokens', () =>
106
106
  assert.ok(cfg.scrubSecrets('SELECT 1 OK', dsn).includes('SELECT 1 OK'))
107
107
  })
108
108
 
109
+ test('scrubSecrets collapses the whole userinfo of a URL-shaped connection string', () => {
110
+ const dsn = 'mysql://root:secret.pw@198.51.100.1:3306/mydb'
111
+ // dbhub's own startup banner masks only the password, so the USERNAME used to
112
+ // survive into the model-facing diagnostics. Both must go now.
113
+ const banner = 'Configuration source: dbhub-1.toml\n- default: mysql://root:****@198.51.100.1:3306/mydb'
114
+ const out = cfg.scrubSecrets(banner, dsn)
115
+ assert.ok(!out.includes('root'), 'the username must not survive: ' + out)
116
+ assert.ok(out.includes('mysql://****:****@198.51.100.1:3306/mydb'), out)
117
+ // A userinfo without a password is collapsed the same way.
118
+ assert.ok(!cfg.scrubSecrets('connect mysql://ops@198.51.100.2:3306/app', dsn).includes('ops'))
119
+ // A metadata label carries no userinfo at all and must stay byte-identical.
120
+ assert.equal(cfg.scrubSecrets('mysql://198.51.100.1:3306/mydb', dsn), 'mysql://198.51.100.1:3306/mydb')
121
+ // SSH secrets riding the child environment are replaced like the DSN password.
122
+ const sshOut = cfg.scrubSecrets('ssh_password=CHANGE_ME', dsn, ['CHANGE_ME'])
123
+ assert.ok(!sshOut.includes('CHANGE_ME'), sshOut)
124
+ })
125
+
109
126
  test('dsnUser extracts the username host-side only (never surfaced)', () => {
110
127
  assert.equal(cfg.dsnUser('mysql://root:secret@h/d'), 'root')
111
128
  assert.equal(cfg.dsnUser('postgres://alice@h/db'), 'alice')
@@ -1,79 +1,121 @@
1
- // Zero-knowledge gate: the model-facing surface must never carry a password
2
- // (or a full DSN / username). Four layers are asserted here:
3
- // 1. summarizeRows produces metadata only (no dsn field, no password).
4
- // 2. Tool sources: the 4-tool contract has no dbhub_query and dbhub_configure
5
- // has NO dsn parameter (a password may never ride a tool argument).
6
- // 3. Tool descriptions must never instruct the model to pass credentials.
7
- // 4. i18n copy must never embed a DSN with a real-looking password.
8
- //
9
- // Together with util.test's scrubSecrets/connLabel tests this is the
10
- // "no password reaches the model" regression suite.
11
-
12
- import test from 'node:test'
13
- import assert from 'node:assert/strict'
14
- import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
15
- import { tmpdir } from 'node:os'
16
- import { join, dirname } from 'node:path'
17
- import { fileURLToPath } from 'node:url'
18
-
19
- const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-zk-'))
20
- process.env.DSH_HOME = home
21
-
22
- const mcp = await import('../lib/mcp.mjs?zk=' + Date.now())
23
- const i18n = await import('../lib/i18n.mjs?zk=' + Date.now())
24
- const here = dirname(fileURLToPath(import.meta.url))
25
- const toolsSource = readFileSync(join(here, '..', 'lib', 'tools.mjs'), 'utf8')
26
-
27
- test.after(() => {
28
- rmSync(home, { recursive: true, force: true })
29
- })
30
-
31
- test('summarizeRows is metadata-only (no dsn/password/username)', () => {
32
- const rows = [{
33
- wsPath: 'C:\\ws\\demo',
34
- title: 'Demo App',
35
- env: 'default',
36
- dsn: 'mysql://root:secret.pw@198.51.100.1:3306/mydb',
37
- source: 'persisted(user)',
38
- persisted: true,
39
- }]
40
- const summary = mcp.summarizeRows(rows)[0]
41
- assert.equal(summary.conn, 'mysql://198.51.100.1:3306/mydb')
42
- assert.equal(summary.dsn, undefined)
43
- assert.ok(!JSON.stringify(summary).includes('secret.pw'))
44
- assert.ok(!JSON.stringify(summary).includes('root'))
45
- })
46
-
47
- test('tools.mjs registers exactly the 4-tool zero-knowledge contract', () => {
48
- // no ad-hoc dsn-passthrough tools remain
49
- assert.doesNotMatch(toolsSource, /name:\s*["']dbhub_query["']/)
50
- assert.doesNotMatch(toolsSource, /name:\s*["']dbhub_query_objects["']/)
51
- // the 4 tools are declared
52
- for (const tool of ['dbhub_configure', 'dbhub_list_sources', 'dbhub_execute_sql', 'dbhub_search_objects']) {
53
- assert.match(toolsSource, new RegExp('name:\\s*["\']' + tool + '["\']'), tool)
54
- }
55
- // NO tool declares a `dsn` schema property the model could fill with a secret
56
- assert.doesNotMatch(toolsSource, /dsn:\s*\{\s*type:\s*["']string["']/)
57
- // descriptions explicitly forbid passing credentials as arguments
58
- assert.match(toolsSource, /不能作为本工具参数传入/)
59
- })
60
-
61
- test('no tool description embeds a real-looking DSN', () => {
62
- // Placeholder examples (user:pass@, u:p@, username:password@, 账号:密码@) are
63
- // tolerated user-facing copy; anything else in the password position is a leak.
64
- const dsnLike = /[a-z]+:\/\/[^/\s]*:[^@\s]+@/g
65
- const placeholders = /(user:pass|u:p|username:password|account:password|账号:密码)@$/
66
- for (const m of toolsSource.matchAll(dsnLike)) {
67
- assert.ok(placeholders.test(m[0]), 'real-looking DSN in copy: ' + m[0])
68
- }
69
- })
70
-
71
- test('i18n copy never embeds a DSN with a password', () => {
72
- const dsnLike = /[a-z]+:\/\/[^/\s]*:[^@\s]+@/
73
- for (const locale of ['zh', 'en']) {
74
- const dict = i18n.dictionaryFor(locale)
75
- for (const key of Object.keys(dict)) {
76
- assert.doesNotMatch(String(dict[key]), dsnLike, key + ' (' + locale + ')')
77
- }
78
- }
1
+ // Zero-knowledge gate: the model-facing surface must never carry a password
2
+ // (or a full DSN / username). Four layers are asserted here:
3
+ // 1. summarizeRows produces metadata only (no dsn field, no password).
4
+ // 2. Tool sources: the 4-tool contract has no dbhub_query and dbhub_configure
5
+ // has NO dsn parameter (a password may never ride a tool argument).
6
+ // 3. Tool descriptions must never instruct the model to pass credentials.
7
+ // 4. i18n copy must never embed a DSN with a real-looking password.
8
+ //
9
+ // Together with util.test's scrubSecrets/connLabel tests this is the
10
+ // "no password reaches the model" regression suite.
11
+
12
+ import test from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join, dirname } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-zk-'))
20
+ process.env.DSH_HOME = home
21
+
22
+ const mcp = await import('../lib/mcp.mjs?zk=' + Date.now())
23
+ const i18n = await import('../lib/i18n.mjs?zk=' + Date.now())
24
+ const here = dirname(fileURLToPath(import.meta.url))
25
+ const toolsSource = readFileSync(join(here, '..', 'lib', 'tools.mjs'), 'utf8')
26
+
27
+ test.after(() => {
28
+ rmSync(home, { recursive: true, force: true })
29
+ })
30
+
31
+ test('summarizeRows is metadata-only (no dsn/password/username)', () => {
32
+ const rows = [{
33
+ wsPath: 'C:\\ws\\demo',
34
+ title: 'Demo App',
35
+ env: 'default',
36
+ dsn: 'mysql://root:secret.pw@198.51.100.1:3306/mydb',
37
+ source: 'persisted(user)',
38
+ persisted: true,
39
+ }]
40
+ const summary = mcp.summarizeRows(rows)[0]
41
+ assert.equal(summary.conn, 'mysql://198.51.100.1:3306/mydb')
42
+ assert.equal(summary.dsn, undefined)
43
+ assert.equal(summary.ro, false)
44
+ assert.equal(summary.ssh, null)
45
+ assert.ok(!JSON.stringify(summary).includes('secret.pw'))
46
+ assert.ok(!JSON.stringify(summary).includes('root'))
47
+ })
48
+
49
+ test('summarizeRows never carries a tunnel secret or the key path', () => {
50
+ // The SSH password / passphrase / private-key path live next to the DSN in the
51
+ // store; the mirrored row may only say WHETHER each exists.
52
+ const rows = [{
53
+ wsPath: 'C:\\ws\\demo',
54
+ title: 'Demo App',
55
+ env: 'prod',
56
+ dsn: 'mysql://root:secret.pw@198.51.100.1:3306/mydb',
57
+ source: 'persisted(user)',
58
+ persisted: true,
59
+ ro: true,
60
+ ssh: {
61
+ host: 'bastion.example.com', port: 22, user: 'ops', auth: 'password',
62
+ password: 'CHANGE_ME', keyPath: '~/.ssh/id_ed25519', passphrase: 'CHANGE_ME_TOO',
63
+ },
64
+ }]
65
+ const summary = mcp.summarizeRows(rows)[0]
66
+ const text = JSON.stringify(summary)
67
+ assert.equal(summary.ro, true)
68
+ assert.equal(summary.ssh.authKind, 'password')
69
+ assert.equal(summary.ssh.hasPassword, true)
70
+ assert.equal(summary.ssh.hasPassphrase, false, 'a password-auth tunnel has no key passphrase')
71
+ assert.equal(summary.ssh.keyReady, false)
72
+ assert.equal(text.includes('CHANGE_ME'), false)
73
+ assert.equal(text.includes('id_ed25519'), false, 'the key path is not mirrored either')
74
+ assert.equal(text.includes('secret.pw'), false)
75
+ assert.equal(summary.ssh.host, 'bastion.example.com', 'the host is routing metadata, not a secret')
76
+ })
77
+
78
+ test('tools.mjs registers exactly the 4-tool zero-knowledge contract', () => {
79
+ // no ad-hoc dsn-passthrough tools remain
80
+ assert.doesNotMatch(toolsSource, /name:\s*["']dbhub_query["']/)
81
+ assert.doesNotMatch(toolsSource, /name:\s*["']dbhub_query_objects["']/)
82
+ // the 4 tools are declared
83
+ for (const tool of ['dbhub_configure', 'dbhub_list_sources', 'dbhub_execute_sql', 'dbhub_search_objects']) {
84
+ assert.match(toolsSource, new RegExp('name:\\s*["\']' + tool + '["\']'), tool)
85
+ }
86
+ // NO tool declares a `dsn` schema property the model could fill with a secret
87
+ assert.doesNotMatch(toolsSource, /dsn:\s*\{\s*type:\s*["']string["']/)
88
+ // ... and no tool may declare a TUNNEL secret either: the model can name a
89
+ // host / user / key path, never a password or a passphrase.
90
+ assert.doesNotMatch(toolsSource, /sshPassword\s*:/)
91
+ assert.doesNotMatch(toolsSource, /sshPassphrase\s*:/)
92
+ // the tunnel parameters that DO exist are the non-secret ones
93
+ for (const key of ['sshHost', 'sshPort', 'sshUser', 'sshAuthKind', 'sshKeyPath', 'sshProxyJump', 'sshOff']) {
94
+ assert.match(toolsSource, new RegExp(key + ':\\s*\\{\\s*type:'), key)
95
+ }
96
+ // read-only is a model-facing toggle, but only in the tightening direction
97
+ assert.match(toolsSource, /readOnly:\s*\{\s*type:\s*["']boolean["']/)
98
+ assert.match(toolsSource, /args\.readOnly === false/)
99
+ // descriptions explicitly forbid passing credentials as arguments
100
+ assert.match(toolsSource, /不能作为本工具参数传入/)
101
+ })
102
+
103
+ test('no tool description embeds a real-looking DSN', () => {
104
+ // Placeholder examples (user:pass@, u:p@, username:password@, 账号:密码@) are
105
+ // tolerated user-facing copy; anything else in the password position is a leak.
106
+ const dsnLike = /[a-z]+:\/\/[^/\s]*:[^@\s]+@/g
107
+ const placeholders = /(user:pass|u:p|username:password|account:password|账号:密码)@$/
108
+ for (const m of toolsSource.matchAll(dsnLike)) {
109
+ assert.ok(placeholders.test(m[0]), 'real-looking DSN in copy: ' + m[0])
110
+ }
111
+ })
112
+
113
+ test('i18n copy never embeds a DSN with a password', () => {
114
+ const dsnLike = /[a-z]+:\/\/[^/\s]*:[^@\s]+@/
115
+ for (const locale of ['zh', 'en']) {
116
+ const dict = i18n.dictionaryFor(locale)
117
+ for (const key of Object.keys(dict)) {
118
+ assert.doesNotMatch(String(dict[key]), dsnLike, key + ' (' + locale + ')')
119
+ }
120
+ }
79
121
  })