custom-skills.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import pino from 'pino'
  4. import { strToU8, unzipSync, zipSync } from 'fflate'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import { createApp, type App } from '../src/app'
  7. import { config, customSkillsDir, customSkillStoreDir } from '../src/config'
  8. import { REGISTRY_LIMITS, renameSkillMd } from '../src/custom-skills/registry'
  9. import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
  10. import { buildServer } from '../src/http/server'
  11. import { resolveSkills } from '../src/providers/claude/skills'
  12. import { skillsToDisable } from '../src/providers/codex/skills'
  13. import { checkAccess, resolveAccess } from '../src/services/access-policy'
  14. import type { RegistrySubmissionDto } from '@agent-server/shared'
  15. let app: App
  16. let server: Awaited<ReturnType<typeof buildServer>>
  17. let base: string
  18. const audits: string[] = []
  19. const live = (name: string, f = 'SKILL.md') => path.join(customSkillsDir(), 'skills', name, f)
  20. const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`, extra = '') => `---\nname: ${name}\ndescription: ${desc}\n${extra}---\n# ${name}\n`
  21. const zip = (files: Record<string, string>) => Buffer.from(zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)]))))
  22. /** 用发布接口推送 */
  23. async function push(key: string, pkg: Buffer, opts: { message?: string; meta?: object; dryRun?: boolean } = {}) {
  24. const form = new FormData()
  25. form.append('file', new Blob([new Uint8Array(pkg)]), 'skills.zip')
  26. form.append('message', opts.message ?? '')
  27. if (opts.meta) form.append('meta', JSON.stringify(opts.meta))
  28. const res = await fetch(`${base}/skills/registry/v1/submissions${opts.dryRun ? '?dryRun=1' : ''}`, { method: 'POST', headers: key ? { Authorization: `Bearer ${key}` } : {}, body: form })
  29. return { status: res.status, body: await res.json() as RegistrySubmissionDto & { error?: { message: string } } }
  30. }
  31. const keyRow = (key: string) => app.skillRegistry.verify(key, '127.0.0.1')!
  32. const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)!
  33. beforeAll(async () => {
  34. const log = pino({ level: 'silent' })
  35. app = createApp(log, { providers: {} })
  36. const orig = app.audit.log.bind(app.audit)
  37. app.audit.log = (e: Parameters<typeof orig>[0]) => { audits.push(e.summary); return orig(e) }
  38. server = await buildServer(app, { logger: log })
  39. base = await server.listen({ port: 0, host: '127.0.0.1' })
  40. })
  41. afterAll(async () => {
  42. await server.close()
  43. })
  44. describe('技能发布平台:推送与审核(docs/design/18-skill-registry.md)', () => {
  45. let key: string
  46. let subId: string
  47. it('C-01 推送:只接受有效的发布密钥;试运行不保存;包中的技能逐个待审核,附自动检查', async () => {
  48. key = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 }).key
  49. expect(key).toMatch(/^sk_pub_/)
  50. const pkg = zip({
  51. 'skills/alpha/SKILL.md': md('alpha'),
  52. 'skills/alpha/references/guide.md': 'alpha guide v1\nline2\n',
  53. 'skills/beta/SKILL.md': md('beta'),
  54. 'skills/beta/scripts/run.sh': 'curl https://api.example.com/v1/x\npassword = abcdefghijklmnop1234\n',
  55. '../evil.md': 'x',
  56. })
  57. expect((await push('', pkg)).status).toBe(401)
  58. expect((await push('sk_pub_nope', pkg)).status).toBe(401)
  59. expect((await push('ak_wrong_kind', pkg)).status).toBe(401)
  60. const dry = await push(key, pkg, { dryRun: true })
  61. expect(dry.status).toBe(200)
  62. expect([dry.body.id, dry.body.dryRun, dry.body.items.map(i => [i.name, i.status])]).toEqual(['', true, [['alpha', 'pending'], ['beta', 'pending']]])
  63. expect(app.skillRegistry.pendingCount()).toBe(0)
  64. const r = await push(key, pkg, { message: '新增 alpha 与 beta', meta: { repo: 'fwh-app', commit: 'abc123' } })
  65. expect(r.status).toBe(201)
  66. subId = r.body.id
  67. expect(r.body.skipped).toEqual(['../evil.md(路径不安全)'])
  68. expect(r.body.items.map(i => [i.name, i.status, i.existing])).toEqual([['alpha', 'pending', null], ['beta', 'pending', null]])
  69. const beta = r.body.items[1]!
  70. expect(beta.checks.map(c => [c.code, c.level])).toEqual([['script', 'warning'], ['secret', 'danger'], ['url', 'info']])
  71. expect(beta.checks[0]!.files).toEqual(['scripts/run.sh'])
  72. expect(beta.checks[2]!.message).toContain('api.example.com')
  73. // 推送不改变任何技能;后台待审核列表中有两项
  74. expect(fs.existsSync(live('alpha'))).toBe(false)
  75. expect(app.skillRegistry.listItems('pending').map(i => [i.name, i.keyName, i.message, i.meta.commit])).toEqual([['alpha', 'fwh-app · CI', '新增 alpha 与 beta', 'abc123'], ['beta', 'fwh-app · CI', '新增 alpha 与 beta', 'abc123']])
  76. expect(audits.some(a => a.startsWith('推送技能包(fwh-app · CI') && a.includes('alpha(待审核)'))).toBe(true)
  77. // 推送方只能看到自己的提交
  78. const other = app.skillRegistry.createKey({ name: '另一个项目', description: '', expiresInDays: null }).key
  79. const get = (k: string) => fetch(`${base}/skills/registry/v1/submissions/${subId}`, { headers: { Authorization: `Bearer ${k}` } })
  80. expect((await get(key)).status).toBe(200)
  81. expect((await get(other)).status).toBe(404)
  82. // 停用的密钥不能使用
  83. const otherId = app.skillRegistry.listKeys().find(k => k.name === '另一个项目')!.id
  84. app.skillRegistry.updateKey(otherId, { enabled: false })
  85. expect((await push(other, pkg)).status).toBe(401)
  86. expect(app.skillRegistry.listKeys().find(k => k.name === 'fwh-app · CI')).toMatchObject({ submissions: 1, pending: 2, lastUsedIp: '127.0.0.1' })
  87. })
  88. it('C-02 批准为新技能(可以改名,SKILL.md 中的 name 随之改写);拒绝须填写原因,推送方可以看到', async () => {
  89. const alpha = pending('alpha')
  90. expect(app.skillRegistry.diff(alpha.id, {}).files.map(f => [f.path, f.status])).toEqual([['references/guide.md', 'added'], ['SKILL.md', 'added']])
  91. const r = app.skillRegistry.approve(alpha.id, { action: 'create', name: 'alpha', publish: true })
  92. expect(r.result).toMatchObject({ action: 'create', skillName: 'alpha', version: 1, published: true })
  93. expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
  94. const a = app.customSkills.detail(r.result!.skillId)
  95. expect(a.versions[0]).toMatchObject({ version: 1, reason: 'submission', note: { submissionId: subId, keyName: 'fwh-app · CI', message: '新增 alpha 与 beta' } })
  96. // 推送来的技能可以直接编辑
  97. app.customSkills.writeFile(a.id, 'notes.md', Buffer.from('x'))
  98. app.customSkills.discard(a.id)
  99. const beta = pending('beta')
  100. const d = app.skillRegistry.diff(beta.id, { name: 'gamma' })
  101. expect(d.renamed).toBe(true)
  102. expect(d.files.find(f => f.path === 'SKILL.md')!.patch).toContain('+name: gamma')
  103. expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'alpha', publish: true })).toThrow(/已被使用/)
  104. expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'Bad Name', publish: true })).toThrow(/小写字母/)
  105. app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma', publish: true })
  106. expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
  107. expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma2', publish: true })).toThrow(/已经处理/)
  108. const s = app.skillRegistry.getSubmission(keyRow(key).id, subId)
  109. expect(s.status).toBe('done')
  110. expect(s.items.map(i => [i.name, i.status, i.result?.skill, i.result?.version])).toEqual([['alpha', 'approved', 'alpha', 1], ['beta', 'approved', 'gamma', 1]])
  111. const r2 = await push(key, zip({ 'delta/SKILL.md': md('delta') }))
  112. const delta = pending('delta')
  113. expect(() => app.skillRegistry.reject(delta.id, ' ')).toThrow(/原因/)
  114. app.skillRegistry.reject(delta.id, '描述不清楚')
  115. expect(app.skillRegistry.getSubmission(keyRow(key).id, r2.body.id).items[0]).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
  116. expect(app.skillRegistry.listItems('done').find(i => i.name === 'delta')).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
  117. })
  118. it('C-03 再次推送:相同内容为无变化;同一密钥再推送替代旧的待审核;覆盖时以审核时看到的版本为准', async () => {
  119. const same = await push(key, zip({ 'alpha/SKILL.md': md('alpha'), 'alpha/references/guide.md': 'alpha guide v1\nline2\n' }))
  120. expect(same.body.items[0]).toMatchObject({ name: 'alpha', status: 'unchanged', existing: { version: 1 } })
  121. const v2 = (body: string, ver: string) => zip({ 'alpha/SKILL.md': md('alpha', undefined, `metadata:\n version: "${ver}"\n`), 'alpha/references/guide.md': body })
  122. const first = await push(key, v2('alpha guide v2\n', '2.0'))
  123. const second = await push(key, v2('alpha guide v3\n', '2.0'))
  124. expect(app.skillRegistry.getSubmission(keyRow(key).id, first.body.id).items[0]!.status).toBe('superseded')
  125. const item = pending('alpha')
  126. expect(item.submissionId).toBe(second.body.id)
  127. expect(item.existing).toMatchObject({ version: 1 })
  128. expect(second.body.items[0]!.changes).toEqual({ added: [], modified: ['SKILL.md', 'references/guide.md'], removed: [] })
  129. expect(item.checks.find(c => c.code === 'version')!.message).toBe('声明版本号:未声明 → 2.0')
  130. const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
  131. const d = app.skillRegistry.diff(item.id, { targetId: alphaId })
  132. expect(d.target).toMatchObject({ name: 'alpha', version: 1, semver: '1.0.0', unsaved: false })
  133. // 声明的 2.0 不是 主.次.补丁:建议补丁升级
  134. expect(d.suggestedSemver).toBe('1.0.1')
  135. expect(d.files.find(f => f.path === 'references/guide.md')!.patch).toContain('+alpha guide v3')
  136. // 审核期间技能有了新版本:拒绝覆盖
  137. expect(() => app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 0, publish: true })).toThrow(/刷新/)
  138. const r = app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 1, publish: false })
  139. expect(r.result).toMatchObject({ action: 'overwrite', skillName: 'alpha', version: 2, semver: '1.0.1', published: false })
  140. expect(app.customSkills.detail(alphaId)).toMatchObject({ latestVersion: 2, latestSemver: '1.0.1', publishedVersion: 1, publishedSemver: '1.0.0' })
  141. expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
  142. app.customSkills.publish(alphaId, 2)
  143. expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
  144. // 覆盖到不同名的技能:SKILL.md 的 name 改写为目标技能名
  145. await push(key, zip({ 'alpha-next/SKILL.md': md('alpha-next') }))
  146. const next = pending('alpha-next')
  147. const gammaId = app.customSkills.list().find(s => s.name === 'gamma')!.id
  148. app.skillRegistry.approve(next.id, { action: 'overwrite', targetId: gammaId, expectedVersion: 1, publish: true })
  149. expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
  150. expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('# alpha-next')
  151. // 版本号:SKILL.md 声明了更大的合法版本号时默认用它;也可以填写;不能小于当前版本
  152. await push(key, zip({ 'delta/SKILL.md': md('delta', undefined, 'version: 2.5.0\n') }))
  153. const delta = pending('delta')
  154. expect(app.skillRegistry.diff(delta.id, { name: 'delta' }).suggestedSemver).toBe('2.5.0')
  155. expect(app.skillRegistry.approve(delta.id, { action: 'create', name: 'delta', publish: true }).result).toMatchObject({ version: 1, semver: '2.5.0' })
  156. await push(key, zip({ 'delta/SKILL.md': md('delta', '新的说明:处理某类业务数据的流程与规范', 'version: 2.4.0\n') }))
  157. const delta2 = pending('delta')
  158. const deltaId = app.customSkills.list().find(s => s.name === 'delta')!.id
  159. expect(app.skillRegistry.diff(delta2.id, { targetId: deltaId }).suggestedSemver).toBe('2.5.1')
  160. expect(() => app.skillRegistry.approve(delta2.id, { action: 'overwrite', targetId: deltaId, expectedVersion: 1, publish: true, semver: '2.4.0' })).toThrow(/必须大于/)
  161. expect(app.skillRegistry.approve(delta2.id, { action: 'overwrite', targetId: deltaId, expectedVersion: 1, publish: true, semver: '3.0.0' }).result).toMatchObject({ version: 2, semver: '3.0.0' })
  162. const reg = await fetch(`${base}/skills/registry/v1/submissions/${delta2.submissionId}`, { headers: { Authorization: `Bearer ${key}` } }).then(x => x.json() as Promise<{ items: { result: unknown }[] }>)
  163. expect(reg.items[0]!.result).toMatchObject({ skill: 'delta', version: 2, semver: '3.0.0' })
  164. })
  165. it('C-04 撤回;删除已处理的提交项(待审核的不能删),技能版本保留提交说明', async () => {
  166. const r = await push(key, zip({ 'epsilon/SKILL.md': md('epsilon') }))
  167. const res = await fetch(`${base}/skills/registry/v1/submissions/${r.body.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${key}` } })
  168. expect(((await res.json()) as RegistrySubmissionDto).items[0]!.status).toBe('withdrawn')
  169. await push(key, zip({ 'zeta/SKILL.md': md('zeta') }))
  170. expect(() => app.skillRegistry.deleteItem(pending('zeta').id)).toThrow(/先批准或拒绝/)
  171. app.skillRegistry.reject(pending('zeta').id, '不需要')
  172. const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
  173. for (const i of app.skillRegistry.listItems('done')) app.skillRegistry.deleteItem(i.id)
  174. expect(app.skillRegistry.listItems('done')).toEqual([])
  175. expect(app.customSkills.detail(alphaId).versions[0]!.note).toMatchObject({ keyName: 'fwh-app · CI' })
  176. expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
  177. })
  178. it('C-05 校验:不合规的技能为 invalid;不是 zip、没有技能、压缩炸弹被拒绝;推送次数与待审核数有上限', async () => {
  179. const r = await push(key, zip({ 'Bad_Name/SKILL.md': md('Bad_Name'), 'ok/SKILL.md': '# 没有 frontmatter\n', 'a/SKILL.md': md('dup'), 'b/SKILL.md': md('dup') }))
  180. expect(r.body.items.map(i => [i.name, i.status])).toEqual([['dup', 'pending'], ['dup', 'invalid'], ['Bad_Name', 'invalid'], ['ok', 'pending']])
  181. expect(r.body.items[1]!.reason).toMatch(/多个名为 dup/)
  182. expect((await push(key, Buffer.from('not a zip'))).status).toBe(400)
  183. expect((await push(key, zip({ 'readme.md': 'x' }))).body.error!.message).toMatch(/没有找到技能/)
  184. const saved = LIMITS.unzipBytes
  185. LIMITS.unzipBytes = 1048576
  186. try {
  187. const bomb = Buffer.from(zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 }))
  188. expect((await push(key, bomb)).body.error!.message).toMatch(/解压后超过/)
  189. } finally {
  190. LIMITS.unzipBytes = saved
  191. }
  192. const k = app.skillRegistry.createKey({ name: '限额', description: '', expiresInDays: 1 }).key
  193. const limits = { ...REGISTRY_LIMITS }
  194. REGISTRY_LIMITS.pendingPerKey = 1
  195. try {
  196. expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(201)
  197. // 同名技能替代旧的,不占额度;新技能超出上限
  198. expect((await push(k, zip({ 'q1/SKILL.md': md('q1', 'q1 的新说明:处理某类业务数据的流程与规范') }))).status).toBe(201)
  199. expect((await push(k, zip({ 'q2/SKILL.md': md('q2') }))).status).toBe(429)
  200. REGISTRY_LIMITS.submissionsPerHour = 2
  201. expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(429)
  202. } finally {
  203. Object.assign(REGISTRY_LIMITS, limits)
  204. }
  205. expect(renameSkillMd('# x\n', 'n')).toBe('---\nname: n\n---\n# x\n')
  206. })
  207. })
  208. describe('自定义技能:在线编写、草稿与发布、版本', () => {
  209. let id: string
  210. it('C-04 新建 → 上传模版并保存为草稿:不生效;发布后生效;恢复到上一版本', () => {
  211. const d = app.customSkills.create({ name: 'report-kit', description: '生成月度报告的规范与模版,涉及月报时使用' })
  212. id = d.id
  213. expect(d.versions.map(v => [v.version, v.semver, v.reason])).toEqual([[1, '1.0.0', 'create']])
  214. expect(d.publishedVersion).toBeNull()
  215. expect(fs.existsSync(live('report-kit'))).toBe(false)
  216. app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([1, 2, 3]))
  217. expect(app.customSkills.detail(id).hasUnsavedChanges).toBe(true)
  218. const v2 = app.customSkills.save(id, { baseVersion: 1, publish: false })
  219. expect([v2.latestVersion, v2.publishedVersion, v2.hasUnsavedChanges]).toEqual([2, null, false])
  220. expect(fs.existsSync(live('report-kit'))).toBe(false)
  221. // 内容无变化不产生新版本;基于旧版本保存时冲突
  222. expect(app.customSkills.save(id, { baseVersion: 2, publish: false }).latestVersion).toBe(2)
  223. expect(() => app.customSkills.save(id, { baseVersion: 1, publish: false })).toThrow(/刷新/)
  224. expect(app.customSkills.publish(id, 2).publishedVersion).toBe(2)
  225. expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
  226. app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([9]))
  227. app.customSkills.writeFile(id, 'SKILL.md', Buffer.from(md('report-kit', '新版描述:生成月度报告的规范与模版,涉及月报时使用')))
  228. const v3 = app.customSkills.save(id, { baseVersion: 2, publish: true })
  229. expect([v3.latestVersion, v3.publishedVersion, v3.description]).toEqual([3, 3, '新版描述:生成月度报告的规范与模版,涉及月报时使用'])
  230. expect(v3.versions[0]!.changed).toEqual({ added: [], modified: ['SKILL.md', 'templates/a.pptx'], removed: [] })
  231. const v4 = app.customSkills.restore(id, 2)
  232. expect([v4.latestVersion, v4.publishedVersion, v4.versions[0]!.reason]).toEqual([4, 4, 'restore'])
  233. // 版本号默认补丁升级;恢复记录恢复自哪个版本
  234. expect(v4.versions.map(v => v.semver)).toEqual(['1.0.3', '1.0.2', '1.0.1', '1.0.0'])
  235. expect(v4.versions[0]!.note).toEqual({ restoredFrom: '1.0.1' })
  236. expect([v4.latestSemver, v4.publishedSemver]).toEqual(['1.0.3', '1.0.3'])
  237. expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
  238. })
  239. it('SKILL.md 校验、路径安全、重命名与删除、放弃改动、导出 zip', () => {
  240. app.customSkills.writeFile(id, 'SKILL.md', Buffer.from('---\nname: other\ndescription: x\n---\n'))
  241. expect(() => app.customSkills.save(id, { baseVersion: 4, publish: false })).toThrow(/不一致/)
  242. app.customSkills.discard(id)
  243. expect(() => app.customSkills.writeFile(id, '../escape.md', Buffer.from('x'))).toThrow(/路径不正确/)
  244. expect(() => normalizeRelPath('/abs')).toThrow()
  245. expect(() => app.customSkills.deleteFile(id, 'SKILL.md')).toThrow(/不能删除/)
  246. app.customSkills.writeFile(id, 'refs/a.md', Buffer.from('a'))
  247. expect(app.customSkills.move(id, 'refs', 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'references/a.md', 'templates/a.pptx'])
  248. expect(app.customSkills.deleteFile(id, 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'templates/a.pptx'])
  249. const z = app.customSkills.exportZip(id)
  250. expect(z.name).toBe('report-kit-v1.0.3.zip')
  251. expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['report-kit/SKILL.md', 'report-kit/templates/a.pptx'])
  252. expect(app.customSkills.exportZip(id, 1).name).toBe('report-kit-v1.0.0.zip')
  253. })
  254. it('版本号:保存时选择升级主版本 / 次版本 / 补丁,或填写更大的版本号', () => {
  255. const edit = (body: string) => app.customSkills.writeFile(id, 'notes.md', Buffer.from(body))
  256. const base = () => app.customSkills.detail(id).latestVersion
  257. edit('a')
  258. expect(app.customSkills.save(id, { baseVersion: base(), publish: false, bump: 'minor' }).latestSemver).toBe('1.1.0')
  259. edit('b')
  260. expect(app.customSkills.save(id, { baseVersion: base(), publish: false, bump: 'major' }).latestSemver).toBe('2.0.0')
  261. edit('c')
  262. for (const bad of ['2.0.0', '1.9.9', 'abc', '2.0', '02.1.0']) {
  263. expect(() => app.customSkills.save(id, { baseVersion: base(), publish: false, semver: bad })).toThrow(/必须大于|格式不正确/)
  264. }
  265. const d = app.customSkills.save(id, { baseVersion: base(), publish: true, semver: 'v2.3.4' })
  266. expect([d.latestSemver, d.publishedSemver]).toEqual(['2.3.4', '2.3.4'])
  267. expect(app.customSkills.restore(id, 1, { bump: 'minor' }).latestSemver).toBe('2.4.0')
  268. expect(app.customSkills.diff(id, d.latestVersion, d.latestVersion + 1).find(f => f.path === 'notes.md')!.patch).toContain('v2.3.4')
  269. })
  270. })
  271. describe('自定义技能:使用、沙箱与恢复', () => {
  272. it('C-05 "全部技能"不包含自定义技能(可逐个设置包含);单独勾选后加载', () => {
  273. const plugins = [
  274. { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
  275. app.customSkills.pluginRef()!,
  276. ]
  277. expect(resolveSkills('all', plugins).skills).toEqual(['agent-skills:r'])
  278. expect(resolveSkills(['custom-skills:report-kit'], plugins).skills).toEqual(['custom-skills:report-kit'])
  279. const id = app.customSkills.list().find(s => s.name === 'report-kit')!.id
  280. app.customSkills.setFlags(id, { includeInAll: true })
  281. expect(resolveSkills('all', [plugins[0]!, app.customSkills.pluginRef()!]).skills).toContain('custom-skills:report-kit')
  282. expect(app.customSkills.excludedFromAll()).not.toContain('custom-skills:report-kit')
  283. // Codex:all 时禁用需要单独勾选的自定义技能
  284. const all = [
  285. { name: 'custom-skills:alpha', path: path.join(customSkillsDir(), 'skills/alpha/SKILL.md') },
  286. { name: 'custom-skills:Report Kit', path: path.join(customSkillsDir(), 'skills/report-kit/SKILL.md') },
  287. ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
  288. expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills', undefined, app.customSkills.excludedFromAll()).map(d => path.basename(path.dirname(d.path)))).toEqual(['alpha'])
  289. })
  290. it('停用后不加载,启用后恢复;指纹随之变化', () => {
  291. const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
  292. const before = app.customSkills.fingerprint()
  293. app.customSkills.setFlags(id, { enabled: false })
  294. expect(fs.existsSync(live('alpha'))).toBe(false)
  295. expect(app.customSkills.skillInfos().map(s => s.name)).not.toContain('alpha')
  296. expect(app.customSkills.fingerprint()).not.toBe(before)
  297. app.customSkills.setFlags(id, { enabled: true })
  298. expect(fs.existsSync(live('alpha'))).toBe(true)
  299. })
  300. it('C-06 沙箱:生效目录只读可见,不能写;版本库不可读', () => {
  301. const settings = app.settings.get()
  302. const ws = path.join(config.dataDir, 'workspaces', 'ses_cs')
  303. fs.mkdirSync(ws, { recursive: true })
  304. const access = resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
  305. expect(checkAccess(access, live('alpha'), 'read').allowed).toBe(true)
  306. expect(checkAccess(access, live('alpha'), 'write').allowed).toBe(false)
  307. expect(checkAccess(access, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
  308. const broad = resolveAccess({ policy: { mode: 'broad', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
  309. expect(checkAccess(broad, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
  310. expect(checkAccess(broad, live('alpha'), 'read').allowed).toBe(true)
  311. })
  312. it('C-07 删除被两个 Profile 使用的技能:从两个 Profile 中移除', () => {
  313. const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin()
  314. const p1 = app.profiles.create({ ...input, name: 'p1', isDefault: false, skills: ['custom-skills:gamma', 'agent-skills:r'] })
  315. const p2 = app.profiles.create({ ...input, name: 'p2', isDefault: false, skills: ['custom-skills:gamma'] })
  316. const gamma = app.customSkills.list().find(s => s.name === 'gamma')!
  317. expect(gamma.profileNames.sort()).toEqual(['p1', 'p2'])
  318. app.customSkills.delete(gamma.id)
  319. expect(app.profiles.get(p1.id).skills).toEqual(['agent-skills:r'])
  320. expect(app.profiles.get(p2.id).skills).toEqual([])
  321. expect(fs.existsSync(live('gamma'))).toBe(false)
  322. })
  323. it('C-08 重启时按数据库重建:被删除或改动的生效目录恢复,多余目录移除', () => {
  324. fs.rmSync(path.join(customSkillsDir(), 'skills', 'alpha'), { recursive: true })
  325. fs.writeFileSync(live('report-kit'), 'tampered')
  326. fs.mkdirSync(path.join(customSkillsDir(), 'skills', 'stray'))
  327. const logs: string[] = []
  328. app.customSkills.reconcile(m => logs.push(m))
  329. expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
  330. expect(fs.existsSync(path.join(customSkillsDir(), 'skills', 'stray'))).toBe(false)
  331. expect(logs.some(l => l.includes('alpha'))).toBe(true)
  332. // 改动文件内容但标记未变:按标记判断不重新发布(运行时目录只能由服务写入)
  333. expect(logs.some(l => l.includes('stray'))).toBe(true)
  334. })
  335. })