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
|
@@ -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
|
+
})
|
package/test/util.test.mjs
CHANGED
|
@@ -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.
|
|
44
|
-
assert.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// the
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
})
|