custom-skills.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import fs from 'node:fs'
  2. import os from 'node:os'
  3. import path from 'node:path'
  4. import pino from 'pino'
  5. import { strToU8, unzipSync, zipSync } from 'fflate'
  6. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  7. import { eq } from 'drizzle-orm'
  8. import { createApp, type App } from '../src/app'
  9. import { config, customSkillsDir, customSkillStoreDir, skillUploadsDir } from '../src/config'
  10. import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
  11. import { customSkills, skillSources } from '../src/db/schema'
  12. import { resolveSkills } from '../src/providers/claude/skills'
  13. import { skillsToDisable } from '../src/providers/codex/skills'
  14. import { checkAccess, resolveAccess } from '../src/services/access-policy'
  15. let app: App
  16. const audits: string[] = []
  17. const live = (name: string, f = 'SKILL.md') => path.join(customSkillsDir(), 'skills', name, f)
  18. const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`) => `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n`
  19. /** 一个插件格式的技能源目录:skills/alpha、skills/beta(含指向 /etc/passwd 的符号链接) */
  20. function makeSourceDir(): string {
  21. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src-'))
  22. for (const n of ['alpha', 'beta']) {
  23. fs.mkdirSync(path.join(root, 'skills', n, 'references'), { recursive: true })
  24. fs.writeFileSync(path.join(root, 'skills', n, 'SKILL.md'), md(n))
  25. fs.writeFileSync(path.join(root, 'skills', n, 'references', 'guide.md'), `${n} guide v1\nline2\n`)
  26. }
  27. fs.symlinkSync('/etc/passwd', path.join(root, 'skills', 'beta', 'passwd'))
  28. return root
  29. }
  30. beforeAll(() => {
  31. app = createApp(pino({ level: 'silent' }), { providers: {} })
  32. const orig = app.audit.log.bind(app.audit)
  33. app.audit.log = (e: Parameters<typeof orig>[0]) => { audits.push(e.summary); return orig(e) }
  34. })
  35. afterAll(() => app.skillSources.stop())
  36. describe('自定义技能:目录技能源导入与同步', () => {
  37. let srcDir: string
  38. let sourceId: string
  39. it('C-01 扫描出 2 个技能并导入;两个引擎的 ID 为 custom-skills:<name>;符号链接被跳过(C-03)', () => {
  40. srcDir = makeSourceDir()
  41. const s = app.skillSources.create({ name: '业务仓库', description: '', path: srcDir, syncMode: 'manual', intervalMin: 10 })
  42. sourceId = s.id
  43. const scan = app.skillSources.scan(sourceId)
  44. expect(scan.skills.map(x => [x.name, x.status, x.path, x.fileCount])).toEqual([['alpha', 'new', 'skills/alpha', 2], ['beta', 'new', 'skills/beta', 2]])
  45. expect(scan.skipped).toEqual(['skills/beta/passwd(符号链接)'])
  46. const r = app.skillSources.import(sourceId, ['alpha', 'beta'], scan.digest)
  47. expect(r.imported.map(x => [x.name, x.version, x.created])).toEqual([['alpha', 1, true], ['beta', 1, true]])
  48. expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
  49. expect(fs.existsSync(live('beta', 'passwd'))).toBe(false)
  50. // 内置的 team-lead 技能(组长 Profile 使用)随服务创建
  51. expect(app.customSkills.list().filter(x => x.name !== 'team-lead').map(x => [x.name, x.origin, x.publishedVersion, x.sourceName])).toEqual([['alpha', 'imported', 1, '业务仓库'], ['beta', 'imported', 1, '业务仓库']])
  52. expect(app.skillSources.list()[0]!.skillNames).toEqual(['alpha', 'beta'])
  53. expect(app.customSkills.pluginRef()!.skills.filter(s => s.id !== 'custom-skills:team-lead').map(s => [s.id, s.optIn])).toEqual([['custom-skills:alpha', true], ['custom-skills:beta', true]])
  54. })
  55. it('C-02 修改来源中的一个参考文件:同步只显示该文件的变化,导入后生效目录更新', () => {
  56. fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'references', 'guide.md'), 'alpha guide v2\nline2\n')
  57. const scan = app.skillSources.scan(sourceId)
  58. const alpha = scan.skills.find(s => s.name === 'alpha')!
  59. expect(alpha.status).toBe('changed')
  60. expect(alpha.changes).toEqual({ added: [], modified: ['references/guide.md'], removed: [] })
  61. expect(scan.skills.find(s => s.name === 'beta')!.status).toBe('unchanged')
  62. // 扫描后来源又变了:拒绝导入未审阅的内容
  63. fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'), 'x')
  64. expect(() => app.skillSources.import(sourceId, ['alpha'], scan.digest)).toThrow(/重新扫描/)
  65. fs.rmSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'))
  66. const again = app.skillSources.scan(sourceId)
  67. expect(app.skillSources.import(sourceId, ['alpha'], again.digest).imported).toEqual([{ name: 'alpha', version: 2, created: false }])
  68. expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v2\nline2\n')
  69. const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
  70. expect(app.customSkills.diff(id, 1, 2)[0]).toMatchObject({ path: 'references/guide.md', status: 'modified' })
  71. expect(app.customSkills.diff(id, 1, 2)[0]!.patch).toContain('-alpha guide v1')
  72. })
  73. it('来源中删除的技能只提示,不自动删除;同名冲突拒绝;导入的技能只读,转为可编辑后可改', () => {
  74. fs.rmSync(path.join(srcDir, 'skills', 'beta'), { recursive: true })
  75. expect(app.skillSources.scan(sourceId).missing).toEqual(['beta'])
  76. expect(fs.existsSync(live('beta'))).toBe(true)
  77. const other = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src2-'))
  78. fs.mkdirSync(path.join(other, 'alpha'))
  79. fs.writeFileSync(path.join(other, 'alpha', 'SKILL.md'), md('alpha'))
  80. const s2 = app.skillSources.create({ name: '另一个来源', description: '', path: other, syncMode: 'manual', intervalMin: 10 })
  81. expect(app.skillSources.scan(s2.id).skills[0]).toMatchObject({ name: 'alpha', status: 'conflict' })
  82. const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
  83. expect(() => app.customSkills.writeFile(alphaId, 'x.md', Buffer.from('x'))).toThrow(/只读/)
  84. expect(app.customSkills.detach(alphaId).origin).toBe('authored')
  85. })
  86. it('自动同步:到期后导入有变化的技能并记入审计', async () => {
  87. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-auto-'))
  88. fs.writeFileSync(path.join(dir, 'SKILL.md'), md('gamma'))
  89. const s = app.skillSources.create({ name: '自动', description: '', path: dir, syncMode: 'auto', intervalMin: 1 })
  90. await app.skillSources.runAutoSyncNow()
  91. expect(app.customSkills.list().find(x => x.name === 'gamma')).toMatchObject({ publishedVersion: 1, sourceId: s.id })
  92. expect(audits.some(a => a.startsWith('自动同步技能源 自动'))).toBe(true)
  93. })
  94. it('技能源路径校验:不能使用服务数据目录、不存在的 home 路径给出提示', () => {
  95. expect(() => app.skillSources.create({ name: 'x', description: '', path: config.dataDir, syncMode: 'manual', intervalMin: 10 })).toThrow(/服务数据目录/)
  96. expect(() => app.skillSources.create({ name: 'x', description: '', path: '/home/nobody/none', syncMode: 'manual', intervalMin: 10 })).toThrow(/ProtectHome/)
  97. })
  98. })
  99. describe('自定义技能:zip 上传', () => {
  100. it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03);不登记为技能源,导入后删除上传的文件', () => {
  101. const zip = zipSync({
  102. 'pack/delta/SKILL.md': strToU8(md('delta')),
  103. 'pack/delta/templates/report.pptx': new Uint8Array([80, 75, 3, 4, 1, 2, 3]),
  104. '../evil.md': strToU8('x'),
  105. '__MACOSX/pack/._delta': strToU8('x'),
  106. })
  107. const { uploadId, name, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
  108. expect(name).toBe('pack.zip')
  109. expect(scan.skills.map(s => [s.name, s.path, s.fileCount, s.status])).toEqual([['delta', 'pack/delta', 2, 'new']])
  110. expect(scan.skipped).toEqual(['../evil.md(路径不安全)'])
  111. expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true)
  112. expect(app.skillSources.list().map(s => s.kind)).not.toContain('upload')
  113. expect(app.skillSources.importUpload(uploadId, ['delta'], scan.digest).imported).toEqual([{ name: 'delta', version: 1, created: true }])
  114. expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3])
  115. // 从 zip 导入的技能可以编辑,不属于任何技能源
  116. expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
  117. expect(fs.readdirSync(skillUploadsDir())).not.toContain(uploadId)
  118. expect(() => app.skillSources.importUpload(uploadId, ['delta'], scan.digest)).toThrow(/重新上传/)
  119. })
  120. it('再次上传同名技能:显示版本对比,可以覆盖为新版本或保留原版本;属于技能源的同名技能不能覆盖', () => {
  121. const v2 = (body: string) => zipSync({ 'delta/SKILL.md': strToU8(`---\nname: delta\ndescription: delta 的说明:处理某类业务数据的流程与规范\nmetadata:\n author: x\n version: "2.0"\n---\n${body}\n`) })
  122. const keep = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
  123. const item = keep.scan.skills[0]!
  124. expect(item).toMatchObject({ status: 'changed', declaredVersion: '2.0', existing: { version: 1, declaredVersion: null, sourceName: null, unsaved: false } })
  125. expect(item.changes.modified).toEqual(['SKILL.md'])
  126. expect(item.changes.removed).toEqual(['templates/report.pptx'])
  127. // 保留原版本:取消上传,技能不变
  128. app.skillSources.discardUpload(keep.uploadId)
  129. expect(app.customSkills.list().find(s => s.name === 'delta')!.latestVersion).toBe(1)
  130. // 覆盖:作为新版本发布,旧版本仍在历史中
  131. const over = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
  132. expect(app.skillSources.importUpload(over.uploadId, ['delta'], over.scan.digest).imported).toEqual([{ name: 'delta', version: 2, created: false }])
  133. expect(fs.readFileSync(live('delta'), 'utf8')).toContain('# 第二版')
  134. expect(app.customSkills.detail(app.customSkills.list().find(s => s.name === 'delta')!.id).versions.map(v => [v.version, v.reason])).toEqual([[2, 'import'], [1, 'import']])
  135. expect(audits.some(a => a === '导入技能包 delta-v2.zip:delta(覆盖) v2')).toBe(true)
  136. // 相同内容:无需导入
  137. const same = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
  138. expect(same.scan.skills[0]).toMatchObject({ status: 'unchanged', existing: { version: 2, declaredVersion: '2.0' } })
  139. app.skillSources.discardUpload(same.uploadId)
  140. // beta 属于目录技能源「业务仓库」
  141. const beta = app.skillSources.upload('beta.zip', Buffer.from(zipSync({ 'beta/SKILL.md': strToU8(md('beta')) })))
  142. expect(beta.scan.skills[0]).toMatchObject({ status: 'conflict', existing: { sourceName: '业务仓库' } })
  143. expect(beta.scan.skills[0]!.reason).toMatch(/技能源「业务仓库」/)
  144. app.skillSources.discardUpload(beta.uploadId)
  145. })
  146. it('早期登记为技能源的 zip 上传:启动时删除记录,从中导入的技能转为可编辑', () => {
  147. const now = new Date().toISOString()
  148. app.db.insert(skillSources).values({ id: 'src_legacy', name: 'old.zip', description: '', kind: 'upload', path: path.join(skillUploadsDir(), 'src_legacy'), syncMode: 'manual' }).run()
  149. app.db.update(customSkills).set({ origin: 'imported', sourceId: 'src_legacy', updatedAt: now }).where(eq(customSkills.name, 'delta')).run()
  150. app.skillSources.start()
  151. app.skillSources.stop()
  152. expect(app.db.select().from(skillSources).where(eq(skillSources.id, 'src_legacy')).get()).toBeUndefined()
  153. expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
  154. })
  155. it('选择目录:只列出子目录,标出含 SKILL.md 的目录与找到的技能数', () => {
  156. const root = makeSourceDir()
  157. fs.writeFileSync(path.join(root, 'readme.md'), 'x')
  158. const top = app.skillSources.listDirs(root)
  159. expect(top.dirs).toEqual([{ name: 'skills', skill: false }])
  160. expect(top.skillCount).toBe(2)
  161. expect(top.parent).toBe(path.dirname(fs.realpathSync(root)))
  162. expect(app.skillSources.listDirs(path.join(root, 'skills')).dirs).toEqual([{ name: 'alpha', skill: true }, { name: 'beta', skill: true }])
  163. expect(app.skillSources.listDirs('/').parent).toBeNull()
  164. expect(app.skillSources.listDirs('~').path).toBe(fs.realpathSync(os.homedir()))
  165. expect(() => app.skillSources.listDirs('relative/path')).toThrow(/绝对路径/)
  166. expect(() => app.skillSources.listDirs(path.join(root, 'none'))).toThrow(/不存在/)
  167. })
  168. it('按实际解压字节数限制大小(防压缩炸弹)', () => {
  169. const saved = LIMITS.unzipBytes
  170. LIMITS.unzipBytes = 1048576
  171. try {
  172. const bomb = zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 })
  173. expect(bomb.length).toBeLessThan(100_000)
  174. expect(() => app.skillSources.upload('bomb.zip', Buffer.from(bomb))).toThrow(/解压后超过/)
  175. } finally {
  176. LIMITS.unzipBytes = saved
  177. }
  178. expect(() => app.skillSources.upload('bad.zip', Buffer.from('not a zip'))).toThrow()
  179. })
  180. })
  181. describe('自定义技能:在线编写、草稿与发布、版本', () => {
  182. let id: string
  183. it('C-04 新建 → 上传模版并保存为草稿:不生效;发布后生效;恢复到上一版本', () => {
  184. const d = app.customSkills.create({ name: 'report-kit', description: '生成月度报告的规范与模版,涉及月报时使用' })
  185. id = d.id
  186. expect(d.versions.map(v => [v.version, v.reason])).toEqual([[1, 'create']])
  187. expect(d.publishedVersion).toBeNull()
  188. expect(fs.existsSync(live('report-kit'))).toBe(false)
  189. app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([1, 2, 3]))
  190. expect(app.customSkills.detail(id).hasUnsavedChanges).toBe(true)
  191. const v2 = app.customSkills.save(id, { baseVersion: 1, publish: false })
  192. expect([v2.latestVersion, v2.publishedVersion, v2.hasUnsavedChanges]).toEqual([2, null, false])
  193. expect(fs.existsSync(live('report-kit'))).toBe(false)
  194. // 内容无变化不产生新版本;基于旧版本保存时冲突
  195. expect(app.customSkills.save(id, { baseVersion: 2, publish: false }).latestVersion).toBe(2)
  196. expect(() => app.customSkills.save(id, { baseVersion: 1, publish: false })).toThrow(/刷新/)
  197. expect(app.customSkills.publish(id, 2).publishedVersion).toBe(2)
  198. expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
  199. app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([9]))
  200. app.customSkills.writeFile(id, 'SKILL.md', Buffer.from(md('report-kit', '新版描述:生成月度报告的规范与模版,涉及月报时使用')))
  201. const v3 = app.customSkills.save(id, { baseVersion: 2, publish: true })
  202. expect([v3.latestVersion, v3.publishedVersion, v3.description]).toEqual([3, 3, '新版描述:生成月度报告的规范与模版,涉及月报时使用'])
  203. expect(v3.versions[0]!.changed).toEqual({ added: [], modified: ['SKILL.md', 'templates/a.pptx'], removed: [] })
  204. const v4 = app.customSkills.restore(id, 2)
  205. expect([v4.latestVersion, v4.publishedVersion, v4.versions[0]!.reason]).toEqual([4, 4, 'restore'])
  206. expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
  207. })
  208. it('SKILL.md 校验、路径安全、重命名与删除、放弃改动、导出 zip', () => {
  209. app.customSkills.writeFile(id, 'SKILL.md', Buffer.from('---\nname: other\ndescription: x\n---\n'))
  210. expect(() => app.customSkills.save(id, { baseVersion: 4, publish: false })).toThrow(/不一致/)
  211. app.customSkills.discard(id)
  212. expect(() => app.customSkills.writeFile(id, '../escape.md', Buffer.from('x'))).toThrow(/路径不正确/)
  213. expect(() => normalizeRelPath('/abs')).toThrow()
  214. expect(() => app.customSkills.deleteFile(id, 'SKILL.md')).toThrow(/不能删除/)
  215. app.customSkills.writeFile(id, 'refs/a.md', Buffer.from('a'))
  216. expect(app.customSkills.move(id, 'refs', 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'references/a.md', 'templates/a.pptx'])
  217. expect(app.customSkills.deleteFile(id, 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'templates/a.pptx'])
  218. const z = app.customSkills.exportZip(id)
  219. expect(z.name).toBe('report-kit-v4.zip')
  220. expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['report-kit/SKILL.md', 'report-kit/templates/a.pptx'])
  221. })
  222. })
  223. describe('自定义技能:使用、沙箱与恢复', () => {
  224. it('C-05 "全部技能"不包含自定义技能(可逐个设置包含);单独勾选后加载', () => {
  225. const plugins = [
  226. { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
  227. app.customSkills.pluginRef()!,
  228. ]
  229. expect(resolveSkills('all', plugins).skills).toEqual(['agent-skills:r'])
  230. expect(resolveSkills(['custom-skills:report-kit'], plugins).skills).toEqual(['custom-skills:report-kit'])
  231. const id = app.customSkills.list().find(s => s.name === 'report-kit')!.id
  232. app.customSkills.setFlags(id, { includeInAll: true })
  233. expect(resolveSkills('all', [plugins[0]!, app.customSkills.pluginRef()!]).skills).toContain('custom-skills:report-kit')
  234. expect(app.customSkills.excludedFromAll()).not.toContain('custom-skills:report-kit')
  235. // Codex:all 时禁用需要单独勾选的自定义技能
  236. const all = [
  237. { name: 'custom-skills:alpha', path: path.join(customSkillsDir(), 'skills/alpha/SKILL.md') },
  238. { name: 'custom-skills:Report Kit', path: path.join(customSkillsDir(), 'skills/report-kit/SKILL.md') },
  239. ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
  240. expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills', undefined, app.customSkills.excludedFromAll()).map(d => path.basename(path.dirname(d.path)))).toEqual(['alpha'])
  241. })
  242. it('停用后不加载,启用后恢复;指纹随之变化', () => {
  243. const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
  244. const before = app.customSkills.fingerprint()
  245. app.customSkills.setFlags(id, { enabled: false })
  246. expect(fs.existsSync(live('alpha'))).toBe(false)
  247. expect(app.customSkills.skillInfos().map(s => s.name)).not.toContain('alpha')
  248. expect(app.customSkills.fingerprint()).not.toBe(before)
  249. app.customSkills.setFlags(id, { enabled: true })
  250. expect(fs.existsSync(live('alpha'))).toBe(true)
  251. })
  252. it('C-06 沙箱:生效目录只读可见,不能写;版本库不可读', () => {
  253. const settings = app.settings.get()
  254. const ws = path.join(config.dataDir, 'workspaces', 'ses_cs')
  255. fs.mkdirSync(ws, { recursive: true })
  256. const access = resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
  257. expect(checkAccess(access, live('alpha'), 'read').allowed).toBe(true)
  258. expect(checkAccess(access, live('alpha'), 'write').allowed).toBe(false)
  259. expect(checkAccess(access, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
  260. const broad = resolveAccess({ policy: { mode: 'broad', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
  261. expect(checkAccess(broad, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
  262. expect(checkAccess(broad, live('alpha'), 'read').allowed).toBe(true)
  263. })
  264. it('C-07 删除被两个 Profile 使用的技能:从两个 Profile 中移除', () => {
  265. const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin()
  266. const p1 = app.profiles.create({ ...input, name: 'p1', isDefault: false, skills: ['custom-skills:gamma', 'agent-skills:r'] })
  267. const p2 = app.profiles.create({ ...input, name: 'p2', isDefault: false, skills: ['custom-skills:gamma'] })
  268. const gamma = app.customSkills.list().find(s => s.name === 'gamma')!
  269. expect(gamma.profileNames.sort()).toEqual(['p1', 'p2'])
  270. app.customSkills.delete(gamma.id)
  271. expect(app.profiles.get(p1.id).skills).toEqual(['agent-skills:r'])
  272. expect(app.profiles.get(p2.id).skills).toEqual([])
  273. expect(fs.existsSync(live('gamma'))).toBe(false)
  274. })
  275. it('C-08 重启时按数据库重建:被删除或改动的生效目录恢复,多余目录移除', () => {
  276. fs.rmSync(path.join(customSkillsDir(), 'skills', 'alpha'), { recursive: true })
  277. fs.writeFileSync(live('report-kit'), 'tampered')
  278. fs.mkdirSync(path.join(customSkillsDir(), 'skills', 'stray'))
  279. const logs: string[] = []
  280. app.customSkills.reconcile(m => logs.push(m))
  281. expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
  282. expect(fs.existsSync(path.join(customSkillsDir(), 'skills', 'stray'))).toBe(false)
  283. expect(logs.some(l => l.includes('alpha'))).toBe(true)
  284. // 改动文件内容但标记未变:按标记判断不重新发布(运行时目录只能由服务写入)
  285. expect(logs.some(l => l.includes('stray'))).toBe(true)
  286. })
  287. })