dsh-dbhub-live 5.1.0 → 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 +116 -254
- package/README.en.md +96 -11
- package/README.md +97 -12
- 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 +41 -3
- package/lib/client.js +704 -27
- package/lib/config.mjs +3 -0
- package/lib/i18n.mjs +14 -4
- package/lib/index.mjs +107 -0
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +16 -1
- package/lib/policy.mjs +92 -0
- package/lib/tools.mjs +4 -1
- package/lib/update.mjs +244 -0
- package/package.json +32 -9
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +18 -10
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +286 -2
- package/test/client-format.test.mjs +89 -5
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +28 -15
- package/test/policy.test.mjs +132 -0
- package/test/update.test.mjs +270 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Unit tests for the Host's version metadata (lib/meta.mjs): semver parsing and
|
|
2
|
+
// precedence, the "is an update newer?" question the update checker asks, the
|
|
3
|
+
// dsh >= 0.2.1 compatibility gate, and the runtime probes (plugin version from
|
|
4
|
+
// package.json, dsh version from an installed package layout).
|
|
5
|
+
//
|
|
6
|
+
// The discovery walk is driven against a temp layout with a fake argv entry, so
|
|
7
|
+
// these tests never touch the real dsh installation; no DSH_HOME / store paths
|
|
8
|
+
// are involved at all (this module reads package.json files only).
|
|
9
|
+
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DSH_MODERN_BASE,
|
|
18
|
+
DSH_PACKAGE_NAMES,
|
|
19
|
+
PLUGIN_NAME,
|
|
20
|
+
atLeastBase,
|
|
21
|
+
compareSemver,
|
|
22
|
+
detectDshRuntime,
|
|
23
|
+
dshIsModern,
|
|
24
|
+
isNewer,
|
|
25
|
+
parseSemver,
|
|
26
|
+
readPluginVersion,
|
|
27
|
+
versionMeta,
|
|
28
|
+
walkDshVersion,
|
|
29
|
+
} from '../lib/meta.mjs'
|
|
30
|
+
|
|
31
|
+
// ── semver primitives ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('parseSemver accepts v-prefix, prerelease and build metadata', () => {
|
|
34
|
+
assert.deepEqual(parseSemver('1.2.3').prerelease, [])
|
|
35
|
+
assert.equal(parseSemver('v5.2.0').major, 5)
|
|
36
|
+
assert.equal(parseSemver('v5.2.0').minor, 2)
|
|
37
|
+
assert.equal(parseSemver(' v5.2.0-dev.1 ').patch, 0)
|
|
38
|
+
assert.deepEqual(parseSemver('5.2.0-dev.1').prerelease, ['dev', '1'])
|
|
39
|
+
// Prerelease AND build metadata together: only the prerelease is compared.
|
|
40
|
+
assert.deepEqual(parseSemver('1.0.0-rc.1+build').prerelease, ['rc', '1'])
|
|
41
|
+
// Build metadata never participates in parsing beyond being accepted.
|
|
42
|
+
assert.deepEqual(parseSemver('1.0.0+sha.abc').prerelease, [])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('parseSemver rejects non-semver junk instead of coercing it', () => {
|
|
46
|
+
for (const bad of ['', ' ', 'latest', '1.2', '192.0.2.10', 'x.y.z', '1..2', '1.2.3-', null, undefined, 42, 'dev.1']) {
|
|
47
|
+
assert.equal(parseSemver(bad), undefined, 'should reject: ' + String(bad))
|
|
48
|
+
}
|
|
49
|
+
// Empty prerelease identifiers are invalid semver.
|
|
50
|
+
assert.equal(parseSemver('1.2.3-alpha..beta'), undefined)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('compareSemver follows semver precedence (incl. prerelease ordering)', () => {
|
|
54
|
+
// The canonical chain from semver.org §11.
|
|
55
|
+
const chain = [
|
|
56
|
+
'1.0.0-alpha',
|
|
57
|
+
'1.0.0-alpha.1',
|
|
58
|
+
'1.0.0-alpha.beta',
|
|
59
|
+
'1.0.0-beta',
|
|
60
|
+
'1.0.0-beta.2',
|
|
61
|
+
'1.0.0-beta.11',
|
|
62
|
+
'1.0.0-rc.1',
|
|
63
|
+
'1.0.0',
|
|
64
|
+
]
|
|
65
|
+
for (let i = 0; i < chain.length - 1; i += 1) {
|
|
66
|
+
assert.equal(compareSemver(chain[i], chain[i + 1]), -1, chain[i] + ' < ' + chain[i + 1])
|
|
67
|
+
assert.equal(compareSemver(chain[i + 1], chain[i]), 1, chain[i + 1] + ' > ' + chain[i])
|
|
68
|
+
assert.equal(isNewer(chain[i + 1], chain[i]), true)
|
|
69
|
+
assert.equal(isNewer(chain[i], chain[i + 1]), false)
|
|
70
|
+
}
|
|
71
|
+
// Numeric identifiers compare numerically (not lexically): 2 < 11.
|
|
72
|
+
assert.equal(compareSemver('1.0.0-beta.2', '1.0.0-beta.11'), -1)
|
|
73
|
+
// Build metadata is ignored for precedence.
|
|
74
|
+
assert.equal(compareSemver('1.0.0+build.9', '1.0.0+sha.1'), 0)
|
|
75
|
+
assert.equal(compareSemver('1.0.0', 'v1.0.0'), 0)
|
|
76
|
+
// Triplet ordering.
|
|
77
|
+
assert.equal(compareSemver('0.2.1', '0.2.0'), 1)
|
|
78
|
+
assert.equal(compareSemver('5.2.0', '5.10.0'), -1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('compareSemver/isNewer never throw on invalid input', () => {
|
|
82
|
+
assert.equal(compareSemver('nope', '1.0.0'), 0)
|
|
83
|
+
assert.equal(compareSemver('1.0.0', undefined), 0)
|
|
84
|
+
assert.equal(isNewer('5.2.0', ''), false, 'unknown current version never claims an update')
|
|
85
|
+
assert.equal(isNewer('', '5.1.0'), false)
|
|
86
|
+
assert.equal(isNewer('garbage', '5.1.0'), false)
|
|
87
|
+
assert.equal(isNewer(undefined, undefined), false)
|
|
88
|
+
// compareSemver also takes already-parsed objects.
|
|
89
|
+
assert.equal(compareSemver(parseSemver('1.0.0'), '1.0.0'), 0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('isNewer is prerelease-aware for this repo dev builds', () => {
|
|
93
|
+
// A dev build is OLDER than the release with the same base version ...
|
|
94
|
+
assert.equal(isNewer('5.2.0', '5.2.0-dev.1'), true)
|
|
95
|
+
assert.equal(isNewer('5.2.0-dev.2', '5.2.0-dev.1'), true)
|
|
96
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.2.0'), false)
|
|
97
|
+
// ... and 5.2.0-dev.1 is NEWER than the previous 5.1.x release line.
|
|
98
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.1.0'), true)
|
|
99
|
+
// Equal versions are never an update.
|
|
100
|
+
assert.equal(isNewer('5.2.0', '5.2.0'), false)
|
|
101
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.2.0-dev.1'), false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ── the dsh >= 0.2.1 gate ─────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
test('atLeastBase compares the numeric triplet and ignores prerelease', () => {
|
|
107
|
+
assert.equal(atLeastBase('0.2.1-rc.2', DSH_MODERN_BASE), true, 'a 0.2.1 prerelease counts as 0.2.1')
|
|
108
|
+
assert.equal(atLeastBase('0.2.1', DSH_MODERN_BASE), true)
|
|
109
|
+
assert.equal(atLeastBase('0.2.2', DSH_MODERN_BASE), true)
|
|
110
|
+
assert.equal(atLeastBase('1.0.0', DSH_MODERN_BASE), true)
|
|
111
|
+
assert.equal(atLeastBase('0.2.0-rc.2', DSH_MODERN_BASE), false)
|
|
112
|
+
assert.equal(atLeastBase('0.1.9', DSH_MODERN_BASE), false)
|
|
113
|
+
assert.equal(atLeastBase('', DSH_MODERN_BASE), false)
|
|
114
|
+
assert.equal(atLeastBase('unknown', DSH_MODERN_BASE), false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('dshIsModern: resolved version wins; unresolvable is modern only on desktop', () => {
|
|
118
|
+
assert.equal(dshIsModern({ version: '0.2.1-rc.2', desktop: false }), true)
|
|
119
|
+
assert.equal(dshIsModern({ version: '0.2.1-rc.2', desktop: true }), true)
|
|
120
|
+
assert.equal(dshIsModern({ version: '0.1.6-alpha.2', desktop: true }), false, 'an old resolved version is old even on desktop')
|
|
121
|
+
assert.equal(dshIsModern({ version: '', desktop: true }), true, 'desktop with an unresolvable version stays modern')
|
|
122
|
+
assert.equal(dshIsModern({ version: '', desktop: false }), false, 'unknown web version => legacy UI')
|
|
123
|
+
assert.equal(dshIsModern({}), false)
|
|
124
|
+
assert.equal(dshIsModern(undefined), false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// ── runtime discovery ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function writePkg(dir, name, version) {
|
|
130
|
+
mkdirSync(dir, { recursive: true })
|
|
131
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version }, null, 2))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('walkDshVersion finds the installed dsh package above a fake argv entry', () => {
|
|
135
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-walk-'))
|
|
136
|
+
try {
|
|
137
|
+
// <root>/app is the dsh package; the entry point is <root>/app/lib/cli.js.
|
|
138
|
+
writePkg(join(root, 'app'), '@deepseek-ai/dsh', '0.2.1-rc.2')
|
|
139
|
+
const entry = join(root, 'app', 'lib', 'cli.js')
|
|
140
|
+
mkdirSync(dirname(entry), { recursive: true })
|
|
141
|
+
writeFileSync(entry, '// fake entry\n')
|
|
142
|
+
assert.equal(walkDshVersion(entry), '0.2.1-rc.2')
|
|
143
|
+
|
|
144
|
+
// A directory entry works too (some loaders pass one).
|
|
145
|
+
assert.equal(walkDshVersion(join(root, 'app', 'lib')), '0.2.1-rc.2')
|
|
146
|
+
|
|
147
|
+
// A nearer, non-dsh package.json is skipped rather than accepted.
|
|
148
|
+
writePkg(join(root, 'app', 'lib'), 'some-other-package', '9.9.9')
|
|
149
|
+
assert.equal(walkDshVersion(entry), '0.2.1-rc.2')
|
|
150
|
+
|
|
151
|
+
// An unresolvable path simply yields ''.
|
|
152
|
+
assert.equal(walkDshVersion(join(root, 'nope', 'nested', 'cli.js')), '')
|
|
153
|
+
assert.equal(walkDshVersion(''), '')
|
|
154
|
+
assert.equal(walkDshVersion(undefined), '')
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(root, { recursive: true, force: true })
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('walkDshVersion honours the maxDepth budget', () => {
|
|
161
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-depth-'))
|
|
162
|
+
try {
|
|
163
|
+
writePkg(root, '@deepseek-ai/dsh', '0.2.1')
|
|
164
|
+
// 10 directories between the entry file and the package root: beyond the
|
|
165
|
+
// default 8-level walk, so the version must NOT be found.
|
|
166
|
+
let dir = root
|
|
167
|
+
const parts = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
|
|
168
|
+
for (const part of parts) dir = join(dir, part)
|
|
169
|
+
mkdirSync(dir, { recursive: true })
|
|
170
|
+
const entry = join(dir, 'cli.js')
|
|
171
|
+
writeFileSync(entry, '// fake entry\n')
|
|
172
|
+
assert.equal(walkDshVersion(entry), '')
|
|
173
|
+
assert.equal(walkDshVersion(entry, { maxDepth: 12 }), '0.2.1')
|
|
174
|
+
// A package.json whose version is not semver is ignored.
|
|
175
|
+
const other = mkdtempSync(join(tmpdir(), 'dsh-meta-badver-'))
|
|
176
|
+
try {
|
|
177
|
+
writePkg(join(other, 'pkg'), '@deepseek-ai/dsh', 'not-a-version')
|
|
178
|
+
assert.equal(walkDshVersion(join(other, 'pkg', 'bin.js')), '')
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(other, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
rmSync(root, { recursive: true, force: true })
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('walkDshVersion recognises every documented host package name', () => {
|
|
188
|
+
assert.deepEqual([...DSH_PACKAGE_NAMES], [
|
|
189
|
+
'@deepseek-ai/dsh',
|
|
190
|
+
'@deepseek-ai/dsh-desktop-runtime',
|
|
191
|
+
'@deepseek-ai/dsh-desktop',
|
|
192
|
+
])
|
|
193
|
+
for (const name of DSH_PACKAGE_NAMES) {
|
|
194
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-name-'))
|
|
195
|
+
try {
|
|
196
|
+
writePkg(root, name, '0.2.1')
|
|
197
|
+
assert.equal(walkDshVersion(join(root, 'lib', 'bin.js')), '0.2.1', name)
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(root, { recursive: true, force: true })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('readPluginVersion equals this package.json version and is cached', () => {
|
|
205
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
206
|
+
assert.equal(PLUGIN_NAME, 'dsh-dbhub-live')
|
|
207
|
+
assert.equal(readPluginVersion(), pkg.version)
|
|
208
|
+
assert.equal(readPluginVersion(), pkg.version, 'the value is stable across calls')
|
|
209
|
+
// The package.json version must itself be semver, otherwise the update check
|
|
210
|
+
// would be comparing against an unknown version forever.
|
|
211
|
+
assert.ok(parseSemver(readPluginVersion()), 'package.json version must be semver')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('detectDshRuntime reports a coherent runtime (never throws)', () => {
|
|
215
|
+
const runtime = detectDshRuntime()
|
|
216
|
+
assert.equal(typeof runtime.version, 'string')
|
|
217
|
+
assert.equal(typeof runtime.desktop, 'boolean')
|
|
218
|
+
// This test process runs under plain node (no Electron), so unless the shell
|
|
219
|
+
// exports DSH_CLIENT_VERSION the runtime must be web.
|
|
220
|
+
if (!process.versions.electron && !process.env.DSH_CLIENT_VERSION) {
|
|
221
|
+
assert.equal(runtime.desktop, false)
|
|
222
|
+
assert.equal(runtime.version, '')
|
|
223
|
+
}
|
|
224
|
+
if (runtime.version) assert.ok(parseSemver(runtime.version), 'a resolved version is always semver')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('versionMeta publishes the four view fields', () => {
|
|
228
|
+
const meta = versionMeta()
|
|
229
|
+
assert.deepEqual(Object.keys(meta).sort(), ['dshModern', 'dshVersion', 'pluginVersion', 'runtime'])
|
|
230
|
+
assert.equal(meta.pluginVersion, readPluginVersion())
|
|
231
|
+
assert.ok(meta.runtime === 'web' || meta.runtime === 'desktop')
|
|
232
|
+
assert.equal(meta.dshModern, dshIsModern({ version: meta.dshVersion, desktop: meta.runtime === 'desktop' }))
|
|
233
|
+
assert.equal(typeof meta.dshModern, 'boolean')
|
|
234
|
+
})
|
package/test/options.test.mjs
CHANGED
|
@@ -26,12 +26,15 @@ test('options seed from process environment; package stays internal-only', () =>
|
|
|
26
26
|
assert.equal(options.get('updateIntervalDays'), 3)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
test('snapshot exposes
|
|
29
|
+
test('snapshot exposes every UI field (sidebar switch, policy announcement)', () => {
|
|
30
30
|
const snap = options.snapshot()
|
|
31
|
-
assert.deepEqual(Object.keys(snap).sort(), ['showSidebarEntry', 'updateIntervalDays'])
|
|
31
|
+
assert.deepEqual(Object.keys(snap).sort(), ['announceAccessPolicy', 'showSidebarEntry', 'updateIntervalDays'])
|
|
32
32
|
// The switch hides the SHORTCUT; it does not disable the plugin. The client
|
|
33
33
|
// ANDs it with the enabled state.
|
|
34
34
|
assert.equal(snap.showSidebarEntry, true)
|
|
35
|
+
// The policy announcement defaults ON: an installed plugin should be the
|
|
36
|
+
// route database work takes. Turning it off removes one prompt section only.
|
|
37
|
+
assert.equal(snap.announceAccessPolicy, true)
|
|
35
38
|
snap.updateIntervalDays = 99
|
|
36
39
|
assert.equal(options.get('updateIntervalDays'), 3)
|
|
37
40
|
})
|
|
@@ -48,6 +51,11 @@ test('applyPatch accepts valid values, emits, and rejects invalid', () => {
|
|
|
48
51
|
assert.equal(options.get('showSidebarEntry'), false)
|
|
49
52
|
assert.equal(options.applyPatch({ showSidebarEntry: 'yes' }), false)
|
|
50
53
|
assert.equal(options.get('showSidebarEntry'), false)
|
|
54
|
+
// ... and so is the policy announcement (boolean-only, no truthy coercion)
|
|
55
|
+
assert.equal(options.applyPatch({ announceAccessPolicy: false }), true)
|
|
56
|
+
assert.equal(options.get('announceAccessPolicy'), false)
|
|
57
|
+
assert.equal(options.applyPatch({ announceAccessPolicy: 'no' }), false)
|
|
58
|
+
assert.equal(options.get('announceAccessPolicy'), false)
|
|
51
59
|
off()
|
|
52
60
|
const before = options.snapshot()
|
|
53
61
|
assert.equal(options.applyPatch({ updateIntervalDays: -1, enabled: false }), false)
|
|
@@ -60,17 +68,20 @@ test('applyPatch accepts valid values, emits, and rejects invalid', () => {
|
|
|
60
68
|
|
|
61
69
|
test('a user write claims the field and persists it for the next boot', async () => {
|
|
62
70
|
assert.equal(options.applyPatch({ updateIntervalDays: 5 }, { persist: true }), true)
|
|
63
|
-
// prefs.json is the whole UI layer, so it carries
|
|
64
|
-
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
71
|
+
// prefs.json is the whole UI layer, so it carries every UI field.
|
|
72
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
73
|
+
{ updateIntervalDays: 5, showSidebarEntry: false, announceAccessPolicy: false })
|
|
65
74
|
// A boot-time layer must never claim a field, so persisting stays opt-in.
|
|
66
75
|
assert.equal(options.applyPatch({ updateIntervalDays: 6 }), true)
|
|
67
|
-
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
76
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
77
|
+
{ updateIntervalDays: 5, showSidebarEntry: false, announceAccessPolicy: false })
|
|
68
78
|
assert.equal(options.applyPatch({ updateIntervalDays: 5 }), true)
|
|
69
79
|
|
|
70
80
|
// A fresh module instance models a restart: prefs outranks the env seed.
|
|
71
81
|
const restarted = await import('../lib/options.mjs?options3=' + Date.now())
|
|
72
82
|
assert.equal(restarted.get('updateIntervalDays'), 5)
|
|
73
83
|
assert.equal(restarted.get('showSidebarEntry'), false)
|
|
84
|
+
assert.equal(restarted.get('announceAccessPolicy'), false)
|
|
74
85
|
assert.equal(restarted.get('dbhubPackage'), 'test/fork-package')
|
|
75
86
|
})
|
|
76
87
|
|
|
@@ -110,28 +121,29 @@ test('the legacy settings document only fills fields prefs does not own', async
|
|
|
110
121
|
})
|
|
111
122
|
|
|
112
123
|
test('a corrupt prefs file cannot seed an invalid option', async () => {
|
|
113
|
-
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: -4, showSidebarEntry: 'yes', junk: 1 }))
|
|
124
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: -4, showSidebarEntry: 'yes', announceAccessPolicy: 'nope', junk: 1 }))
|
|
114
125
|
const fresh = await import('../lib/options.mjs?options5=' + Date.now())
|
|
115
126
|
assert.equal(fresh.get('updateIntervalDays'), 3) // falls back to the env seed
|
|
116
127
|
assert.equal(fresh.get('showSidebarEntry'), true) // built-in default
|
|
117
|
-
assert.
|
|
128
|
+
assert.equal(fresh.get('announceAccessPolicy'), true) // built-in default (ON)
|
|
129
|
+
assert.deepEqual(fresh.snapshot(), { updateIntervalDays: 3, showSidebarEntry: true, announceAccessPolicy: true })
|
|
118
130
|
})
|
|
119
131
|
|
|
120
132
|
test('configValuesOf reads the declared Config in both runtime shapes', async () => {
|
|
121
133
|
const fresh = await import('../lib/options.mjs?options6=' + Date.now())
|
|
122
134
|
// dsh 0.1.7+/0.2.x: `.volatile()` fields arrive as live references.
|
|
123
135
|
assert.deepEqual(
|
|
124
|
-
fresh.configValuesOf({ updateIntervalDays: { get: () => 4 }, showSidebarEntry: { get: () => false } }),
|
|
125
|
-
{ updateIntervalDays: 4, showSidebarEntry: false },
|
|
136
|
+
fresh.configValuesOf({ updateIntervalDays: { get: () => 4 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } }),
|
|
137
|
+
{ updateIntervalDays: 4, showSidebarEntry: false, announceAccessPolicy: false },
|
|
126
138
|
)
|
|
127
139
|
// dsh 0.1.6 (schemastery 3.18.2 has no `.volatile()`): plain values.
|
|
128
140
|
assert.deepEqual(
|
|
129
|
-
fresh.configValuesOf({ updateIntervalDays: 2, showSidebarEntry: true }),
|
|
130
|
-
{ updateIntervalDays: 2, showSidebarEntry: true },
|
|
141
|
+
fresh.configValuesOf({ updateIntervalDays: 2, showSidebarEntry: true, announceAccessPolicy: true }),
|
|
142
|
+
{ updateIntervalDays: 2, showSidebarEntry: true, announceAccessPolicy: true },
|
|
131
143
|
)
|
|
132
144
|
// Unset, invalid and junk input is skipped — the field keeps its other layers.
|
|
133
145
|
assert.deepEqual(fresh.configValuesOf({ updateIntervalDays: { get: () => undefined }, showSidebarEntry: { get: () => 'yes' } }), {})
|
|
134
|
-
assert.deepEqual(fresh.configValuesOf({ updateIntervalDays: -1, showSidebarEntry: null }), {})
|
|
146
|
+
assert.deepEqual(fresh.configValuesOf({ updateIntervalDays: -1, showSidebarEntry: null, announceAccessPolicy: 'yes' }), {})
|
|
135
147
|
assert.deepEqual(fresh.configValuesOf({ unknownKey: 5 }), {})
|
|
136
148
|
assert.deepEqual(fresh.configValuesOf(undefined), {})
|
|
137
149
|
assert.deepEqual(fresh.configValuesOf('nope'), {})
|
|
@@ -139,9 +151,10 @@ test('configValuesOf reads the declared Config in both runtime shapes', async ()
|
|
|
139
151
|
|
|
140
152
|
test('an adopted Config value claims the field and outlives the config edit', async () => {
|
|
141
153
|
const fresh = await import('../lib/options.mjs?options7=' + Date.now())
|
|
142
|
-
const patch = fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false } })
|
|
154
|
+
const patch = fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } })
|
|
143
155
|
assert.equal(fresh.applyPatch(patch, { persist: true }), true)
|
|
144
|
-
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
156
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
157
|
+
{ updateIntervalDays: 11, showSidebarEntry: false, announceAccessPolicy: false })
|
|
145
158
|
// Adopting the same value again is a no-op (no write, no republish loop).
|
|
146
|
-
assert.equal(fresh.applyPatch(fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false } }), { persist: true }), false)
|
|
159
|
+
assert.equal(fresh.applyPatch(fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } }), { persist: true }), false)
|
|
147
160
|
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Model-facing database-access policy: the system-prompt section that keeps
|
|
2
|
+
// database work on the dbhub_* tools and out of shell-level clients.
|
|
3
|
+
//
|
|
4
|
+
// Gates asserted here:
|
|
5
|
+
// 1. the text states every clause the incident requires — the authorized
|
|
6
|
+
// route, the CLI/script ban, "an AGENTS.md recipe is not authorization",
|
|
7
|
+
// the read-only boundary, and the ask-the-user escape hatch;
|
|
8
|
+
// 2. COEXISTENCE: the authority claim is scoped to this plugin's own
|
|
9
|
+
// connections, a database this plugin does not cover is explicitly handed
|
|
10
|
+
// to the user's other plugin, and a configured read-only target may not be
|
|
11
|
+
// reached through another plugin either;
|
|
12
|
+
// 3. a DISABLED plugin, or a user who turned the announcement off,
|
|
13
|
+
// contributes nothing (an empty section is dropped), so the prompt can
|
|
14
|
+
// never claim dbhub is the only route while every call answers
|
|
15
|
+
// "插件已禁用";
|
|
16
|
+
// 4. registration targets the optional `systemPrompt` service, is owned by an
|
|
17
|
+
// effect (a reload re-runs apply, and a duplicate section name throws in
|
|
18
|
+
// one scope), and is a silent no-op where that service is absent
|
|
19
|
+
// (older dsh deployments);
|
|
20
|
+
// 5. the text stays inside its token budget and stays zero-knowledge
|
|
21
|
+
// (no DSN-looking string, no `{{variable}}` that assembly would reject).
|
|
22
|
+
|
|
23
|
+
import test from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-policy-'))
|
|
30
|
+
process.env.DSH_HOME = home
|
|
31
|
+
|
|
32
|
+
// Plain specifiers only: these are the exact module instances the plugin
|
|
33
|
+
// resolves, so `setEnabled`/`applyPatch` here reach the state the policy reads.
|
|
34
|
+
const state = await import('../lib/state.mjs')
|
|
35
|
+
const options = await import('../lib/options.mjs')
|
|
36
|
+
const policy = await import('../lib/policy.mjs')
|
|
37
|
+
|
|
38
|
+
test.after(() => {
|
|
39
|
+
rmSync(home, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('the policy text states every clause the incident requires', () => {
|
|
43
|
+
state.setEnabled(true)
|
|
44
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
45
|
+
const text = policy.dbhubPolicyText()
|
|
46
|
+
assert.match(text, /dbhub_\* 工具/, 'the authorized route')
|
|
47
|
+
assert.match(text, /唯一授权途径/, 'the authority claim that outranks a workspace recipe')
|
|
48
|
+
assert.match(text, /psql/, 'the shell-level bypass, named')
|
|
49
|
+
assert.match(text, /AGENTS\.md/, 'workspace recipes are explicitly not authorization')
|
|
50
|
+
assert.match(text, /READONLY_VIOLATION/, 'the read-only boundary')
|
|
51
|
+
assert.match(text, /请用户授权/, 'the escape hatch: ask the user, do not improvise')
|
|
52
|
+
assert.match(text, /不要自行绕过/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('the authority claim is scoped, and other plugins keep the databases this one does not cover', () => {
|
|
56
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
57
|
+
const text = policy.dbhubPolicyText()
|
|
58
|
+
// Scoped to THIS plugin's connections — not "every database on this machine".
|
|
59
|
+
assert.match(text, /本插件已配置的连接/)
|
|
60
|
+
assert.doesNotMatch(text, /本机唯一授权途径/)
|
|
61
|
+
// A target this plugin cannot serve goes to the user's other plugin...
|
|
62
|
+
assert.match(text, /不支持或未配置的库/)
|
|
63
|
+
assert.match(text, /其它数据库插件是允许的/)
|
|
64
|
+
// ...while a configured read-only target stays closed on every route.
|
|
65
|
+
assert.match(text, /包括换用别的数据库插件/)
|
|
66
|
+
// Overlapping targets have a deterministic tie-break plus a fallback question.
|
|
67
|
+
assert.match(text, /默认用本插件的 source/)
|
|
68
|
+
assert.match(text, /先问用户/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a disabled plugin, or the announcement switched off, contributes an EMPTY section', () => {
|
|
72
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
73
|
+
state.setEnabled(false)
|
|
74
|
+
assert.equal(policy.dbhubPolicyText(), '')
|
|
75
|
+
state.setEnabled(true)
|
|
76
|
+
assert.notEqual(policy.dbhubPolicyText(), '')
|
|
77
|
+
|
|
78
|
+
options.applyPatch({ announceAccessPolicy: false })
|
|
79
|
+
assert.equal(policy.dbhubPolicyText(), '', 'the user turned the announcement off')
|
|
80
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
81
|
+
assert.notEqual(policy.dbhubPolicyText(), '')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('the policy text stays inside its budget and its zero-knowledge frame', () => {
|
|
85
|
+
state.setEnabled(true)
|
|
86
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
87
|
+
const text = policy.dbhubPolicyText()
|
|
88
|
+
assert.ok(text.length <= 420, 'token budget: ' + String(text.length) + ' chars')
|
|
89
|
+
// A real-looking DSN would leak a credential into the system prompt.
|
|
90
|
+
assert.doesNotMatch(text, /[a-z]+:\/\/[^/\s]*:[^@\s]+@/)
|
|
91
|
+
// `{{name}}` is interpolated strictly by prompt assembly: an unregistered
|
|
92
|
+
// name would make every assembly throw, i.e. the model would get no prompt.
|
|
93
|
+
assert.doesNotMatch(text, /\{\{[a-z_]+\}\}/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('registration injects the optional systemPrompt service and owns the section', () => {
|
|
97
|
+
const injected = []
|
|
98
|
+
const sections = []
|
|
99
|
+
const effects = []
|
|
100
|
+
const ctx = {
|
|
101
|
+
inject(deps, cb) {
|
|
102
|
+
injected.push(deps)
|
|
103
|
+
cb({
|
|
104
|
+
systemPrompt: {
|
|
105
|
+
section(def) {
|
|
106
|
+
sections.push(def)
|
|
107
|
+
return () => sections.splice(sections.indexOf(def), 1)
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
effect(fn, label) {
|
|
111
|
+
effects.push(label)
|
|
112
|
+
return fn()
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
policy.registerDbhubPolicy(ctx)
|
|
118
|
+
assert.deepEqual(injected, [['systemPrompt']])
|
|
119
|
+
assert.equal(sections.length, 1)
|
|
120
|
+
assert.equal(sections[0].name, policy.POLICY_SECTION)
|
|
121
|
+
assert.equal(sections[0].order, policy.POLICY_ORDER)
|
|
122
|
+
assert.equal(typeof sections[0].text, 'function', 'the text is a provider, not a snapshot')
|
|
123
|
+
assert.equal(sections[0].text(), policy.dbhubPolicyText())
|
|
124
|
+
// The section is effect-owned, so a re-run of apply() disposes it instead of
|
|
125
|
+
// throwing on the duplicate name.
|
|
126
|
+
assert.deepEqual(effects, ['dsh-dbhub-live: access policy'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a deployment without systemPrompt registers nothing and does not throw', () => {
|
|
130
|
+
const never = { inject() { /* the callback never fires without the service */ } }
|
|
131
|
+
assert.doesNotThrow(() => policy.registerDbhubPolicy(never))
|
|
132
|
+
})
|