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.
@@ -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
+ })
@@ -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 both UI fields (the sidebar switch is a user preference)', () => {
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 both fields.
64
- assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')), { updateIntervalDays: 5, showSidebarEntry: false })
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')), { updateIntervalDays: 5, showSidebarEntry: false })
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.deepEqual(fresh.snapshot(), { updateIntervalDays: 3, showSidebarEntry: true })
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')), { updateIntervalDays: 11, showSidebarEntry: false })
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
+ })