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 { createApp, type App } from '../src/app' import { config, customSkillsDir, customSkillStoreDir } from '../src/config' import { LIMITS, normalizeRelPath } from '../src/custom-skills/store' import { resolveSkills } from '../src/providers/claude/skills' import { skillsToDisable } from '../src/providers/codex/skills' import { checkAccess, resolveAccess } from '../src/services/access-policy' let app: App 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 } beforeAll(() => { app = createApp(pino({ level: 'silent' }), { providers: {} }) const orig = app.audit.log.bind(app.audit) app.audit.log = (e: Parameters[0]) => { audits.push(e.summary); return orig(e) } }) afterAll(() => app.skillSources.stop()) describe('自定义技能:目录技能源导入与同步', () => { let srcDir: string let sourceId: string it('C-01 扫描出 2 个技能并导入;两个引擎的 ID 为 custom-skills:;符号链接被跳过(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) expect(app.customSkills.list().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.map(s => [s.id, s.optIn])).toEqual([['custom-skills:alpha', true], ['custom-skills:beta', true]]) }) 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('来源中删除的技能只提示,不自动删除;同名冲突拒绝;导入的技能只读,转为可编辑后可改', () => { fs.rmSync(path.join(srcDir, 'skills', 'beta'), { recursive: true }) expect(app.skillSources.scan(sourceId).missing).toEqual(['beta']) expect(fs.existsSync(live('beta'))).toBe(true) 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 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/) }) }) 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 { source, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip)) expect(source.kind).toBe('upload') expect(scan.skills.map(s => [s.name, s.path, s.fileCount])).toEqual([['delta', 'pack/delta', 2]]) expect(scan.skipped).toEqual(['../evil.md(路径不安全)']) expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true) app.skillSources.import(source.id, ['delta'], scan.digest) expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3]) }) 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(/解压后超过/) } finally { LIMITS.unzipBytes = saved } expect(() => app.skillSources.upload('bad.zip', Buffer.from('not a zip'))).toThrow() }) }) describe('自定义技能:在线编写、草稿与发布、版本', () => { let id: string it('C-04 新建 → 上传模版并保存为草稿:不生效;发布后生效;恢复到上一版本', () => { const d = app.customSkills.create({ name: 'report-kit', description: '生成月度报告的规范与模版,涉及月报时使用' }) id = d.id expect(d.versions.map(v => [v.version, v.reason])).toEqual([[1, 'create']]) expect(d.publishedVersion).toBeNull() expect(fs.existsSync(live('report-kit'))).toBe(false) app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([1, 2, 3])) expect(app.customSkills.detail(id).hasUnsavedChanges).toBe(true) const v2 = app.customSkills.save(id, { baseVersion: 1, publish: false }) expect([v2.latestVersion, v2.publishedVersion, v2.hasUnsavedChanges]).toEqual([2, null, false]) expect(fs.existsSync(live('report-kit'))).toBe(false) // 内容无变化不产生新版本;基于旧版本保存时冲突 expect(app.customSkills.save(id, { baseVersion: 2, publish: false }).latestVersion).toBe(2) expect(() => app.customSkills.save(id, { baseVersion: 1, publish: false })).toThrow(/刷新/) expect(app.customSkills.publish(id, 2).publishedVersion).toBe(2) expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3]) app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([9])) app.customSkills.writeFile(id, 'SKILL.md', Buffer.from(md('report-kit', '新版描述:生成月度报告的规范与模版,涉及月报时使用'))) const v3 = app.customSkills.save(id, { baseVersion: 2, publish: true }) expect([v3.latestVersion, v3.publishedVersion, v3.description]).toEqual([3, 3, '新版描述:生成月度报告的规范与模版,涉及月报时使用']) expect(v3.versions[0]!.changed).toEqual({ added: [], modified: ['SKILL.md', 'templates/a.pptx'], removed: [] }) const v4 = app.customSkills.restore(id, 2) expect([v4.latestVersion, v4.publishedVersion, v4.versions[0]!.reason]).toEqual([4, 4, 'restore']) expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3]) }) it('SKILL.md 校验、路径安全、重命名与删除、放弃改动、导出 zip', () => { app.customSkills.writeFile(id, 'SKILL.md', Buffer.from('---\nname: other\ndescription: x\n---\n')) expect(() => app.customSkills.save(id, { baseVersion: 4, publish: false })).toThrow(/不一致/) app.customSkills.discard(id) expect(() => app.customSkills.writeFile(id, '../escape.md', Buffer.from('x'))).toThrow(/路径不正确/) expect(() => normalizeRelPath('/abs')).toThrow() expect(() => app.customSkills.deleteFile(id, 'SKILL.md')).toThrow(/不能删除/) app.customSkills.writeFile(id, 'refs/a.md', Buffer.from('a')) expect(app.customSkills.move(id, 'refs', 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'references/a.md', 'templates/a.pptx']) expect(app.customSkills.deleteFile(id, 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'templates/a.pptx']) const z = app.customSkills.exportZip(id) expect(z.name).toBe('report-kit-v4.zip') expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['report-kit/SKILL.md', 'report-kit/templates/a.pptx']) }) }) describe('自定义技能:使用、沙箱与恢复', () => { it('C-05 "全部技能"不包含自定义技能(可逐个设置包含);单独勾选后加载', () => { const plugins = [ { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] }, app.customSkills.pluginRef()!, ] expect(resolveSkills('all', plugins).skills).toEqual(['agent-skills:r']) expect(resolveSkills(['custom-skills:report-kit'], plugins).skills).toEqual(['custom-skills:report-kit']) const id = app.customSkills.list().find(s => s.name === 'report-kit')!.id app.customSkills.setFlags(id, { includeInAll: true }) expect(resolveSkills('all', [plugins[0]!, app.customSkills.pluginRef()!]).skills).toContain('custom-skills:report-kit') expect(app.customSkills.excludedFromAll()).not.toContain('custom-skills:report-kit') // Codex:all 时禁用需要单独勾选的自定义技能 const all = [ { name: 'custom-skills:alpha', path: path.join(customSkillsDir(), 'skills/alpha/SKILL.md') }, { name: 'custom-skills:Report Kit', path: path.join(customSkillsDir(), 'skills/report-kit/SKILL.md') }, ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null })) expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills', false, app.customSkills.excludedFromAll()).map(d => path.basename(path.dirname(d.path)))).toEqual(['alpha']) }) it('停用后不加载,启用后恢复;指纹随之变化', () => { const id = app.customSkills.list().find(s => s.name === 'alpha')!.id const before = app.customSkills.fingerprint() app.customSkills.setFlags(id, { enabled: false }) expect(fs.existsSync(live('alpha'))).toBe(false) expect(app.customSkills.skillInfos().map(s => s.name)).not.toContain('alpha') expect(app.customSkills.fingerprint()).not.toBe(before) app.customSkills.setFlags(id, { enabled: true }) expect(fs.existsSync(live('alpha'))).toBe(true) }) it('C-06 沙箱:生效目录只读可见,不能写;版本库不可读', () => { const settings = app.settings.get() const ws = path.join(config.dataDir, 'workspaces', 'ses_cs') fs.mkdirSync(ws, { recursive: true }) const access = resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }) expect(checkAccess(access, live('alpha'), 'read').allowed).toBe(true) expect(checkAccess(access, live('alpha'), 'write').allowed).toBe(false) expect(checkAccess(access, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false) const broad = resolveAccess({ policy: { mode: 'broad', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }) expect(checkAccess(broad, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false) expect(checkAccess(broad, live('alpha'), 'read').allowed).toBe(true) }) it('C-07 删除被两个 Profile 使用的技能:从两个 Profile 中移除', () => { const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin() const p1 = app.profiles.create({ ...input, name: 'p1', isDefault: false, skills: ['custom-skills:gamma', 'agent-skills:r'] }) const p2 = app.profiles.create({ ...input, name: 'p2', isDefault: false, skills: ['custom-skills:gamma'] }) const gamma = app.customSkills.list().find(s => s.name === 'gamma')! expect(gamma.profileNames.sort()).toEqual(['p1', 'p2']) app.customSkills.delete(gamma.id) expect(app.profiles.get(p1.id).skills).toEqual(['agent-skills:r']) expect(app.profiles.get(p2.id).skills).toEqual([]) expect(fs.existsSync(live('gamma'))).toBe(false) }) it('C-08 重启时按数据库重建:被删除或改动的生效目录恢复,多余目录移除', () => { fs.rmSync(path.join(customSkillsDir(), 'skills', 'alpha'), { recursive: true }) fs.writeFileSync(live('report-kit'), 'tampered') fs.mkdirSync(path.join(customSkillsDir(), 'skills', 'stray')) const logs: string[] = [] app.customSkills.reconcile(m => logs.push(m)) expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha') expect(fs.existsSync(path.join(customSkillsDir(), 'skills', 'stray'))).toBe(false) expect(logs.some(l => l.includes('alpha'))).toBe(true) // 改动文件内容但标记未变:按标记判断不重新发布(运行时目录只能由服务写入) expect(logs.some(l => l.includes('stray'))).toBe(true) }) })