|
|
@@ -1,201 +1,216 @@
|
|
|
import fs from 'node:fs'
|
|
|
-import os from 'node:os'
|
|
|
import path from 'node:path'
|
|
|
import pino from 'pino'
|
|
|
import { strToU8, unzipSync, zipSync } from 'fflate'
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
-import { eq } from 'drizzle-orm'
|
|
|
import { createApp, type App } from '../src/app'
|
|
|
-import { config, customSkillsDir, customSkillStoreDir, skillUploadsDir } from '../src/config'
|
|
|
+import { config, customSkillsDir, customSkillStoreDir } from '../src/config'
|
|
|
+import { REGISTRY_LIMITS, renameSkillMd } from '../src/custom-skills/registry'
|
|
|
import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
|
|
|
-import { customSkills, skillSources } from '../src/db/schema'
|
|
|
+import { buildServer } from '../src/http/server'
|
|
|
import { resolveSkills } from '../src/providers/claude/skills'
|
|
|
import { skillsToDisable } from '../src/providers/codex/skills'
|
|
|
import { checkAccess, resolveAccess } from '../src/services/access-policy'
|
|
|
+import type { RegistrySubmissionDto } from '@agent-server/shared'
|
|
|
|
|
|
let app: App
|
|
|
+let server: Awaited<ReturnType<typeof buildServer>>
|
|
|
+let base: string
|
|
|
const audits: string[] = []
|
|
|
const live = (name: string, f = 'SKILL.md') => path.join(customSkillsDir(), 'skills', name, f)
|
|
|
-const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`) => `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n`
|
|
|
-
|
|
|
-/** 一个插件格式的技能源目录:skills/alpha、skills/beta(含指向 /etc/passwd 的符号链接) */
|
|
|
-function makeSourceDir(): string {
|
|
|
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src-'))
|
|
|
- for (const n of ['alpha', 'beta']) {
|
|
|
- fs.mkdirSync(path.join(root, 'skills', n, 'references'), { recursive: true })
|
|
|
- fs.writeFileSync(path.join(root, 'skills', n, 'SKILL.md'), md(n))
|
|
|
- fs.writeFileSync(path.join(root, 'skills', n, 'references', 'guide.md'), `${n} guide v1\nline2\n`)
|
|
|
- }
|
|
|
- fs.symlinkSync('/etc/passwd', path.join(root, 'skills', 'beta', 'passwd'))
|
|
|
- return root
|
|
|
+const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`, extra = '') => `---\nname: ${name}\ndescription: ${desc}\n${extra}---\n# ${name}\n`
|
|
|
+const zip = (files: Record<string, string>) => Buffer.from(zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)]))))
|
|
|
+
|
|
|
+/** 用发布接口推送 */
|
|
|
+async function push(key: string, pkg: Buffer, opts: { message?: string; meta?: object; dryRun?: boolean } = {}) {
|
|
|
+ const form = new FormData()
|
|
|
+ form.append('file', new Blob([new Uint8Array(pkg)]), 'skills.zip')
|
|
|
+ form.append('message', opts.message ?? '')
|
|
|
+ if (opts.meta) form.append('meta', JSON.stringify(opts.meta))
|
|
|
+ const res = await fetch(`${base}/skills/registry/v1/submissions${opts.dryRun ? '?dryRun=1' : ''}`, { method: 'POST', headers: key ? { Authorization: `Bearer ${key}` } : {}, body: form })
|
|
|
+ return { status: res.status, body: await res.json() as RegistrySubmissionDto & { error?: { message: string } } }
|
|
|
}
|
|
|
+const keyRow = (key: string) => app.skillRegistry.verify(key, '127.0.0.1')!
|
|
|
+const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)!
|
|
|
|
|
|
-beforeAll(() => {
|
|
|
- app = createApp(pino({ level: 'silent' }), { providers: {} })
|
|
|
+beforeAll(async () => {
|
|
|
+ const log = pino({ level: 'silent' })
|
|
|
+ app = createApp(log, { providers: {} })
|
|
|
const orig = app.audit.log.bind(app.audit)
|
|
|
app.audit.log = (e: Parameters<typeof orig>[0]) => { audits.push(e.summary); return orig(e) }
|
|
|
+ server = await buildServer(app, { logger: log })
|
|
|
+ base = await server.listen({ port: 0, host: '127.0.0.1' })
|
|
|
})
|
|
|
-afterAll(() => app.skillSources.stop())
|
|
|
-
|
|
|
-describe('自定义技能:目录技能源导入与同步', () => {
|
|
|
- let srcDir: string
|
|
|
- let sourceId: string
|
|
|
-
|
|
|
- it('C-01 扫描出 2 个技能并导入;两个引擎的 ID 为 custom-skills:<name>;符号链接被跳过(C-03)', () => {
|
|
|
- srcDir = makeSourceDir()
|
|
|
- const s = app.skillSources.create({ name: '业务仓库', description: '', path: srcDir, syncMode: 'manual', intervalMin: 10 })
|
|
|
- sourceId = s.id
|
|
|
- const scan = app.skillSources.scan(sourceId)
|
|
|
- expect(scan.skills.map(x => [x.name, x.status, x.path, x.fileCount])).toEqual([['alpha', 'new', 'skills/alpha', 2], ['beta', 'new', 'skills/beta', 2]])
|
|
|
- expect(scan.skipped).toEqual(['skills/beta/passwd(符号链接)'])
|
|
|
-
|
|
|
- const r = app.skillSources.import(sourceId, ['alpha', 'beta'], scan.digest)
|
|
|
- expect(r.imported.map(x => [x.name, x.version, x.created])).toEqual([['alpha', 1, true], ['beta', 1, true]])
|
|
|
- expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
|
|
|
- expect(fs.existsSync(live('beta', 'passwd'))).toBe(false)
|
|
|
- // 内置的 team-lead 技能(组长 Profile 使用)随服务创建
|
|
|
- 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, '业务仓库']])
|
|
|
- expect(app.skillSources.list()[0]!.skillNames).toEqual(['alpha', 'beta'])
|
|
|
- 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]])
|
|
|
+afterAll(async () => {
|
|
|
+ await server.close()
|
|
|
+})
|
|
|
+
|
|
|
+describe('技能发布平台:推送与审核(docs/design/18-skill-registry.md)', () => {
|
|
|
+ let key: string
|
|
|
+ let subId: string
|
|
|
+
|
|
|
+ it('C-01 推送:只接受有效的发布密钥;试运行不保存;包中的技能逐个待审核,附自动检查', async () => {
|
|
|
+ key = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 }).key
|
|
|
+ expect(key).toMatch(/^sk_pub_/)
|
|
|
+ const pkg = zip({
|
|
|
+ 'skills/alpha/SKILL.md': md('alpha'),
|
|
|
+ 'skills/alpha/references/guide.md': 'alpha guide v1\nline2\n',
|
|
|
+ 'skills/beta/SKILL.md': md('beta'),
|
|
|
+ 'skills/beta/scripts/run.sh': 'curl https://api.example.com/v1/x\npassword = abcdefghijklmnop1234\n',
|
|
|
+ '../evil.md': 'x',
|
|
|
+ })
|
|
|
+ expect((await push('', pkg)).status).toBe(401)
|
|
|
+ expect((await push('sk_pub_nope', pkg)).status).toBe(401)
|
|
|
+ expect((await push('ak_wrong_kind', pkg)).status).toBe(401)
|
|
|
+
|
|
|
+ const dry = await push(key, pkg, { dryRun: true })
|
|
|
+ expect(dry.status).toBe(200)
|
|
|
+ expect([dry.body.id, dry.body.dryRun, dry.body.items.map(i => [i.name, i.status])]).toEqual(['', true, [['alpha', 'pending'], ['beta', 'pending']]])
|
|
|
+ expect(app.skillRegistry.pendingCount()).toBe(0)
|
|
|
+
|
|
|
+ const r = await push(key, pkg, { message: '新增 alpha 与 beta', meta: { repo: 'fwh-app', commit: 'abc123' } })
|
|
|
+ expect(r.status).toBe(201)
|
|
|
+ subId = r.body.id
|
|
|
+ expect(r.body.skipped).toEqual(['../evil.md(路径不安全)'])
|
|
|
+ expect(r.body.items.map(i => [i.name, i.status, i.existing])).toEqual([['alpha', 'pending', null], ['beta', 'pending', null]])
|
|
|
+ const beta = r.body.items[1]!
|
|
|
+ expect(beta.checks.map(c => [c.code, c.level])).toEqual([['script', 'warning'], ['secret', 'danger'], ['url', 'info']])
|
|
|
+ expect(beta.checks[0]!.files).toEqual(['scripts/run.sh'])
|
|
|
+ expect(beta.checks[2]!.message).toContain('api.example.com')
|
|
|
+
|
|
|
+ // 推送不改变任何技能;后台待审核列表中有两项
|
|
|
+ expect(fs.existsSync(live('alpha'))).toBe(false)
|
|
|
+ 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']])
|
|
|
+ expect(audits.some(a => a.startsWith('推送技能包(fwh-app · CI') && a.includes('alpha(待审核)'))).toBe(true)
|
|
|
+
|
|
|
+ // 推送方只能看到自己的提交
|
|
|
+ const other = app.skillRegistry.createKey({ name: '另一个项目', description: '', expiresInDays: null }).key
|
|
|
+ const get = (k: string) => fetch(`${base}/skills/registry/v1/submissions/${subId}`, { headers: { Authorization: `Bearer ${k}` } })
|
|
|
+ expect((await get(key)).status).toBe(200)
|
|
|
+ expect((await get(other)).status).toBe(404)
|
|
|
+ // 停用的密钥不能使用
|
|
|
+ const otherId = app.skillRegistry.listKeys().find(k => k.name === '另一个项目')!.id
|
|
|
+ app.skillRegistry.updateKey(otherId, { enabled: false })
|
|
|
+ expect((await push(other, pkg)).status).toBe(401)
|
|
|
+ expect(app.skillRegistry.listKeys().find(k => k.name === 'fwh-app · CI')).toMatchObject({ submissions: 1, pending: 2, lastUsedIp: '127.0.0.1' })
|
|
|
})
|
|
|
|
|
|
- it('C-02 修改来源中的一个参考文件:同步只显示该文件的变化,导入后生效目录更新', () => {
|
|
|
- fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'references', 'guide.md'), 'alpha guide v2\nline2\n')
|
|
|
- const scan = app.skillSources.scan(sourceId)
|
|
|
- const alpha = scan.skills.find(s => s.name === 'alpha')!
|
|
|
- expect(alpha.status).toBe('changed')
|
|
|
- expect(alpha.changes).toEqual({ added: [], modified: ['references/guide.md'], removed: [] })
|
|
|
- expect(scan.skills.find(s => s.name === 'beta')!.status).toBe('unchanged')
|
|
|
- // 扫描后来源又变了:拒绝导入未审阅的内容
|
|
|
- fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'), 'x')
|
|
|
- expect(() => app.skillSources.import(sourceId, ['alpha'], scan.digest)).toThrow(/重新扫描/)
|
|
|
- fs.rmSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'))
|
|
|
- const again = app.skillSources.scan(sourceId)
|
|
|
- expect(app.skillSources.import(sourceId, ['alpha'], again.digest).imported).toEqual([{ name: 'alpha', version: 2, created: false }])
|
|
|
- expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v2\nline2\n')
|
|
|
- const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
|
|
|
- expect(app.customSkills.diff(id, 1, 2)[0]).toMatchObject({ path: 'references/guide.md', status: 'modified' })
|
|
|
- expect(app.customSkills.diff(id, 1, 2)[0]!.patch).toContain('-alpha guide v1')
|
|
|
+ it('C-02 批准为新技能(可以改名,SKILL.md 中的 name 随之改写);拒绝须填写原因,推送方可以看到', async () => {
|
|
|
+ const alpha = pending('alpha')
|
|
|
+ expect(app.skillRegistry.diff(alpha.id, {}).files.map(f => [f.path, f.status])).toEqual([['references/guide.md', 'added'], ['SKILL.md', 'added']])
|
|
|
+ const r = app.skillRegistry.approve(alpha.id, { action: 'create', name: 'alpha', publish: true })
|
|
|
+ expect(r.result).toMatchObject({ action: 'create', skillName: 'alpha', version: 1, published: true })
|
|
|
+ expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
|
|
|
+ const a = app.customSkills.detail(r.result!.skillId)
|
|
|
+ expect(a.versions[0]).toMatchObject({ version: 1, reason: 'submission', note: { submissionId: subId, keyName: 'fwh-app · CI', message: '新增 alpha 与 beta' } })
|
|
|
+ // 推送来的技能可以直接编辑
|
|
|
+ app.customSkills.writeFile(a.id, 'notes.md', Buffer.from('x'))
|
|
|
+ app.customSkills.discard(a.id)
|
|
|
+
|
|
|
+ const beta = pending('beta')
|
|
|
+ const d = app.skillRegistry.diff(beta.id, { name: 'gamma' })
|
|
|
+ expect(d.renamed).toBe(true)
|
|
|
+ expect(d.files.find(f => f.path === 'SKILL.md')!.patch).toContain('+name: gamma')
|
|
|
+ expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'alpha', publish: true })).toThrow(/已被使用/)
|
|
|
+ expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'Bad Name', publish: true })).toThrow(/小写字母/)
|
|
|
+ app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma', publish: true })
|
|
|
+ expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
|
|
|
+ expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma2', publish: true })).toThrow(/已经处理/)
|
|
|
+
|
|
|
+ const s = app.skillRegistry.getSubmission(keyRow(key).id, subId)
|
|
|
+ expect(s.status).toBe('done')
|
|
|
+ expect(s.items.map(i => [i.name, i.status, i.result?.skill, i.result?.version])).toEqual([['alpha', 'approved', 'alpha', 1], ['beta', 'approved', 'gamma', 1]])
|
|
|
+
|
|
|
+ const r2 = await push(key, zip({ 'delta/SKILL.md': md('delta') }))
|
|
|
+ const delta = pending('delta')
|
|
|
+ expect(() => app.skillRegistry.reject(delta.id, ' ')).toThrow(/原因/)
|
|
|
+ app.skillRegistry.reject(delta.id, '描述不清楚')
|
|
|
+ expect(app.skillRegistry.getSubmission(keyRow(key).id, r2.body.id).items[0]).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
|
|
|
+ expect(app.skillRegistry.listItems('done').find(i => i.name === 'delta')).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
|
|
|
})
|
|
|
|
|
|
- it('来源中删除的技能只提示,不自动删除;同名冲突拒绝;导入的技能只读,转为可编辑后可改', () => {
|
|
|
- fs.rmSync(path.join(srcDir, 'skills', 'beta'), { recursive: true })
|
|
|
- expect(app.skillSources.scan(sourceId).missing).toEqual(['beta'])
|
|
|
- expect(fs.existsSync(live('beta'))).toBe(true)
|
|
|
+ it('C-03 再次推送:相同内容为无变化;同一密钥再推送替代旧的待审核;覆盖时以审核时看到的版本为准', async () => {
|
|
|
+ const same = await push(key, zip({ 'alpha/SKILL.md': md('alpha'), 'alpha/references/guide.md': 'alpha guide v1\nline2\n' }))
|
|
|
+ expect(same.body.items[0]).toMatchObject({ name: 'alpha', status: 'unchanged', existing: { version: 1 } })
|
|
|
|
|
|
- const other = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src2-'))
|
|
|
- fs.mkdirSync(path.join(other, 'alpha'))
|
|
|
- fs.writeFileSync(path.join(other, 'alpha', 'SKILL.md'), md('alpha'))
|
|
|
- const s2 = app.skillSources.create({ name: '另一个来源', description: '', path: other, syncMode: 'manual', intervalMin: 10 })
|
|
|
- expect(app.skillSources.scan(s2.id).skills[0]).toMatchObject({ name: 'alpha', status: 'conflict' })
|
|
|
+ const v2 = (body: string, ver: string) => zip({ 'alpha/SKILL.md': md('alpha', undefined, `metadata:\n version: "${ver}"\n`), 'alpha/references/guide.md': body })
|
|
|
+ const first = await push(key, v2('alpha guide v2\n', '2.0'))
|
|
|
+ const second = await push(key, v2('alpha guide v3\n', '2.0'))
|
|
|
+ expect(app.skillRegistry.getSubmission(keyRow(key).id, first.body.id).items[0]!.status).toBe('superseded')
|
|
|
+ const item = pending('alpha')
|
|
|
+ expect(item.submissionId).toBe(second.body.id)
|
|
|
+ expect(item.existing).toMatchObject({ version: 1 })
|
|
|
+ expect(second.body.items[0]!.changes).toEqual({ added: [], modified: ['SKILL.md', 'references/guide.md'], removed: [] })
|
|
|
+ expect(item.checks.find(c => c.code === 'version')!.message).toBe('声明版本号:未声明 → 2.0')
|
|
|
|
|
|
const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
|
|
|
- expect(() => app.customSkills.writeFile(alphaId, 'x.md', Buffer.from('x'))).toThrow(/只读/)
|
|
|
- expect(app.customSkills.detach(alphaId).origin).toBe('authored')
|
|
|
- })
|
|
|
-
|
|
|
- it('自动同步:到期后导入有变化的技能并记入审计', async () => {
|
|
|
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-auto-'))
|
|
|
- fs.writeFileSync(path.join(dir, 'SKILL.md'), md('gamma'))
|
|
|
- const s = app.skillSources.create({ name: '自动', description: '', path: dir, syncMode: 'auto', intervalMin: 1 })
|
|
|
- await app.skillSources.runAutoSyncNow()
|
|
|
- expect(app.customSkills.list().find(x => x.name === 'gamma')).toMatchObject({ publishedVersion: 1, sourceId: s.id })
|
|
|
- expect(audits.some(a => a.startsWith('自动同步技能源 自动'))).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- it('技能源路径校验:不能使用服务数据目录、不存在的 home 路径给出提示', () => {
|
|
|
- expect(() => app.skillSources.create({ name: 'x', description: '', path: config.dataDir, syncMode: 'manual', intervalMin: 10 })).toThrow(/服务数据目录/)
|
|
|
- expect(() => app.skillSources.create({ name: 'x', description: '', path: '/home/nobody/none', syncMode: 'manual', intervalMin: 10 })).toThrow(/ProtectHome/)
|
|
|
+ const d = app.skillRegistry.diff(item.id, { targetId: alphaId })
|
|
|
+ expect(d.target).toMatchObject({ name: 'alpha', version: 1, unsaved: false })
|
|
|
+ expect(d.files.find(f => f.path === 'references/guide.md')!.patch).toContain('+alpha guide v3')
|
|
|
+ // 审核期间技能有了新版本:拒绝覆盖
|
|
|
+ expect(() => app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 0, publish: true })).toThrow(/刷新/)
|
|
|
+ const r = app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 1, publish: false })
|
|
|
+ expect(r.result).toMatchObject({ action: 'overwrite', skillName: 'alpha', version: 2, published: false })
|
|
|
+ expect(app.customSkills.detail(alphaId)).toMatchObject({ latestVersion: 2, publishedVersion: 1 })
|
|
|
+ expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
|
|
|
+ app.customSkills.publish(alphaId, 2)
|
|
|
+ expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
|
|
|
+
|
|
|
+ // 覆盖到不同名的技能:SKILL.md 的 name 改写为目标技能名
|
|
|
+ await push(key, zip({ 'alpha-next/SKILL.md': md('alpha-next') }))
|
|
|
+ const next = pending('alpha-next')
|
|
|
+ const gammaId = app.customSkills.list().find(s => s.name === 'gamma')!.id
|
|
|
+ app.skillRegistry.approve(next.id, { action: 'overwrite', targetId: gammaId, expectedVersion: 1, publish: true })
|
|
|
+ expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
|
|
|
+ expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('# alpha-next')
|
|
|
})
|
|
|
-})
|
|
|
|
|
|
-describe('自定义技能:zip 上传', () => {
|
|
|
- it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03);不登记为技能源,导入后删除上传的文件', () => {
|
|
|
- const zip = zipSync({
|
|
|
- 'pack/delta/SKILL.md': strToU8(md('delta')),
|
|
|
- 'pack/delta/templates/report.pptx': new Uint8Array([80, 75, 3, 4, 1, 2, 3]),
|
|
|
- '../evil.md': strToU8('x'),
|
|
|
- '__MACOSX/pack/._delta': strToU8('x'),
|
|
|
- })
|
|
|
- const { uploadId, name, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
|
|
|
- expect(name).toBe('pack.zip')
|
|
|
- expect(scan.skills.map(s => [s.name, s.path, s.fileCount, s.status])).toEqual([['delta', 'pack/delta', 2, 'new']])
|
|
|
- expect(scan.skipped).toEqual(['../evil.md(路径不安全)'])
|
|
|
- expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true)
|
|
|
- expect(app.skillSources.list().map(s => s.kind)).not.toContain('upload')
|
|
|
- expect(app.skillSources.importUpload(uploadId, ['delta'], scan.digest).imported).toEqual([{ name: 'delta', version: 1, created: true }])
|
|
|
- expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3])
|
|
|
- // 从 zip 导入的技能可以编辑,不属于任何技能源
|
|
|
- expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
|
|
|
- expect(fs.readdirSync(skillUploadsDir())).not.toContain(uploadId)
|
|
|
- expect(() => app.skillSources.importUpload(uploadId, ['delta'], scan.digest)).toThrow(/重新上传/)
|
|
|
- })
|
|
|
+ it('C-04 撤回;删除已处理的提交项(待审核的不能删),技能版本保留提交说明', async () => {
|
|
|
+ const r = await push(key, zip({ 'epsilon/SKILL.md': md('epsilon') }))
|
|
|
+ const res = await fetch(`${base}/skills/registry/v1/submissions/${r.body.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${key}` } })
|
|
|
+ expect(((await res.json()) as RegistrySubmissionDto).items[0]!.status).toBe('withdrawn')
|
|
|
|
|
|
- it('再次上传同名技能:显示版本对比,可以覆盖为新版本或保留原版本;属于技能源的同名技能不能覆盖', () => {
|
|
|
- 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`) })
|
|
|
- const keep = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
|
|
|
- const item = keep.scan.skills[0]!
|
|
|
- expect(item).toMatchObject({ status: 'changed', declaredVersion: '2.0', existing: { version: 1, declaredVersion: null, sourceName: null, unsaved: false } })
|
|
|
- expect(item.changes.modified).toEqual(['SKILL.md'])
|
|
|
- expect(item.changes.removed).toEqual(['templates/report.pptx'])
|
|
|
- // 保留原版本:取消上传,技能不变
|
|
|
- app.skillSources.discardUpload(keep.uploadId)
|
|
|
- expect(app.customSkills.list().find(s => s.name === 'delta')!.latestVersion).toBe(1)
|
|
|
-
|
|
|
- // 覆盖:作为新版本发布,旧版本仍在历史中
|
|
|
- const over = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
|
|
|
- expect(app.skillSources.importUpload(over.uploadId, ['delta'], over.scan.digest).imported).toEqual([{ name: 'delta', version: 2, created: false }])
|
|
|
- expect(fs.readFileSync(live('delta'), 'utf8')).toContain('# 第二版')
|
|
|
- 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']])
|
|
|
- expect(audits.some(a => a === '导入技能包 delta-v2.zip:delta(覆盖) v2')).toBe(true)
|
|
|
-
|
|
|
- // 相同内容:无需导入
|
|
|
- const same = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
|
|
|
- expect(same.scan.skills[0]).toMatchObject({ status: 'unchanged', existing: { version: 2, declaredVersion: '2.0' } })
|
|
|
- app.skillSources.discardUpload(same.uploadId)
|
|
|
-
|
|
|
- // beta 属于目录技能源「业务仓库」
|
|
|
- const beta = app.skillSources.upload('beta.zip', Buffer.from(zipSync({ 'beta/SKILL.md': strToU8(md('beta')) })))
|
|
|
- expect(beta.scan.skills[0]).toMatchObject({ status: 'conflict', existing: { sourceName: '业务仓库' } })
|
|
|
- expect(beta.scan.skills[0]!.reason).toMatch(/技能源「业务仓库」/)
|
|
|
- app.skillSources.discardUpload(beta.uploadId)
|
|
|
- })
|
|
|
+ await push(key, zip({ 'zeta/SKILL.md': md('zeta') }))
|
|
|
+ expect(() => app.skillRegistry.deleteItem(pending('zeta').id)).toThrow(/先批准或拒绝/)
|
|
|
+ app.skillRegistry.reject(pending('zeta').id, '不需要')
|
|
|
|
|
|
- it('早期登记为技能源的 zip 上传:启动时删除记录,从中导入的技能转为可编辑', () => {
|
|
|
- const now = new Date().toISOString()
|
|
|
- app.db.insert(skillSources).values({ id: 'src_legacy', name: 'old.zip', description: '', kind: 'upload', path: path.join(skillUploadsDir(), 'src_legacy'), syncMode: 'manual' }).run()
|
|
|
- app.db.update(customSkills).set({ origin: 'imported', sourceId: 'src_legacy', updatedAt: now }).where(eq(customSkills.name, 'delta')).run()
|
|
|
- app.skillSources.start()
|
|
|
- app.skillSources.stop()
|
|
|
- expect(app.db.select().from(skillSources).where(eq(skillSources.id, 'src_legacy')).get()).toBeUndefined()
|
|
|
- expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
|
|
|
+ const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
|
|
|
+ for (const i of app.skillRegistry.listItems('done')) app.skillRegistry.deleteItem(i.id)
|
|
|
+ expect(app.skillRegistry.listItems('done')).toEqual([])
|
|
|
+ expect(app.customSkills.detail(alphaId).versions[0]!.note).toMatchObject({ keyName: 'fwh-app · CI' })
|
|
|
+ expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
|
|
|
})
|
|
|
|
|
|
- it('选择目录:只列出子目录,标出含 SKILL.md 的目录与找到的技能数', () => {
|
|
|
- const root = makeSourceDir()
|
|
|
- fs.writeFileSync(path.join(root, 'readme.md'), 'x')
|
|
|
- const top = app.skillSources.listDirs(root)
|
|
|
- expect(top.dirs).toEqual([{ name: 'skills', skill: false }])
|
|
|
- expect(top.skillCount).toBe(2)
|
|
|
- expect(top.parent).toBe(path.dirname(fs.realpathSync(root)))
|
|
|
- expect(app.skillSources.listDirs(path.join(root, 'skills')).dirs).toEqual([{ name: 'alpha', skill: true }, { name: 'beta', skill: true }])
|
|
|
- expect(app.skillSources.listDirs('/').parent).toBeNull()
|
|
|
- expect(app.skillSources.listDirs('~').path).toBe(fs.realpathSync(os.homedir()))
|
|
|
- expect(() => app.skillSources.listDirs('relative/path')).toThrow(/绝对路径/)
|
|
|
- expect(() => app.skillSources.listDirs(path.join(root, 'none'))).toThrow(/不存在/)
|
|
|
- })
|
|
|
+ it('C-05 校验:不合规的技能为 invalid;不是 zip、没有技能、压缩炸弹被拒绝;推送次数与待审核数有上限', async () => {
|
|
|
+ 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') }))
|
|
|
+ expect(r.body.items.map(i => [i.name, i.status])).toEqual([['dup', 'pending'], ['dup', 'invalid'], ['Bad_Name', 'invalid'], ['ok', 'pending']])
|
|
|
+ expect(r.body.items[1]!.reason).toMatch(/多个名为 dup/)
|
|
|
+ expect((await push(key, Buffer.from('not a zip'))).status).toBe(400)
|
|
|
+ expect((await push(key, zip({ 'readme.md': 'x' }))).body.error!.message).toMatch(/没有找到技能/)
|
|
|
|
|
|
- it('按实际解压字节数限制大小(防压缩炸弹)', () => {
|
|
|
const saved = LIMITS.unzipBytes
|
|
|
LIMITS.unzipBytes = 1048576
|
|
|
try {
|
|
|
- const bomb = zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 })
|
|
|
- expect(bomb.length).toBeLessThan(100_000)
|
|
|
- expect(() => app.skillSources.upload('bomb.zip', Buffer.from(bomb))).toThrow(/解压后超过/)
|
|
|
+ const bomb = Buffer.from(zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 }))
|
|
|
+ expect((await push(key, bomb)).body.error!.message).toMatch(/解压后超过/)
|
|
|
} finally {
|
|
|
LIMITS.unzipBytes = saved
|
|
|
}
|
|
|
- expect(() => app.skillSources.upload('bad.zip', Buffer.from('not a zip'))).toThrow()
|
|
|
+
|
|
|
+ const k = app.skillRegistry.createKey({ name: '限额', description: '', expiresInDays: 1 }).key
|
|
|
+ const limits = { ...REGISTRY_LIMITS }
|
|
|
+ REGISTRY_LIMITS.pendingPerKey = 1
|
|
|
+ try {
|
|
|
+ expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(201)
|
|
|
+ // 同名技能替代旧的,不占额度;新技能超出上限
|
|
|
+ expect((await push(k, zip({ 'q1/SKILL.md': md('q1', 'q1 的新说明:处理某类业务数据的流程与规范') }))).status).toBe(201)
|
|
|
+ expect((await push(k, zip({ 'q2/SKILL.md': md('q2') }))).status).toBe(429)
|
|
|
+ REGISTRY_LIMITS.submissionsPerHour = 2
|
|
|
+ expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(429)
|
|
|
+ } finally {
|
|
|
+ Object.assign(REGISTRY_LIMITS, limits)
|
|
|
+ }
|
|
|
+ expect(renameSkillMd('# x\n', 'n')).toBe('---\nname: n\n---\n# x\n')
|
|
|
})
|
|
|
})
|
|
|
|