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,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
+ })