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,244 @@
|
|
|
1
|
+
// dbhub_configure's options-only path, the §7.5 decision table and the D7
|
|
2
|
+
// security asymmetry (lib/tools.mjs runOptionsOnly / pendingOptionsOf):
|
|
3
|
+
//
|
|
4
|
+
// * the model may TIGHTEN an environment (readOnly:true) and may NEVER RELAX
|
|
5
|
+
// it — readOnly:false is refused before anything is read or written, and it
|
|
6
|
+
// must not open a dialog either (that would be a social-engineering channel);
|
|
7
|
+
// * an options-only call needs a home for the option: a persisted row, or an
|
|
8
|
+
// auto-discovered row that gets PROMOTED in the same explicit action (D10);
|
|
9
|
+
// with neither, the plugin says so and persists nothing;
|
|
10
|
+
// * options that accompany connection facts ride the SAME store write as the
|
|
11
|
+
// connection, so a same-endpoint call must leave the DSN byte-identical.
|
|
12
|
+
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-opts-'))
|
|
20
|
+
process.env.DSH_HOME = home
|
|
21
|
+
|
|
22
|
+
// Plain imports = the very singletons lib/tools.mjs uses (see copy-from.test.mjs).
|
|
23
|
+
const cfg = await import('../lib/config.mjs')
|
|
24
|
+
const mcp = await import('../lib/mcp.mjs')
|
|
25
|
+
const state = await import('../lib/state.mjs')
|
|
26
|
+
const { currentT } = await import('../lib/i18n.mjs')
|
|
27
|
+
const { registerCoreTools } = await import('../lib/tools.mjs?opts=' + Date.now())
|
|
28
|
+
|
|
29
|
+
// One workspace per case: distinct store keys, so no case can leak into another.
|
|
30
|
+
const WS_A = 'D:/work/a' // readOnly:true on an existing row
|
|
31
|
+
const WS_B = 'D:/work/b' // readOnly:false — the security red line
|
|
32
|
+
const WS_C = 'D:/work/c' // options with no row and no auto-discovery
|
|
33
|
+
const WS_D = 'D:/work/d' // auto-discovered -> promoted on an explicit options write
|
|
34
|
+
const WS_E = 'D:/work/e' // SSH facts + connection facts keep the original DSN
|
|
35
|
+
|
|
36
|
+
const REGISTRY = [
|
|
37
|
+
{ path: WS_A, title: 'a' },
|
|
38
|
+
{ path: WS_B, title: 'b' },
|
|
39
|
+
{ path: WS_C, title: 'c' },
|
|
40
|
+
{ path: WS_D, title: 'd' },
|
|
41
|
+
{ path: WS_E, title: 'e' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const AUTO_ENV = WS_D + '/.env'
|
|
45
|
+
const AUTO_DSN = 'mysql://127.0.0.1:3306/auto'
|
|
46
|
+
|
|
47
|
+
// `mise env` fails IMMEDIATELY (a failed handle settles at once, so no case
|
|
48
|
+
// burns the 2.5 s discovery budget), while a dbhub probe completes the MCP
|
|
49
|
+
// handshake: the probe-first loop's SUCCESS branches are what cases 1 and 5
|
|
50
|
+
// assert (option applied without rebuilding the DSN / without a dialog).
|
|
51
|
+
function failHandle() {
|
|
52
|
+
return {
|
|
53
|
+
stdin: { write() {} },
|
|
54
|
+
stdout: { on() {} },
|
|
55
|
+
stderr: { on() {} },
|
|
56
|
+
done: Promise.resolve({ exitCode: 1 }),
|
|
57
|
+
terminate() {},
|
|
58
|
+
waitForExit: async () => ({ exitCode: 1 }),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function okHandle() {
|
|
62
|
+
let onData = null
|
|
63
|
+
return {
|
|
64
|
+
stdin: {
|
|
65
|
+
write(chunk) {
|
|
66
|
+
let req
|
|
67
|
+
try {
|
|
68
|
+
req = JSON.parse(String(chunk))
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const result =
|
|
73
|
+
req.method === 'tools/call'
|
|
74
|
+
? { content: [{ type: 'text', text: 'ok' }] }
|
|
75
|
+
: {
|
|
76
|
+
protocolVersion: '2025-03-26',
|
|
77
|
+
capabilities: {},
|
|
78
|
+
serverInfo: { name: 'fake-dbhub', version: '1.4.0' },
|
|
79
|
+
}
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
if (onData) onData(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n')
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
stdout: {
|
|
86
|
+
on(event, handler) {
|
|
87
|
+
if (event === 'data') onData = handler
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
stderr: { on() {} },
|
|
91
|
+
done: new Promise(() => {}), // stays alive: no early settle for a live probe
|
|
92
|
+
terminate() {},
|
|
93
|
+
waitForExit: async () => ({ exitCode: 0 }),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const subprocess = {
|
|
97
|
+
resolveExecutable: async (name) => '/nonexistent/' + name,
|
|
98
|
+
spawn: ({ argv }) => (String((argv && argv[0]) || '').includes('dbhub') ? okHandle() : failHandle()),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dialogs = []
|
|
102
|
+
const fakeQuestions = {
|
|
103
|
+
ask: async (params) => {
|
|
104
|
+
dialogs.push(params)
|
|
105
|
+
return { answers: [] } // reached at all = an unexpected dialog
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
const fakeFs = {
|
|
109
|
+
resolve: async (name, opts) => {
|
|
110
|
+
if (name === '.env' && opts && opts.cwd === WS_D) return AUTO_ENV
|
|
111
|
+
throw new Error('no .env')
|
|
112
|
+
},
|
|
113
|
+
readText: async (target) => (target === AUTO_ENV ? 'DSN=' + AUTO_DSN + '\n' : ''),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const captured = {}
|
|
117
|
+
const ctx = {
|
|
118
|
+
tools: {
|
|
119
|
+
register: (def) => {
|
|
120
|
+
captured[def.name] = def
|
|
121
|
+
return () => {}
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
get: (name) => {
|
|
125
|
+
if (name === 'userQuestions') return fakeQuestions
|
|
126
|
+
if (name === 'fs') return fakeFs
|
|
127
|
+
if (name === 'workspaceRegistry') return { list: async () => REGISTRY }
|
|
128
|
+
return undefined
|
|
129
|
+
},
|
|
130
|
+
inject: () => {},
|
|
131
|
+
}
|
|
132
|
+
registerCoreTools(ctx, subprocess)
|
|
133
|
+
|
|
134
|
+
const execFor = (wsPath) => ({ agent: { session: { header: { cwd: wsPath } } }, signal: undefined })
|
|
135
|
+
const configure = (args, wsPath) => captured.dbhub_configure.execute(args, execFor(wsPath))
|
|
136
|
+
const rowOf = (wsPath, env) =>
|
|
137
|
+
cfg.listWorkspaceEnvironments(cfg.store).find((r) => r.wsPath === wsPath && r.env === env)
|
|
138
|
+
|
|
139
|
+
test.before(() => {
|
|
140
|
+
if (!state.isEnabled()) state.setEnabled(true)
|
|
141
|
+
cfg.resetAutoDsnCache()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test.after(() => {
|
|
145
|
+
rmSync(home, { recursive: true, force: true })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('readOnly:true on an existing row tightens the switch with zero dialogs', async () => {
|
|
149
|
+
const dsn = 'mysql://127.0.0.1:3306/a'
|
|
150
|
+
cfg.setWorkspaceEnv(WS_A, 'default', dsn, 'user')
|
|
151
|
+
dialogs.length = 0
|
|
152
|
+
|
|
153
|
+
const res = await configure({ readOnly: true }, WS_A)
|
|
154
|
+
|
|
155
|
+
assert.equal(res.ok, true, res.text)
|
|
156
|
+
const row = rowOf(WS_A, 'default')
|
|
157
|
+
assert.equal(row.ro, true, 'the read-only flag is persisted')
|
|
158
|
+
assert.equal(row.dsn, dsn, 'an options-only write never touches the DSN')
|
|
159
|
+
assert.equal(row.source, 'user')
|
|
160
|
+
assert.equal(dialogs.length, 0, 'tightening a safety switch needs no user input')
|
|
161
|
+
assert.ok(res.text.includes(currentT('result.readOnlyOn', { env: 'default' })), res.text)
|
|
162
|
+
// The option write still runs the host-side check of the EFFECTIVE connection.
|
|
163
|
+
assert.ok(res.text.includes(currentT('result.savedProbeOk')), res.text)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('readOnly:false is refused and writes nothing (user-only action)', async () => {
|
|
167
|
+
cfg.setWorkspaceEnv(WS_B, 'default', 'mysql://127.0.0.1:3306/b', 'user')
|
|
168
|
+
const snapshot = JSON.stringify(cfg.store[WS_B])
|
|
169
|
+
dialogs.length = 0
|
|
170
|
+
|
|
171
|
+
const res = await configure({ readOnly: false }, WS_B)
|
|
172
|
+
|
|
173
|
+
assert.equal(res.ok, false)
|
|
174
|
+
assert.equal(res.text, currentT('result.readOnlyUserOnly'))
|
|
175
|
+
assert.equal(
|
|
176
|
+
JSON.stringify(cfg.store[WS_B]),
|
|
177
|
+
snapshot,
|
|
178
|
+
'the store must be byte-identical after a refused relax',
|
|
179
|
+
)
|
|
180
|
+
assert.equal(dialogs.length, 0, 'a refused relax must not even prompt the user')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('options with no row and no auto-discovery are refused with guidance', async () => {
|
|
184
|
+
dialogs.length = 0
|
|
185
|
+
|
|
186
|
+
const res = await configure({ readOnly: true }, WS_C)
|
|
187
|
+
|
|
188
|
+
assert.equal(res.ok, false)
|
|
189
|
+
assert.equal(res.text, currentT('result.needConnFirst'))
|
|
190
|
+
assert.equal(cfg.store[WS_C], undefined, 'nothing may be persisted without a connection')
|
|
191
|
+
assert.equal(dialogs.length, 0)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('options on an auto-discovered environment promote it to a persisted row', async () => {
|
|
195
|
+
assert.equal(cfg.store[WS_D], undefined, 'precondition: the connection is not persisted')
|
|
196
|
+
|
|
197
|
+
const res = await configure({ readOnly: true }, WS_D)
|
|
198
|
+
|
|
199
|
+
assert.equal(res.ok, true, res.text)
|
|
200
|
+
const row = rowOf(WS_D, 'default')
|
|
201
|
+
assert.ok(row, 'the promotion must persist the auto-discovered connection')
|
|
202
|
+
assert.equal(row.dsn, AUTO_DSN)
|
|
203
|
+
assert.equal(row.source, 'promoted')
|
|
204
|
+
assert.equal(row.ro, true)
|
|
205
|
+
assert.ok(res.text.includes(currentT('result.promotedNotice')), res.text)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('SSH facts on an existing row keep the original DSN when the endpoint is unchanged', async () => {
|
|
209
|
+
const dsn = 'mysql://u:p@127.0.0.1:3306/e'
|
|
210
|
+
cfg.setWorkspaceEnv(WS_E, 'default', dsn, 'user')
|
|
211
|
+
dialogs.length = 0
|
|
212
|
+
|
|
213
|
+
const res = await configure(
|
|
214
|
+
{
|
|
215
|
+
sshHost: 'bastion.example.com',
|
|
216
|
+
sshUser: 'deploy',
|
|
217
|
+
sshAuthKind: 'key',
|
|
218
|
+
sshKeyPath: '~/.ssh/id_ed25519',
|
|
219
|
+
host: '127.0.0.1',
|
|
220
|
+
database: 'e',
|
|
221
|
+
},
|
|
222
|
+
WS_E,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
assert.equal(res.ok, true, res.text)
|
|
226
|
+
const row = rowOf(WS_E, 'default')
|
|
227
|
+
assert.equal(row.dsn, dsn, 'a same-endpoint call must not rebuild the DSN')
|
|
228
|
+
assert.ok(row.ssh, 'the tunnel is persisted with the connection facts')
|
|
229
|
+
assert.equal(row.ssh.host, 'bastion.example.com')
|
|
230
|
+
assert.equal(row.ssh.auth, 'key')
|
|
231
|
+
assert.equal(dialogs.length, 0, 'a model-supplied key path needs no dialog')
|
|
232
|
+
// The unchanged endpoint means the EXISTING row (with its stored password) was
|
|
233
|
+
// verified in place — no re-entry, no rebuilt DSN.
|
|
234
|
+
assert.ok(
|
|
235
|
+
res.text.includes(
|
|
236
|
+
currentT('result.existingOk', {
|
|
237
|
+
env: 'default',
|
|
238
|
+
conn: 'mysql://127.0.0.1:3306/e',
|
|
239
|
+
srcId: mcp.sourceIdOf({ title: 'e', wsPath: WS_E, env: 'default' }),
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
res.text,
|
|
243
|
+
)
|
|
244
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// dbhub_configure's SSH tunnel surface (lib/tools.mjs resolveSshRequest and the
|
|
2
|
+
// options-only write it feeds). §7.5's last rows:
|
|
3
|
+
//
|
|
4
|
+
// * the model may NAME a tunnel (host/port/user/auth kind/key path) but never
|
|
5
|
+
// CARRY its secret: a password is collected in a UI dialog, stored host-side,
|
|
6
|
+
// and must not appear in the tool result either;
|
|
7
|
+
// * a model-argument mistake (no user, multi-hop ProxyJump) is refused without
|
|
8
|
+
// a dialog and without a write;
|
|
9
|
+
// * a dismissed dialog cancels the whole call;
|
|
10
|
+
// * sshOff clears ONLY the tunnel: the connection and the safety switch stay.
|
|
11
|
+
|
|
12
|
+
import test from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-ssh-'))
|
|
19
|
+
process.env.DSH_HOME = home
|
|
20
|
+
|
|
21
|
+
// Plain imports = the very singletons lib/tools.mjs uses (see copy-from.test.mjs).
|
|
22
|
+
const cfg = await import('../lib/config.mjs')
|
|
23
|
+
const state = await import('../lib/state.mjs')
|
|
24
|
+
const { currentT } = await import('../lib/i18n.mjs')
|
|
25
|
+
const { registerCoreTools } = await import('../lib/tools.mjs?ssh=' + Date.now())
|
|
26
|
+
|
|
27
|
+
// One workspace per case, so a leaked write can never masquerade as a pass.
|
|
28
|
+
const WS_P = 'D:/work/p' // password auth -> dialog -> stored secret
|
|
29
|
+
const WS_M = 'D:/work/m' // missing sshUser
|
|
30
|
+
const WS_X = 'D:/work/x' // user cancels the dialog
|
|
31
|
+
const WS_O = 'D:/work/o' // sshOff keeps DSN + read-only
|
|
32
|
+
const WS_J = 'D:/work/j' // multi-hop ProxyJump
|
|
33
|
+
const WS_K = 'D:/work/k' // key auth with a model-supplied key path
|
|
34
|
+
|
|
35
|
+
const REGISTRY = [WS_P, WS_M, WS_X, WS_O, WS_J, WS_K].map((path) => ({
|
|
36
|
+
path,
|
|
37
|
+
title: path.slice(-1),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
// Failed probe (exit 1): the option write happens before the probe and its
|
|
41
|
+
// outcome is only appended to the reply, so every case asserts store state.
|
|
42
|
+
const subprocess = {
|
|
43
|
+
resolveExecutable: async (name) => '/nonexistent/' + name,
|
|
44
|
+
spawn: () => ({
|
|
45
|
+
stdin: { write() {} },
|
|
46
|
+
stdout: { on() {} },
|
|
47
|
+
stderr: { on() {} },
|
|
48
|
+
done: Promise.resolve({ exitCode: 1 }),
|
|
49
|
+
terminate() {},
|
|
50
|
+
waitForExit: async () => ({ exitCode: 1 }),
|
|
51
|
+
}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dialogs = []
|
|
55
|
+
let responder = async () => undefined
|
|
56
|
+
const fakeQuestions = {
|
|
57
|
+
ask: async (params) => {
|
|
58
|
+
dialogs.push(params)
|
|
59
|
+
return responder(params)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
const fakeFs = {
|
|
63
|
+
resolve: async () => {
|
|
64
|
+
throw new Error('no .env')
|
|
65
|
+
},
|
|
66
|
+
readText: async () => '',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const captured = {}
|
|
70
|
+
const ctx = {
|
|
71
|
+
tools: {
|
|
72
|
+
register: (def) => {
|
|
73
|
+
captured[def.name] = def
|
|
74
|
+
return () => {}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
get: (name) => {
|
|
78
|
+
if (name === 'userQuestions') return fakeQuestions
|
|
79
|
+
if (name === 'fs') return fakeFs
|
|
80
|
+
if (name === 'workspaceRegistry') return { list: async () => REGISTRY }
|
|
81
|
+
return undefined
|
|
82
|
+
},
|
|
83
|
+
inject: () => {},
|
|
84
|
+
}
|
|
85
|
+
registerCoreTools(ctx, subprocess)
|
|
86
|
+
|
|
87
|
+
const execFor = (wsPath) => ({ agent: { session: { header: { cwd: wsPath } } }, signal: undefined })
|
|
88
|
+
const configure = (args, wsPath) => captured.dbhub_configure.execute(args, execFor(wsPath))
|
|
89
|
+
const rowOf = (wsPath, env) =>
|
|
90
|
+
cfg.listWorkspaceEnvironments(cfg.store).find((r) => r.wsPath === wsPath && r.env === env)
|
|
91
|
+
|
|
92
|
+
const HOST = 'bastion.example.com'
|
|
93
|
+
|
|
94
|
+
test.before(() => {
|
|
95
|
+
if (!state.isEnabled()) state.setEnabled(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test.after(() => {
|
|
99
|
+
rmSync(home, { recursive: true, force: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('a password-auth tunnel stores the user-entered password and never echoes it', async () => {
|
|
103
|
+
cfg.setWorkspaceEnv(WS_P, 'default', 'mysql://u:p@127.0.0.1:3306/p', 'user')
|
|
104
|
+
dialogs.length = 0
|
|
105
|
+
responder = async () => ({ answers: [{ id: 'sshPassword', custom: 'CHANGE_ME' }] })
|
|
106
|
+
|
|
107
|
+
const res = await configure(
|
|
108
|
+
{ sshHost: HOST, sshUser: 'deploy', sshAuthKind: 'password' },
|
|
109
|
+
WS_P,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert.equal(res.ok, true, res.text)
|
|
113
|
+
const row = rowOf(WS_P, 'default')
|
|
114
|
+
assert.ok(row.ssh, 'the tunnel is persisted')
|
|
115
|
+
assert.equal(row.ssh.auth, 'password')
|
|
116
|
+
assert.equal(row.ssh.password, 'CHANGE_ME', 'the dialog secret is stored host-side')
|
|
117
|
+
assert.equal(row.ssh.host, HOST)
|
|
118
|
+
|
|
119
|
+
assert.equal(dialogs.length, 1, 'exactly one password dialog')
|
|
120
|
+
assert.equal(dialogs[0].questions[0].id, 'sshPassword')
|
|
121
|
+
|
|
122
|
+
// Zero-knowledge surface: the reply may describe the tunnel, never its secret.
|
|
123
|
+
assert.ok(!res.text.includes('CHANGE_ME'), 'the stored password must not be echoed to the model')
|
|
124
|
+
assert.ok(res.text.includes(HOST), res.text)
|
|
125
|
+
assert.ok(res.text.includes('deploy'), res.text)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an SSH request without a user is refused before anything is written', async () => {
|
|
129
|
+
cfg.setWorkspaceEnv(WS_M, 'default', 'mysql://127.0.0.1:3306/m', 'user')
|
|
130
|
+
const snapshot = JSON.stringify(cfg.store[WS_M])
|
|
131
|
+
dialogs.length = 0
|
|
132
|
+
responder = async () => undefined
|
|
133
|
+
|
|
134
|
+
const res = await configure(
|
|
135
|
+
{ sshHost: HOST, sshAuthKind: 'key', sshKeyPath: '~/.ssh/id_ed25519' },
|
|
136
|
+
WS_M,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert.equal(res.ok, false)
|
|
140
|
+
assert.equal(res.text, currentT('result.sshMissingUser'))
|
|
141
|
+
assert.equal(JSON.stringify(cfg.store[WS_M]), snapshot, 'nothing may be written')
|
|
142
|
+
assert.equal(dialogs.length, 0, 'a missing host/user is a model-argument error, not a dialog')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('a dismissed SSH dialog cancels the call and writes nothing', async () => {
|
|
146
|
+
cfg.setWorkspaceEnv(WS_X, 'default', 'mysql://127.0.0.1:3306/x', 'user')
|
|
147
|
+
const snapshot = JSON.stringify(cfg.store[WS_X])
|
|
148
|
+
dialogs.length = 0
|
|
149
|
+
responder = async () => undefined // the user closed/cancelled the ask
|
|
150
|
+
|
|
151
|
+
const res = await configure(
|
|
152
|
+
{ sshHost: HOST, sshUser: 'deploy', sshAuthKind: 'password' },
|
|
153
|
+
WS_X,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
assert.equal(res.ok, false)
|
|
157
|
+
assert.equal(res.text, currentT('result.modeCanceled'))
|
|
158
|
+
assert.equal(dialogs.length, 1)
|
|
159
|
+
assert.equal(JSON.stringify(cfg.store[WS_X]), snapshot, 'a cancel writes nothing')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('sshOff removes the tunnel while the DSN and read-only flag survive', async () => {
|
|
163
|
+
const dsn = 'mysql://u:p@127.0.0.1:3306/o'
|
|
164
|
+
cfg.setWorkspaceEnv(WS_O, 'default', dsn, 'user', {
|
|
165
|
+
readOnly: true,
|
|
166
|
+
ssh: {
|
|
167
|
+
host: HOST,
|
|
168
|
+
port: 22,
|
|
169
|
+
user: 'deploy',
|
|
170
|
+
auth: 'key',
|
|
171
|
+
keyPath: '~/.ssh/id_ed25519',
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const res = await configure({ sshOff: true }, WS_O)
|
|
176
|
+
|
|
177
|
+
assert.equal(res.ok, true, res.text)
|
|
178
|
+
const entry = cfg.store[WS_O].environments.default
|
|
179
|
+
assert.equal('ssh' in entry, false, 'the ssh field must be DELETED, not blanked')
|
|
180
|
+
assert.equal(entry.dsn, dsn, 'the connection string is untouched')
|
|
181
|
+
assert.equal(entry.readOnly, true, 'the safety switch survives sshOff')
|
|
182
|
+
|
|
183
|
+
const row = rowOf(WS_O, 'default')
|
|
184
|
+
assert.equal(row.ssh, null)
|
|
185
|
+
assert.equal(row.ro, true)
|
|
186
|
+
assert.ok(res.text.includes(currentT('result.sshCleared', { env: 'default' })), res.text)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('a multi-hop ProxyJump is refused and nothing is written', async () => {
|
|
190
|
+
cfg.setWorkspaceEnv(WS_J, 'default', 'mysql://127.0.0.1:3306/j', 'user')
|
|
191
|
+
const snapshot = JSON.stringify(cfg.store[WS_J])
|
|
192
|
+
dialogs.length = 0
|
|
193
|
+
|
|
194
|
+
const res = await configure(
|
|
195
|
+
{
|
|
196
|
+
sshHost: HOST,
|
|
197
|
+
sshUser: 'deploy',
|
|
198
|
+
sshAuthKind: 'key',
|
|
199
|
+
sshKeyPath: '~/.ssh/id_ed25519',
|
|
200
|
+
sshProxyJump: 'a:22,b:22',
|
|
201
|
+
},
|
|
202
|
+
WS_J,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert.equal(res.ok, false)
|
|
206
|
+
assert.equal(res.text, currentT('result.sshMultiHopUnsupported'))
|
|
207
|
+
assert.equal(JSON.stringify(cfg.store[WS_J]), snapshot, 'a refused tunnel writes nothing')
|
|
208
|
+
assert.equal(dialogs.length, 0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('key auth with a model-supplied key path needs no dialog and stores no secret', async () => {
|
|
212
|
+
cfg.setWorkspaceEnv(WS_K, 'default', 'mysql://u:p@127.0.0.1:3306/k', 'user')
|
|
213
|
+
dialogs.length = 0
|
|
214
|
+
responder = async () => undefined
|
|
215
|
+
|
|
216
|
+
const res = await configure(
|
|
217
|
+
{ sshHost: HOST, sshUser: 'deploy', sshAuthKind: 'key', sshKeyPath: '~/.ssh/id_ed25519' },
|
|
218
|
+
WS_K,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert.equal(res.ok, true, res.text)
|
|
222
|
+
assert.equal(dialogs.length, 0, 'a key path is not a secret: no dialog')
|
|
223
|
+
const row = rowOf(WS_K, 'default')
|
|
224
|
+
assert.equal(row.ssh.auth, 'key')
|
|
225
|
+
assert.equal(row.ssh.keyPath, '~/.ssh/id_ed25519')
|
|
226
|
+
assert.equal(row.ssh.password, undefined)
|
|
227
|
+
assert.equal(row.ssh.passphrase, undefined, 'no passphrase was given, none is stored')
|
|
228
|
+
|
|
229
|
+
assert.ok(res.text.includes(HOST), res.text)
|
|
230
|
+
assert.ok(res.text.includes('deploy'), res.text)
|
|
231
|
+
assert.ok(!res.text.includes('CHANGE_ME'), 'no secret may surface in the reply')
|
|
232
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// dbhub_configure's `copyFrom` path (lib/tools.mjs runCopyFrom): reusing another
|
|
2
|
+
// workspace's connection must create a REAL row in THIS workspace, and the model
|
|
3
|
+
// must never be told "source not found" for a handle the plugin itself published.
|
|
4
|
+
//
|
|
5
|
+
// Regression this file locks down: `runCopyFrom` was called with the executing
|
|
6
|
+
// context in the `target` position, so `target.path` was undefined and EVERY copy
|
|
7
|
+
// failed (or wrote under an undefined workspace key). The copy is done host-side
|
|
8
|
+
// only: the DSN is read from the source row and written to the target row, and
|
|
9
|
+
// the model sees neither the DSN nor the password.
|
|
10
|
+
|
|
11
|
+
import test from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-copy-'))
|
|
18
|
+
process.env.DSH_HOME = home
|
|
19
|
+
|
|
20
|
+
// Plain imports on purpose: lib/tools.mjs (imported below with a cache-busting
|
|
21
|
+
// query) resolves './config.mjs' / './mcp.mjs' / './state.mjs' / './i18n.mjs'
|
|
22
|
+
// WITHOUT a query, so these are the very singletons the plugin uses.
|
|
23
|
+
const cfg = await import('../lib/config.mjs')
|
|
24
|
+
const mcp = await import('../lib/mcp.mjs')
|
|
25
|
+
const state = await import('../lib/state.mjs')
|
|
26
|
+
const { currentT } = await import('../lib/i18n.mjs')
|
|
27
|
+
const { registerCoreTools } = await import('../lib/tools.mjs?copy=' + Date.now())
|
|
28
|
+
|
|
29
|
+
const APP = 'D:/work/app'
|
|
30
|
+
const OTHER = 'D:/work/other'
|
|
31
|
+
const SRC_DSN = 'mysql://127.0.0.1:3306/app'
|
|
32
|
+
|
|
33
|
+
// A probe that always fails (exit 1) is all these cases need: nothing real may
|
|
34
|
+
// run, and the copy itself is the regression under test. The probe outcome is
|
|
35
|
+
// only appended to the reply.
|
|
36
|
+
const subprocess = {
|
|
37
|
+
resolveExecutable: async (name) => '/nonexistent/' + name,
|
|
38
|
+
spawn: () => ({
|
|
39
|
+
stdin: { write() {} },
|
|
40
|
+
stdout: { on() {} },
|
|
41
|
+
stderr: { on() {} },
|
|
42
|
+
done: Promise.resolve({ exitCode: 1 }),
|
|
43
|
+
terminate() {},
|
|
44
|
+
waitForExit: async () => ({ exitCode: 1 }),
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const REGISTRY = [
|
|
49
|
+
{ path: APP, title: 'app' },
|
|
50
|
+
{ path: OTHER, title: 'other' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const dialogs = []
|
|
54
|
+
const fakeQuestions = {
|
|
55
|
+
ask: async (params) => {
|
|
56
|
+
dialogs.push(params)
|
|
57
|
+
return undefined // a copy must never need a dialog anyway
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
const fakeFs = {
|
|
61
|
+
resolve: async () => {
|
|
62
|
+
throw new Error('no .env')
|
|
63
|
+
},
|
|
64
|
+
readText: async () => '',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const captured = {}
|
|
68
|
+
const ctx = {
|
|
69
|
+
tools: {
|
|
70
|
+
register: (def) => {
|
|
71
|
+
captured[def.name] = def
|
|
72
|
+
return () => {}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
get: (name) => {
|
|
76
|
+
if (name === 'userQuestions') return fakeQuestions
|
|
77
|
+
if (name === 'fs') return fakeFs
|
|
78
|
+
if (name === 'workspaceRegistry') return { list: async () => REGISTRY }
|
|
79
|
+
return undefined
|
|
80
|
+
},
|
|
81
|
+
inject: () => {},
|
|
82
|
+
}
|
|
83
|
+
registerCoreTools(ctx, subprocess)
|
|
84
|
+
|
|
85
|
+
const exec = { agent: { session: { header: { cwd: APP } } }, signal: undefined }
|
|
86
|
+
|
|
87
|
+
test.before(() => {
|
|
88
|
+
if (!state.isEnabled()) state.setEnabled(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test.after(() => {
|
|
92
|
+
rmSync(home, { recursive: true, force: true })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('copyFrom brings another workspace connection into THIS workspace', async () => {
|
|
96
|
+
cfg.setWorkspaceEnv(OTHER, 'default', SRC_DSN, 'user')
|
|
97
|
+
|
|
98
|
+
// The handle the model would have read out of dbhub_list_sources — the copy
|
|
99
|
+
// must accept exactly that.
|
|
100
|
+
const listed = await captured.dbhub_list_sources.execute({}, exec)
|
|
101
|
+
assert.equal(listed.ok, true, listed.text)
|
|
102
|
+
const otherId = mcp.sourceIdOf({ title: 'other', wsPath: OTHER, env: 'default' })
|
|
103
|
+
assert.ok(listed.text.includes(otherId), 'the source handle must be listed')
|
|
104
|
+
|
|
105
|
+
dialogs.length = 0
|
|
106
|
+
const res = await captured.dbhub_configure.execute({ copyFrom: otherId, env: 'prod' }, exec)
|
|
107
|
+
|
|
108
|
+
assert.equal(res.ok, true, res.text)
|
|
109
|
+
assert.notEqual(res.text, currentT('result.noSource', { src: otherId }))
|
|
110
|
+
assert.ok(!res.text.includes(currentT('result.noSource', { src: otherId })), res.text)
|
|
111
|
+
|
|
112
|
+
const row = cfg
|
|
113
|
+
.listWorkspaceEnvironments(cfg.store)
|
|
114
|
+
.find((r) => r.wsPath === APP && r.env === 'prod')
|
|
115
|
+
assert.ok(row, 'the copied row must live in the CURRENT workspace')
|
|
116
|
+
assert.equal(row.dsn, SRC_DSN, 'the source DSN is copied host-side, verbatim')
|
|
117
|
+
assert.equal(row.source, 'copied')
|
|
118
|
+
assert.equal(dialogs.length, 0, 'a host-side copy needs no user input')
|
|
119
|
+
|
|
120
|
+
// The source row is untouched.
|
|
121
|
+
const source = cfg
|
|
122
|
+
.listWorkspaceEnvironments(cfg.store)
|
|
123
|
+
.find((r) => r.wsPath === OTHER && r.env === 'default')
|
|
124
|
+
assert.equal(source.dsn, SRC_DSN)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('copying a row onto itself is refused without rewriting the row', async () => {
|
|
128
|
+
const selfDsn = 'mysql://127.0.0.1:3306/self'
|
|
129
|
+
cfg.setWorkspaceEnv(APP, 'self', selfDsn, 'user')
|
|
130
|
+
const selfId = mcp.sourceIdOf({ title: 'app', wsPath: APP, env: 'self' })
|
|
131
|
+
|
|
132
|
+
const res = await captured.dbhub_configure.execute({ copyFrom: selfId, env: 'self' }, exec)
|
|
133
|
+
|
|
134
|
+
assert.equal(res.ok, false)
|
|
135
|
+
assert.equal(res.text, currentT('result.copySelf', { title: 'app', env: 'self' }))
|
|
136
|
+
|
|
137
|
+
const row = cfg
|
|
138
|
+
.listWorkspaceEnvironments(cfg.store)
|
|
139
|
+
.find((r) => r.wsPath === APP && r.env === 'self')
|
|
140
|
+
assert.equal(row.dsn, selfDsn)
|
|
141
|
+
assert.equal(row.source, 'user', 'a refused self-copy must not rewrite the row')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('renameFrom combined with copyFrom is refused as an argument conflict', async () => {
|
|
145
|
+
const before = JSON.stringify(cfg.store[APP] || {})
|
|
146
|
+
|
|
147
|
+
const res = await captured.dbhub_configure.execute(
|
|
148
|
+
{ renameFrom: 'default', copyFrom: 'other', env: 'prod2' },
|
|
149
|
+
exec,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
assert.equal(res.ok, false)
|
|
153
|
+
assert.equal(res.text, currentT('result.argConflict'))
|
|
154
|
+
assert.equal(JSON.stringify(cfg.store[APP] || {}), before, 'a refused call writes nothing')
|
|
155
|
+
})
|