codex-skills.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { execFileSync } from 'node:child_process'
  2. import fs from 'node:fs'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { describe, expect, it } from 'vitest'
  6. import { skillDisplayId, skillsToDisable } from '../src/providers/codex/skills'
  7. import { CodexSkillService } from '../src/services/codex-skills'
  8. const SHA1 = 'a'.repeat(40)
  9. const SHA2 = 'b'.repeat(40)
  10. /** 构造一个与 GitHub codeload 格式相同的仓库快照(顶层目录 skills-<sha>/) */
  11. function tarball(sha: string, skills: Record<string, string>): Buffer {
  12. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-'))
  13. const top = path.join(root, `skills-${sha}`)
  14. for (const [name, license] of Object.entries(skills)) {
  15. const d = path.join(top, 'skills/.curated', name)
  16. fs.mkdirSync(d, { recursive: true })
  17. fs.writeFileSync(path.join(d, 'SKILL.md'), `---\nname: "${name}"\ndescription: "${name} skill"\n---\nbody\n`)
  18. fs.writeFileSync(path.join(d, 'LICENSE.txt'), license)
  19. }
  20. fs.mkdirSync(path.join(top, 'skills/.system/imagegen'), { recursive: true })
  21. fs.writeFileSync(path.join(top, 'README.md'), 'x')
  22. const out = path.join(root, 'repo.tar.gz')
  23. execFileSync('tar', ['-czf', out, '-C', root, `skills-${sha}`])
  24. return fs.readFileSync(out)
  25. }
  26. describe('CodexSkillService(openai/skills)', () => {
  27. const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cxskills-'))
  28. const roots = { catalog: path.join(base, 'catalog'), skills: path.join(base, 'codex-home/skills') }
  29. let sha = SHA1
  30. const repos: Record<string, Buffer> = {
  31. [SHA1]: tarball(SHA1, { pdf: 'Apache License\nVersion 2.0', secret: 'PROPRIETARY AND CONFIDENTIAL. All rights reserved.', figma: 'Use of these Figma skills is governed by the Figma Developer Terms' }),
  32. [SHA2]: tarball(SHA2, { pdf: 'Apache License\nVersion 2.0', screenshot: 'MIT License\nPermission is hereby granted, free of charge' }),
  33. }
  34. const audits: string[] = []
  35. const fetcher = async (url: string) => {
  36. if (url.endsWith('/commits/main')) return new Response(JSON.stringify({ sha }))
  37. const s = url.split('/').pop()!
  38. return repos[s] ? new Response(repos[s]) : new Response('nf', { status: 404 })
  39. }
  40. const svc = new CodexSkillService(s => audits.push(s), fetcher, roots)
  41. it('获取目录、识别许可证;安装后写入标记,专有许可拒绝安装', async () => {
  42. expect(svc.catalog().sha).toBeNull()
  43. const cat = await svc.refresh()
  44. expect(cat.sha).toBe(SHA1)
  45. expect(cat.skills.map(s => [s.name, s.license, s.licenseName])).toEqual([['figma', 'restricted', '第三方条款'], ['pdf', 'open', 'Apache-2.0'], ['secret', 'restricted', '专有许可']])
  46. expect(() => svc.install('secret')).toThrow(/专有许可/)
  47. expect(() => svc.install('figma')).toThrow(/第三方条款/)
  48. const after = svc.install('pdf')
  49. expect(after.skills.find(s => s.name === 'pdf')).toMatchObject({ installed: true, installedSha: SHA1, updatable: false })
  50. expect(fs.existsSync(path.join(roots.skills, 'pdf/SKILL.md'))).toBe(true)
  51. const marker = JSON.parse(fs.readFileSync(path.join(roots.skills, 'pdf/.agent-server.json'), 'utf8'))
  52. expect(marker).toMatchObject({ source: 'openai/skills', sha: SHA1, license: 'Apache-2.0' })
  53. expect(svc.installedNames()).toEqual(['pdf'])
  54. })
  55. it('目录更新后可以升级已安装技能;只能卸载自己安装的技能', async () => {
  56. sha = SHA2
  57. const cat = await svc.refresh()
  58. expect(cat.skills.find(s => s.name === 'pdf')).toMatchObject({ installed: true, updatable: true })
  59. expect(svc.install('pdf').skills.find(s => s.name === 'pdf')).toMatchObject({ installedSha: SHA2, updatable: false })
  60. fs.mkdirSync(path.join(roots.skills, 'mine'), { recursive: true })
  61. fs.writeFileSync(path.join(roots.skills, 'mine/SKILL.md'), '---\nname: mine\n---\n')
  62. expect(svc.catalog().unmanaged).toEqual(['mine'])
  63. expect(() => svc.uninstall('mine')).toThrow()
  64. expect(() => svc.install('mine')).toThrow()
  65. svc.uninstall('pdf')
  66. expect(fs.existsSync(path.join(roots.skills, 'pdf'))).toBe(false)
  67. expect(audits.some(a => a.includes('安装 Codex 官方技能 pdf'))).toBe(true)
  68. })
  69. it('拒绝不安全的名称', () => {
  70. expect(() => svc.install('../etc')).toThrow(/格式不正确/)
  71. })
  72. })
  73. describe('Codex 技能 ID 与禁用规则', () => {
  74. const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cxhome-'))
  75. fs.mkdirSync(path.join(home, 'skills/pdf'), { recursive: true })
  76. fs.writeFileSync(path.join(home, 'skills/pdf/.agent-server.json'), '{}')
  77. fs.mkdirSync(path.join(home, 'skills/mine'), { recursive: true })
  78. const all = [
  79. { name: 'imagegen', scope: 'system' as const, path: '/sys/imagegen/SKILL.md' },
  80. { name: 'skill-installer', scope: 'system' as const, path: '/sys/skill-installer/SKILL.md' },
  81. { name: 'pdf', scope: 'user' as const, path: path.join(home, 'skills/pdf/SKILL.md') },
  82. { name: 'mine', scope: 'user' as const, path: path.join(home, 'skills/mine/SKILL.md') },
  83. { name: 'agent-skills:report-conventions', scope: 'user' as const, path: '/srv/skills/report-conventions/SKILL.md' },
  84. ].map(s => ({ ...s, description: '', enabled: true, pluginId: null }))
  85. it('展示 ID', () => {
  86. expect(all.map(s => skillDisplayId(s, home))).toEqual([
  87. 'codex-system:imagegen', 'codex-system:skill-installer', 'openai-skills:pdf', 'codex-user:mine', 'agent-skills:report-conventions',
  88. ])
  89. })
  90. it('all 只禁用始终禁用的技能;列表模式禁用未选中的技能', () => {
  91. expect(skillsToDisable(all, 'all', home, 'agent-skills').map(d => d.path)).toEqual(['/sys/skill-installer/SKILL.md'])
  92. const off = skillsToDisable(all, ['openai-skills:pdf', 'report-conventions'], home, 'agent-skills').map(d => path.basename(path.dirname(d.path)))
  93. expect(off).toEqual(['imagegen', 'skill-installer', 'mine'])
  94. })
  95. })