dsh-prompt-customizer 0.9.2 → 2.1.2
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/README.en.md +49 -234
- package/README.md +45 -310
- package/build.mjs +27 -10
- package/client/client.js +2446 -1364
- package/client/client.js.map +4 -4
- package/docs/adr/0001-remove-catalog-pool.md +27 -0
- package/docs/adr/0002-hide-identity-name-source.md +23 -0
- package/docs/adr/0003-destructive-updates-allowed.md +18 -0
- package/docs/adr/0004-generalized-filter-model.md +17 -0
- package/docs/adr/0005-universal-prompt-takeover.md +109 -0
- package/docs/design/prompt-control-consensus.md +103 -0
- package/docs/guide.md +280 -0
- package/docs/images/prompt.png +0 -0
- package/docs/images/tool.png +0 -0
- package/docs/testing/feature-matrix.md +142 -0
- package/docs/upgrades/1.0.0-sqlite-breaking.md +26 -0
- package/docs/upgrades/1.4.0-generalized-model.md +23 -0
- package/docs/upgrades/1.4.1-web-preview-fixes.md +15 -0
- package/docs/upgrades/2.0.0-remove-workspace-level.md +16 -0
- package/docs/upgrades/2.0.1-context-fixes.md +6 -0
- package/docs/upgrades/2.0.10-import-and-skill.md +6 -0
- package/docs/upgrades/2.0.11-regression-fixes.md +7 -0
- package/docs/upgrades/2.0.12-generative-tool-tests.md +5 -0
- package/docs/upgrades/2.0.13-runtime-contract.md +8 -0
- package/docs/upgrades/2.0.14-tool-execution-guard.md +5 -0
- package/docs/upgrades/2.0.15-mask-pool-regression.md +5 -0
- package/docs/upgrades/2.0.16-regression-cleanup.md +6 -0
- package/docs/upgrades/2.0.17-unhide-source.md +5 -0
- package/docs/upgrades/2.0.18-unhide-cleanup.md +4 -0
- package/docs/upgrades/2.0.19-mcp-json-edit.md +4 -0
- package/docs/upgrades/2.0.2-generic-context-filter.md +6 -0
- package/docs/upgrades/2.0.20-preset-isolation.md +4 -0
- package/docs/upgrades/2.0.21-isolation-restore-test.md +4 -0
- package/docs/upgrades/2.0.22-source-immutability.md +3 -0
- package/docs/upgrades/2.0.3-skill-summary.md +6 -0
- package/docs/upgrades/2.0.4-tool-draft.md +5 -0
- package/docs/upgrades/2.0.5-tool-catalog-robustness.md +13 -0
- package/docs/upgrades/2.0.6-tool-restrict.md +6 -0
- package/docs/upgrades/2.0.7-tool-stability.md +5 -0
- package/docs/upgrades/2.0.8-catalog-row-stability.md +4 -0
- package/docs/upgrades/2.0.9-tool-and-write.md +8 -0
- package/docs/upgrades/2.1.0-universal-prompt-takeover.md +41 -0
- package/docs/upgrades/2.1.1-tool-shadow-detection.md +48 -0
- package/docs/upgrades/2.1.2-tool-catalog-scope.md +55 -0
- package/docs/upgrades/README.md +298 -0
- package/docs/upgrades/dsh-0.2.2-rc1.md +49 -0
- package/docs/upgrades/rc2-migration.md +70 -0
- package/lib/index.js +4697 -1701
- package/lib/index.js.map +4 -4
- package/package.json +7 -5
- package/scripts/e2e-report.json +221 -0
- package/scripts/e2e-terminal.mjs +210 -0
- package/scripts/simulate-rescan.mjs +184 -0
- package/scripts/smoke-host.mjs +1 -1
- package/scripts/verify-apply-all.mjs +64 -0
- package/scripts/verify-effect.mjs +153 -0
- package/scripts/verify-mcp-edit-ext.mjs +48 -0
- package/scripts/verify-mcp-edit.mjs +45 -0
- package/scripts/verify-panel-browser.mjs +298 -0
- package/scripts/verify-workspace-mcp.mjs +62 -0
- package/scripts/test-mcp-layering.mjs +0 -441
- package/scripts/test-mcp-tool-disable.mjs +0 -420
- package/scripts/test-rc2-runtime.mjs +0 -42
- package/scripts/test-skill-toggles.mjs +0 -100
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** 工作区级 MCP:把给定 codegraph JSON 加入工作区 .mcp.json → 校验读取/JSON视图转换/工作区级编辑(改后还原)。 */
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
const argv = process.argv.slice(2)
|
|
6
|
+
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d }
|
|
7
|
+
const TOKEN_URL = arg('--url'); if (!TOKEN_URL) { console.error('缺 --url'); process.exit(2) }
|
|
8
|
+
const ORIGIN = new URL(TOKEN_URL).origin
|
|
9
|
+
const results = []
|
|
10
|
+
const rec = (n, ok, d) => { results.push({ n, ok }); console.log(`${ok ? 'PASS' : 'FAIL'} ${n}${d ? ` — ${d}` : ''}`) }
|
|
11
|
+
let cookie = ''
|
|
12
|
+
async function handshake() { const r = await fetch(TOKEN_URL, { redirect: 'manual' }); const sc = r.headers.getSetCookie ? r.headers.getSetCookie() : []; cookie = sc.map((c) => c.split(';')[0]).join('; ') }
|
|
13
|
+
async function req(method, p, body) { const h = { accept: 'application/json' }; if (cookie) h.cookie = cookie; if (body !== undefined) h['content-type'] = 'application/json'; const r = await fetch(ORIGIN + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }); let j = null; try { j = await r.json() } catch {} return { status: r.status, json: j } }
|
|
14
|
+
const codegraph = { mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
await handshake()
|
|
18
|
+
const cfg = (await req('GET', '/api/prompt-customizer/config')).json ?? {}
|
|
19
|
+
const wsList = (await req('GET', '/api/skill-toggles/workspaces')).json?.workspaces ?? []
|
|
20
|
+
const workspace = String(arg('--workspace', cfg.currentWorkspace ?? wsList[0] ?? ''))
|
|
21
|
+
console.log('workspace =', workspace)
|
|
22
|
+
rec('拿到工作区路径', workspace !== '')
|
|
23
|
+
if (workspace === '') return
|
|
24
|
+
const wsFile = path.join(workspace, '.mcp.json')
|
|
25
|
+
|
|
26
|
+
// 加入工作区
|
|
27
|
+
const add = await req('POST', '/api/triad/mcp-config', { action: 'add', scope: 'workspace', workspace, format: 'json', text: JSON.stringify(codegraph) })
|
|
28
|
+
rec('把 codegraph JSON 加入工作区 .mcp.json', add.status === 200 && add.json?.ok === true, JSON.stringify(add.json))
|
|
29
|
+
rec('工作区 .mcp.json 已含 codegraph', fs.existsSync(wsFile) && /codegraph/.test(fs.readFileSync(wsFile, 'utf8')))
|
|
30
|
+
|
|
31
|
+
// 读取(工作区级)
|
|
32
|
+
const g = await req('GET', `/api/triad/mcp-edit?source=workspace&workspace=${encodeURIComponent(workspace)}&serverName=codegraph`)
|
|
33
|
+
let cfgObj = null
|
|
34
|
+
try { cfgObj = JSON.parse(String(g.json?.text ?? '')) } catch {}
|
|
35
|
+
rec('工作区 GET 读到 codegraph 配置', g.status === 200 && g.json?.ok === true && cfgObj !== null)
|
|
36
|
+
// 注:`.mcp.json` 按项目既定契约不写推断出的 transport/type(命令型 server 主流 agent 自动按 stdio),只保留 command/args。
|
|
37
|
+
rec('读到的配置 command=codegraph/args含 --mcp', cfgObj?.command === 'codegraph' && Array.isArray(cfgObj?.args) && cfgObj.args.includes('--mcp'), JSON.stringify(cfgObj))
|
|
38
|
+
|
|
39
|
+
// JSON ↔ YAML 视图转换(编辑弹窗切格式用)
|
|
40
|
+
const toYaml = await req('POST', '/api/triad/mcp-format', { format: 'json', text: JSON.stringify(codegraph), to: 'yaml' })
|
|
41
|
+
rec('JSON→YAML 转换成功且为 mcpServers 形态', toYaml.status === 200 && toYaml.json?.ok === true && /mcpServers:/.test(String(toYaml.json.text)) && /command: codegraph/.test(String(toYaml.json.text)))
|
|
42
|
+
const toJson = await req('POST', '/api/triad/mcp-format', { format: 'yaml', text: String(toYaml.json?.text ?? ''), to: 'json' })
|
|
43
|
+
let back = null
|
|
44
|
+
try { back = JSON.parse(String(toJson.json?.text ?? '')) } catch {}
|
|
45
|
+
rec('YAML→JSON 转换回 mcpServers.codegraph 正确', toJson.status === 200 && toJson.json?.ok === true && back?.mcpServers?.codegraph?.command === 'codegraph')
|
|
46
|
+
|
|
47
|
+
// 工作区级编辑(改 args,加 --e2e)
|
|
48
|
+
const edited = { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp', '--e2e'] }
|
|
49
|
+
const p1 = await req('POST', '/api/triad/mcp-edit', { source: 'workspace', workspace, serverName: 'codegraph', text: JSON.stringify(edited) })
|
|
50
|
+
rec('工作区级编辑保存成功', p1.status === 200 && p1.json?.ok === true, JSON.stringify(p1.json))
|
|
51
|
+
const fileAfter = fs.existsSync(wsFile) ? JSON.parse(fs.readFileSync(wsFile, 'utf8')) : {}
|
|
52
|
+
rec('工作区文件 args 已改为含 --e2e', JSON.stringify(fileAfter?.mcpServers?.codegraph?.args ?? []) === '["serve","--mcp","--e2e"]', JSON.stringify(fileAfter?.mcpServers?.codegraph?.args))
|
|
53
|
+
|
|
54
|
+
// 还原为给定 JSON
|
|
55
|
+
const restore = await req('POST', '/api/triad/mcp-edit', { source: 'workspace', workspace, serverName: 'codegraph', text: JSON.stringify({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }) })
|
|
56
|
+
rec('已还原为给定 args=[serve,--mcp]', restore.status === 200 && restore.json?.ok === true)
|
|
57
|
+
}
|
|
58
|
+
try { await main() } catch (e) { rec('主流程异常', false, e instanceof Error ? e.stack ?? e.message : String(e)) } finally {
|
|
59
|
+
const p = results.filter((r) => r.ok).length
|
|
60
|
+
console.log(`\n==== 工作区 MCP 加入+编辑实测: ${p}/${results.length} 通过 ====`)
|
|
61
|
+
process.exit(results.length - p === 0 ? 0 : 1)
|
|
62
|
+
}
|
|
@@ -1,441 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-prompt-customizer — MCP 全局/预设分层回归测试。
|
|
3
|
-
*
|
|
4
|
-
* 直接跑构建产物 lib/index.js:DSH_HOME / DSH_AGENTS_HOME 指到临时目录,
|
|
5
|
-
* 不碰用户真实预设、技能与账本。覆盖:
|
|
6
|
-
* - 预设遮蔽账本(preset-masks.json)写入与镜像;
|
|
7
|
-
* - agent 作用域三层补丁(tools.restrict / 同名空 section / 资源 stub);
|
|
8
|
-
* - deny 计算排除「预设自带 serverName」(自带覆盖优先于遮蔽);
|
|
9
|
-
* - 预设专属 mcp-client 行:从预设声明行的 config.plugins 读取、经
|
|
10
|
-
* configEditor.edit 写回(0.1.7 声明式预设模型);
|
|
11
|
-
* - 行不可寻址(id 不唯一)时拒绝写入(409);
|
|
12
|
-
* - 前缀匹配的全局 profile patch 路径(`- insert:` 块)不变;
|
|
13
|
-
* - patch 目标随宿主 profileContext 走(桌面 profile 不再误写 web),
|
|
14
|
-
* 无宿主面时按 DSH_DESKTOP_PROFILE 环境变量回落(0.8.1)。
|
|
15
|
-
*
|
|
16
|
-
* Usage: node scripts/test-mcp-layering.mjs
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"
|
|
20
|
-
import { tmpdir } from "node:os"
|
|
21
|
-
import { join } from "node:path"
|
|
22
|
-
import { pathToFileURL } from "node:url"
|
|
23
|
-
import { EventEmitter } from "node:events"
|
|
24
|
-
|
|
25
|
-
const sandbox = mkdtempSync(join(tmpdir(), "dsh-prompt-customizer-mcp-"))
|
|
26
|
-
process.env.DSH_AGENTS_HOME = join(sandbox, "agents")
|
|
27
|
-
process.env.DSH_HOME = join(sandbox, "dsh")
|
|
28
|
-
|
|
29
|
-
/* 全局 profile patch(全局 MCP 的写入目标)。 */
|
|
30
|
-
const patchFile = join(process.env.DSH_HOME, "profiles", "web", "cordis.patch.yml")
|
|
31
|
-
mkdirSync(join(process.env.DSH_HOME, "profiles", "web"), { recursive: true })
|
|
32
|
-
writeFileSync(patchFile, ["# my patch layer", "- id: some-plugin", " disabled: true", ""].join("\n"))
|
|
33
|
-
|
|
34
|
-
const maskFile = join(process.env.DSH_HOME, "mcp", "dsh-prompt-customizer", "preset-masks.json")
|
|
35
|
-
|
|
36
|
-
/* ── 断言小工具 ──────────────────────────────────────────────────────── */
|
|
37
|
-
let failed = 0
|
|
38
|
-
function check(label, condition) {
|
|
39
|
-
if (condition) {
|
|
40
|
-
console.log(`ok ${label}`)
|
|
41
|
-
} else {
|
|
42
|
-
failed += 1
|
|
43
|
-
console.error(`FAIL ${label}`)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** 安装器是异步的(预设声明读取):等一轮微任务 + 定时器再断言。 */
|
|
48
|
-
const settle = () => new Promise(resolve => setTimeout(resolve, 20))
|
|
49
|
-
|
|
50
|
-
/* ── 假的宿主面 ──────────────────────────────────────────────────────── */
|
|
51
|
-
const routes = new Map()
|
|
52
|
-
const restrictCalls = []
|
|
53
|
-
const sections = []
|
|
54
|
-
const resourceStubs = []
|
|
55
|
-
|
|
56
|
-
const agentServices = {
|
|
57
|
-
tools: {
|
|
58
|
-
schemas: () => [
|
|
59
|
-
{ name: "mcp__github__search" },
|
|
60
|
-
{ name: "mcp__github__create_issue" },
|
|
61
|
-
{ name: "mcp__web__fetch" },
|
|
62
|
-
{ name: "read" },
|
|
63
|
-
],
|
|
64
|
-
restrict: (filter) => {
|
|
65
|
-
restrictCalls.push(filter)
|
|
66
|
-
return () => {}
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
systemPrompt: {
|
|
70
|
-
getSectionOrder: () => 105,
|
|
71
|
-
section: (section) => {
|
|
72
|
-
sections.push(section)
|
|
73
|
-
return () => {}
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
mcpResources: {
|
|
77
|
-
register: (serverName, provider) => {
|
|
78
|
-
resourceStubs.push({ serverName, provider })
|
|
79
|
-
return () => {}
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
}
|
|
83
|
-
const agent = {
|
|
84
|
-
id: "agent-1",
|
|
85
|
-
presetId: "code",
|
|
86
|
-
ctx: {
|
|
87
|
-
presetId: "code",
|
|
88
|
-
get: (name) => agentServices[name],
|
|
89
|
-
inject: () => () => {},
|
|
90
|
-
},
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 预设声明行(0.1.7 模型):行的 options.config 就是该预设的声明,plugins 里
|
|
95
|
-
* 放子行。写入直接改这份数据 —— 与 configEditor.edit() 给的 current 同源。
|
|
96
|
-
*/
|
|
97
|
-
const presetRows = new Map()
|
|
98
|
-
presetRows.set("code", {
|
|
99
|
-
options: {
|
|
100
|
-
id: "preset-code",
|
|
101
|
-
name: "@deepseek-ai/dsh-agent-preset",
|
|
102
|
-
config: {
|
|
103
|
-
id: "code",
|
|
104
|
-
order: 2,
|
|
105
|
-
plugins: [
|
|
106
|
-
{ id: "persona", name: "@deepseek-ai/dsh-persona", config: { prefix: "code agent" } },
|
|
107
|
-
],
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
})
|
|
111
|
-
presetRows.set("minimal", {
|
|
112
|
-
options: {
|
|
113
|
-
id: "preset-minimal",
|
|
114
|
-
name: "@deepseek-ai/dsh-agent-preset",
|
|
115
|
-
config: {
|
|
116
|
-
id: "minimal",
|
|
117
|
-
order: 1,
|
|
118
|
-
plugins: [
|
|
119
|
-
{ id: "tool-fs", name: "@deepseek-ai/dsh-tool-fs" },
|
|
120
|
-
{ id: "dup", name: "@deepseek-ai/dsh-tool-fs" },
|
|
121
|
-
],
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
})
|
|
125
|
-
/* 行 id 不唯一 → configEditor 不把它列进可寻址行(只读)。 */
|
|
126
|
-
presetRows.set("ambiguous", {
|
|
127
|
-
options: {
|
|
128
|
-
id: "preset-ambiguous",
|
|
129
|
-
name: "@deepseek-ai/dsh-agent-preset",
|
|
130
|
-
config: { id: "ambiguous", plugins: [] },
|
|
131
|
-
},
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
/** 已写入 profile patch 的预设覆盖(presetId → config)。 */
|
|
135
|
-
const overrides = new Map()
|
|
136
|
-
let editCalls = 0
|
|
137
|
-
|
|
138
|
-
const configEditor = {
|
|
139
|
-
entries: () => [...presetRows.values()].filter((entry) => entry.options.id !== "preset-ambiguous"),
|
|
140
|
-
configuration: () => [...presetRows.values()].map((entry) => ({
|
|
141
|
-
entry,
|
|
142
|
-
inherited: {},
|
|
143
|
-
override: overrides.get(entry.options.config.id) ?? {},
|
|
144
|
-
})),
|
|
145
|
-
edit: async (entry, change) => {
|
|
146
|
-
editCalls += 1
|
|
147
|
-
const current = structuredClone(entry.options.config)
|
|
148
|
-
const next = change(current, {})
|
|
149
|
-
entry.options.config = next
|
|
150
|
-
overrides.set(next.id, next)
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const ctx = {
|
|
155
|
-
logger: { info: () => {}, warn: () => {}, debug: () => {} },
|
|
156
|
-
webServer: { register: (route) => { routes.set(route.path, route.handler); return () => {} } },
|
|
157
|
-
tools: { register: () => () => {}, schemas: () => [] },
|
|
158
|
-
on: () => () => {},
|
|
159
|
-
effect: (fn) => { fn?.(); return () => {} },
|
|
160
|
-
settings: { get: () => ({ providers: {} }), register: () => () => {} },
|
|
161
|
-
credentials: {}, sessions: {}, sessionPersistence: {}, llm: {},
|
|
162
|
-
// loader 视图含全部行(含 configEditor 不列出的不可寻址行)—— 与真实组合一致:
|
|
163
|
-
// 这种行读得到、写不了,路由据此回 409 而不是 404。
|
|
164
|
-
loader: { entries: () => [...presetRows.values()] },
|
|
165
|
-
get: (name) => {
|
|
166
|
-
if (name === "agentPresets") {
|
|
167
|
-
return {
|
|
168
|
-
list: async () => [
|
|
169
|
-
{ id: "code", name: "Code" },
|
|
170
|
-
{ id: "minimal", name: "Minimal" },
|
|
171
|
-
{ id: "ambiguous" },
|
|
172
|
-
],
|
|
173
|
-
composedPreset: (agentCtx) => agentCtx?.presetId,
|
|
174
|
-
readDocument: async (presetId) => {
|
|
175
|
-
if (presetId === "ghost") throw new Error(`Unknown agent preset: ${presetId}`)
|
|
176
|
-
const plugins = presetRows.get(presetId)?.options.config.plugins ?? []
|
|
177
|
-
const content = plugins.map((row) => `- id: ${row.id}\n name: ${row.name}\n`).join("")
|
|
178
|
-
return { agentPreset: presetId, content }
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (name === "agents") return { list: () => [agent] }
|
|
183
|
-
if (name === "configEditor") return configEditor
|
|
184
|
-
return undefined
|
|
185
|
-
},
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const mod = await import(pathToFileURL(join(process.cwd(), "lib/index.js")).href)
|
|
189
|
-
await mod.apply(ctx, {})
|
|
190
|
-
await settle()
|
|
191
|
-
|
|
192
|
-
/* ── HTTP 小工具 ─────────────────────────────────────────────────────── */
|
|
193
|
-
function fakeReq(method, url, body) {
|
|
194
|
-
const req = new EventEmitter()
|
|
195
|
-
req.method = method
|
|
196
|
-
req.url = url
|
|
197
|
-
req.socket = { remoteAddress: "127.0.0.1" }
|
|
198
|
-
req.headers = { host: "localhost" }
|
|
199
|
-
req.destroy = () => {}
|
|
200
|
-
// 用宏任务而不是微任务:让处理器的监听器先挂上(真实 socket 有缓冲,
|
|
201
|
-
// 但假请求没有,微任务会在处理器 await 之后、readBody 之前抢先发数据)。
|
|
202
|
-
const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
|
203
|
-
if (payload !== undefined) {
|
|
204
|
-
setTimeout(() => {
|
|
205
|
-
req.emit("data", payload)
|
|
206
|
-
req.emit("end")
|
|
207
|
-
}, 0)
|
|
208
|
-
} else {
|
|
209
|
-
setTimeout(() => req.emit("end"), 0)
|
|
210
|
-
}
|
|
211
|
-
// 有的处理器用 `for await (const chunk of req)` 读体(mcp-config 路由)。
|
|
212
|
-
req[Symbol.asyncIterator] = async function* () {
|
|
213
|
-
if (payload !== undefined) yield payload
|
|
214
|
-
}
|
|
215
|
-
return req
|
|
216
|
-
}
|
|
217
|
-
function fakeRes() {
|
|
218
|
-
const res = {
|
|
219
|
-
status: 0,
|
|
220
|
-
body: "",
|
|
221
|
-
/** 路由 handler 是 void handle(...):响应在异步体读完之后才写出,等 promise。 */
|
|
222
|
-
settled: undefined,
|
|
223
|
-
}
|
|
224
|
-
res.settled = new Promise(resolve => { res.settle = resolve })
|
|
225
|
-
res.writeHead = status => { res.status = status }
|
|
226
|
-
res.end = body => { res.body = body; res.settle() }
|
|
227
|
-
return res
|
|
228
|
-
}
|
|
229
|
-
async function callOn(routePath, method, url, body) {
|
|
230
|
-
const res = fakeRes()
|
|
231
|
-
routes.get(routePath)(fakeReq(method, url, body), res)
|
|
232
|
-
await res.settled
|
|
233
|
-
return { status: res.status, json: res.body === "" ? undefined : JSON.parse(res.body) }
|
|
234
|
-
}
|
|
235
|
-
const callMask = (method, url, body) => callOn("/api/triad/mcp-masks", method, url, body)
|
|
236
|
-
const callPreset = (method, url, body) => callOn("/api/triad/mcp-presets", method, url, body)
|
|
237
|
-
const callStatus = (method, url) => callOn("/api/triad/mcp-status", method, url)
|
|
238
|
-
const pluginsOf = presetId => presetRows.get(presetId).options.config.plugins
|
|
239
|
-
|
|
240
|
-
/* ── 1. 遮蔽:账本写入 + 三层补丁 ────────────────────────────────────── */
|
|
241
|
-
await callMask("PUT", "/api/triad/mcp-masks/code/github", { enabled: false })
|
|
242
|
-
await settle()
|
|
243
|
-
const ledger = JSON.parse(readFileSync(maskFile, "utf8"))
|
|
244
|
-
check("遮蔽账本写入 preset-masks.json", ledger.presets?.code?.github === false)
|
|
245
|
-
check("restrict 的 deny 只含被遮蔽 server 的全局工具", restrictCalls.length === 1
|
|
246
|
-
&& restrictCalls[0].deny.includes("mcp__github__search")
|
|
247
|
-
&& restrictCalls[0].deny.includes("mcp__github__create_issue")
|
|
248
|
-
&& !restrictCalls[0].deny.some(name => name.startsWith("mcp__web__"))
|
|
249
|
-
&& !restrictCalls[0].deny.includes("read"))
|
|
250
|
-
check("同名空 section 遮蔽服务器指令", sections.length === 1
|
|
251
|
-
&& sections[0].name === "mcp:github" && sections[0].text === "")
|
|
252
|
-
check("资源 stub 注册同名 provider", resourceStubs.length === 1
|
|
253
|
-
&& resourceStubs[0].serverName === "github")
|
|
254
|
-
let stubError = ""
|
|
255
|
-
try { await resourceStubs[0].provider.request() } catch (error) { stubError = String(error?.message ?? error) }
|
|
256
|
-
check("资源 stub 读取报「已对本预设禁用」", stubError.includes("disabled for agent preset"))
|
|
257
|
-
|
|
258
|
-
/* ── 2. 自带 serverName 覆盖优先于遮蔽 ──────────────────────────────── */
|
|
259
|
-
pluginsOf("code").push({
|
|
260
|
-
id: "mcp-neo",
|
|
261
|
-
name: "@deepseek-ai/dsh-mcp-client",
|
|
262
|
-
config: { serverName: "neo", transport: "streamable-http", url: "http://127.0.0.1:9/mcp" },
|
|
263
|
-
})
|
|
264
|
-
const statusBefore = await callStatus("GET", "/api/triad/mcp-status")
|
|
265
|
-
check("状态路由从声明行读到预设自带 server",
|
|
266
|
-
(statusBefore.json?.presetServers?.code ?? []).some(row => row.serverName === "neo"))
|
|
267
|
-
check("未定制过的预设标记为随安装(system)",
|
|
268
|
-
statusBefore.json?.presets?.find(row => row.id === "minimal")?.trust === "system")
|
|
269
|
-
restrictCalls.length = 0
|
|
270
|
-
sections.length = 0
|
|
271
|
-
resourceStubs.length = 0
|
|
272
|
-
await callMask("PUT", "/api/triad/mcp-masks/code/github", { enabled: true })
|
|
273
|
-
await callMask("PUT", "/api/triad/mcp-masks/code/neo", { enabled: false })
|
|
274
|
-
await settle()
|
|
275
|
-
check("自带 serverName 不进入 deny(自带覆盖优先于遮蔽)",
|
|
276
|
-
restrictCalls.every(call => !call.deny.some(name => name.startsWith("mcp__neo__"))))
|
|
277
|
-
check("全局视图无该 server 工具时不注册遮蔽补丁(deny 空 → 三处皆无)",
|
|
278
|
-
restrictCalls.length === 0 && sections.length === 0 && resourceStubs.length === 0)
|
|
279
|
-
|
|
280
|
-
/* ── 3. 预设专属行:读取 / 插入 / 删除 ──────────────────────────────── */
|
|
281
|
-
const added = await callPreset("POST", "/api/triad/mcp-presets/code/servers", {
|
|
282
|
-
serverName: "docs", transport: "stdio", command: "npx", args: ["-y", "@scope/mcp-docs"],
|
|
283
|
-
})
|
|
284
|
-
check("新增预设专属行返回 200", added.status === 200 && added.json?.ok === true)
|
|
285
|
-
const inserted = pluginsOf("code").find(row => row.name === "@deepseek-ai/dsh-mcp-client" && row.config.serverName === "docs")
|
|
286
|
-
check("行已写进声明行 config.plugins(id / name / transport)", inserted !== undefined
|
|
287
|
-
&& inserted.id === "mcp-docs" && inserted.config.transport === "stdio"
|
|
288
|
-
&& inserted.config.serverName === "docs")
|
|
289
|
-
check("写入走 configEditor.edit", editCalls === 1)
|
|
290
|
-
check("既有声明行保留(persona / mcp-neo)",
|
|
291
|
-
pluginsOf("code").some(row => row.id === "persona") && pluginsOf("code").some(row => row.id === "mcp-neo"))
|
|
292
|
-
const statusAfter = await callStatus("GET", "/api/triad/mcp-status")
|
|
293
|
-
check("写过的预设标记为用户定制(user)",
|
|
294
|
-
statusAfter.json?.presets?.find(row => row.id === "code")?.trust === "user")
|
|
295
|
-
|
|
296
|
-
const dup = await callPreset("POST", "/api/triad/mcp-presets/code/servers", {
|
|
297
|
-
serverName: "docs", transport: "stdio", command: "npx",
|
|
298
|
-
})
|
|
299
|
-
check("重复 serverName 被拒", dup.status === 400 && String(dup.json?.error).includes("already exists"))
|
|
300
|
-
|
|
301
|
-
const removed = await callPreset("DELETE", "/api/triad/mcp-presets/code/servers/docs")
|
|
302
|
-
check("删除预设专属行返回 200", removed.status === 200 && removed.json?.ok === true)
|
|
303
|
-
check("行已从声明行移除", !pluginsOf("code").some(row => row.config?.serverName === "docs"))
|
|
304
|
-
check("删除保留其它行", pluginsOf("code").length === 2)
|
|
305
|
-
|
|
306
|
-
const shipped = await callPreset("POST", "/api/triad/mcp-presets/minimal/servers", {
|
|
307
|
-
serverName: "x", transport: "stdio", command: "npx",
|
|
308
|
-
})
|
|
309
|
-
check("随安装的默认预设同样可写", shipped.status === 200 && shipped.json?.ok === true)
|
|
310
|
-
check("写入落在该预设声明行的 plugins",
|
|
311
|
-
pluginsOf("minimal").some(row => row.config?.serverName === "x"))
|
|
312
|
-
|
|
313
|
-
const locked = await callPreset("POST", "/api/triad/mcp-presets/ambiguous/servers", {
|
|
314
|
-
serverName: "z", transport: "stdio", command: "npx",
|
|
315
|
-
})
|
|
316
|
-
check("行 id 不唯一(不可寻址)时拒绝写入 409",
|
|
317
|
-
locked.status === 409 && locked.json?.error === "preset-not-editable")
|
|
318
|
-
|
|
319
|
-
const missing = await callPreset("POST", "/api/triad/mcp-presets/nope/servers", { serverName: "y", transport: "stdio", command: "npx" })
|
|
320
|
-
check("名单外的预设返回 404", missing.status === 404 && missing.json?.error === "preset-not-found")
|
|
321
|
-
|
|
322
|
-
/* ── 3b. 粘贴添加:JSON / YAML 校验与写入 ───────────────────────────── */
|
|
323
|
-
const previewRoute = "/api/triad/mcp-preview"
|
|
324
|
-
const preview = (body) => callOn(previewRoute, "POST", previewRoute, body)
|
|
325
|
-
|
|
326
|
-
const jsonPaste = JSON.stringify({
|
|
327
|
-
mcpServers: {
|
|
328
|
-
alpha: { command: "npx", args: ["-y", "@scope/alpha"] },
|
|
329
|
-
beta: { url: "https://example.com/mcp" },
|
|
330
|
-
},
|
|
331
|
-
})
|
|
332
|
-
const pv = await preview({ format: "json", text: jsonPaste, target: "preset" })
|
|
333
|
-
check("预览:JSON 多 server 解析成功", pv.status === 200 && pv.json?.ok === true && pv.json?.servers?.length === 2)
|
|
334
|
-
check("预览:返回待写入 YAML(serverName/transport)", String(pv.json?.yaml).includes("serverName: alpha")
|
|
335
|
-
&& String(pv.json?.yaml).includes("transport: streamable-http"))
|
|
336
|
-
check("预览:url 型 server 推断为 streamable-http",
|
|
337
|
-
(pv.json?.servers ?? []).some(item => item.name === "beta" && item.transport === "streamable-http"))
|
|
338
|
-
|
|
339
|
-
const yamlPaste = [
|
|
340
|
-
"mcpServers:",
|
|
341
|
-
" gamma:",
|
|
342
|
-
" command: npx",
|
|
343
|
-
" env:",
|
|
344
|
-
" TOKEN: ${MY_TOKEN}",
|
|
345
|
-
"",
|
|
346
|
-
].join("\n")
|
|
347
|
-
const pvYaml = await preview({ format: "yaml", text: yamlPaste, target: "preset" })
|
|
348
|
-
check("预览:DSH 原生 YAML(mcpServers 形态)", pvYaml.status === 200 && pvYaml.json?.servers?.[0]?.name === "gamma")
|
|
349
|
-
check("预览:${VAR} 占位转 !!js 模板",
|
|
350
|
-
String(pvYaml.json?.yaml).includes("!!js") && String(pvYaml.json?.yaml).includes("process.env.MY_TOKEN"))
|
|
351
|
-
|
|
352
|
-
const rowYaml = [
|
|
353
|
-
"- id: mcp-delta",
|
|
354
|
-
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
355
|
-
" config:",
|
|
356
|
-
" serverName: delta",
|
|
357
|
-
" transport: stdio",
|
|
358
|
-
" command: node",
|
|
359
|
-
" failOnStartupError: true",
|
|
360
|
-
"",
|
|
361
|
-
].join("\n")
|
|
362
|
-
const pvRow = await preview({ format: "yaml", text: rowYaml, target: "preset" })
|
|
363
|
-
check("预览:mcp-client 行片段(保留额外 config 键)",
|
|
364
|
-
pvRow.status === 200 && String(pvRow.json?.yaml).includes("failOnStartupError: true"))
|
|
365
|
-
|
|
366
|
-
const pvBadJson = await preview({ format: "json", text: "{ not json" })
|
|
367
|
-
check("预览:坏 JSON 给出解析错误", pvBadJson.json?.ok === false
|
|
368
|
-
&& String(pvBadJson.json?.errors?.[0]).includes("JSON 解析失败"))
|
|
369
|
-
const pvBadYaml = await preview({ format: "yaml", text: "mcpServers:\n x:\n - broken: [1,2\n" })
|
|
370
|
-
check("预览:坏 YAML 给出带行号的解析错误", pvBadYaml.json?.ok === false
|
|
371
|
-
&& String(pvBadYaml.json?.errors?.[0]).includes("YAML 解析失败"))
|
|
372
|
-
|
|
373
|
-
const pasted = await callPreset("POST", "/api/triad/mcp-presets/code/servers", { format: "json", text: jsonPaste })
|
|
374
|
-
check("粘贴写入预设:多 server 一次写入", pasted.status === 200 && (pasted.json?.added ?? []).length === 2)
|
|
375
|
-
const pastedAgain = await callPreset("POST", "/api/triad/mcp-presets/code/servers", { format: "json", text: jsonPaste })
|
|
376
|
-
check("重复粘贴:已存在的 serverName 跳过", pastedAgain.status === 200
|
|
377
|
-
&& (pastedAgain.json?.added ?? []).length === 0 && (pastedAgain.json?.skipped ?? []).length === 2)
|
|
378
|
-
const presetEnv = await callPreset("POST", "/api/triad/mcp-presets/code/servers", { format: "yaml", text: yamlPaste })
|
|
379
|
-
check("预设粘贴:${VAR} 占位在行数据里是 !!js 包装", presetEnv.status === 200
|
|
380
|
-
&& pluginsOf("code").some(row => row.config?.serverName === "gamma"
|
|
381
|
-
&& typeof row.config?.env?.TOKEN?.__jsExpr === "string"
|
|
382
|
-
&& row.config.env.TOKEN.__jsExpr.includes("process.env.MY_TOKEN")))
|
|
383
|
-
|
|
384
|
-
const globalAdd = await callOn("/api/triad/mcp-config", "POST", "/api/triad/mcp-config", { action: "add", format: "yaml", text: yamlPaste })
|
|
385
|
-
check("全局粘贴写入 profile patch", globalAdd.status === 200 && (globalAdd.json?.added ?? []).includes("gamma"))
|
|
386
|
-
const patchText = readFileSync(patchFile, "utf8")
|
|
387
|
-
check("patch 保留原内容并追加 - insert: 块",
|
|
388
|
-
patchText.includes("some-plugin") && patchText.includes("- insert:") && patchText.includes("serverName: gamma"))
|
|
389
|
-
check("patch 写前备份存在", readFileSync(`${patchFile}.bak-last-toggle`, "utf8").includes("some-plugin"))
|
|
390
|
-
|
|
391
|
-
/* ── 4. 非法输入拒绝 ───────────────────────────────────────────────── */
|
|
392
|
-
const badName = await callMask("PUT", "/api/triad/mcp-masks/code/bad.name", { enabled: false })
|
|
393
|
-
check("非法 serverName 被拒", badName.status === 400)
|
|
394
|
-
const badBody = await callMask("PUT", "/api/triad/mcp-masks/code/github", { enabled: "yes" })
|
|
395
|
-
check("enabled 非布尔被拒", badBody.status === 400)
|
|
396
|
-
|
|
397
|
-
/* ── 5. 桌面/自定义 profile:patch 目标随宿主 profileContext 走 ─────────
|
|
398
|
-
* 0.8.1 前 PROFILE_NAME 写死 'web':桌面端(profile desktop)的状态与切换
|
|
399
|
-
* 会读写 web profile 的 cordis.patch.yml(真实 bug)。回归两件事:
|
|
400
|
-
* 主路径 ctx.profileContext(真实宿主注入形态)与 DSH_DESKTOP_PROFILE 回落。
|
|
401
|
-
* 在全套断言之后执行:重复 apply 会重挂路由(stub 的 Map.set 幂等覆盖),
|
|
402
|
-
* 但会给 stub agent 补一层 restrict 调用,别污染上面的计数。 */
|
|
403
|
-
const desktopDir = join(process.env.DSH_HOME, "profiles", "desktop")
|
|
404
|
-
const desktopPatch = join(desktopDir, "cordis.patch.yml")
|
|
405
|
-
mkdirSync(desktopDir, { recursive: true })
|
|
406
|
-
writeFileSync(desktopPatch, ["# desktop patch layer", ""].join("\n"))
|
|
407
|
-
const ctxDesktop = {
|
|
408
|
-
...ctx,
|
|
409
|
-
profileContext: { name: "desktop", dir: desktopDir, patchPath: desktopPatch },
|
|
410
|
-
}
|
|
411
|
-
await mod.apply(ctxDesktop, {})
|
|
412
|
-
await settle()
|
|
413
|
-
const desktopStatus = await callStatus("GET", "/api/triad/mcp-status")
|
|
414
|
-
check("status.patchFile 随 profileContext 指向 desktop(profile=desktop)",
|
|
415
|
-
desktopStatus.json?.patchFile === desktopPatch && desktopStatus.json?.profile === "desktop")
|
|
416
|
-
const desktopAdd = await callOn("/api/triad/mcp-config", "POST", "/api/triad/mcp-config",
|
|
417
|
-
{ action: "add", format: "yaml", text: yamlPaste })
|
|
418
|
-
check("桌面 profile:粘贴写入 desktop patch 而非 web",
|
|
419
|
-
desktopAdd.status === 200 && readFileSync(desktopPatch, "utf8").includes("serverName: gamma")
|
|
420
|
-
&& !readFileSync(patchFile, "utf8").includes("# gamma-desktop-marker"))
|
|
421
|
-
|
|
422
|
-
// 回落链:真实宿主面缺失(如桌面壳 v4 只传环境变量)时按 DSH_DESKTOP_PROFILE 推。
|
|
423
|
-
const shellDir = join(process.env.DSH_HOME, "profiles", "desktop-shell")
|
|
424
|
-
mkdirSync(shellDir, { recursive: true })
|
|
425
|
-
process.env.DSH_DESKTOP_PROFILE = "desktop-shell"
|
|
426
|
-
const ctxShell = { ...ctx }
|
|
427
|
-
delete ctxShell.profileContext
|
|
428
|
-
await mod.apply(ctxShell, {})
|
|
429
|
-
await settle()
|
|
430
|
-
const shellStatus = await callStatus("GET", "/api/triad/mcp-status")
|
|
431
|
-
check("无 profileContext 时按 DSH_DESKTOP_PROFILE 回落",
|
|
432
|
-
shellStatus.json?.patchFile === join(shellDir, "cordis.patch.yml")
|
|
433
|
-
&& shellStatus.json?.profile === "desktop-shell")
|
|
434
|
-
delete process.env.DSH_DESKTOP_PROFILE
|
|
435
|
-
|
|
436
|
-
rmSync(sandbox, { recursive: true, force: true })
|
|
437
|
-
if (failed > 0) {
|
|
438
|
-
console.error(`\nMCP-LAYERING TEST FAILED (${failed})`)
|
|
439
|
-
process.exit(1)
|
|
440
|
-
}
|
|
441
|
-
console.log("\nMCP-LAYERING TEST PASSED")
|