Files
ycode/scripts/test-skills-files.mjs

122 lines
14 KiB
JavaScript

// Actual isolated filesystem lifecycle. Only Electron's app.getPath is bound to a verified test directory.
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { zipSync, strToU8 } from 'fflate'
const repository = path.resolve('.'), testRoot = await fs.mkdtemp(path.resolve('.tmp/skills-files-'))
const relative = path.relative(path.resolve('.tmp'), testRoot)
assert.ok(relative.startsWith('skills-files-') && !relative.includes('..') && !path.isAbsolute(relative))
const userData = path.join(testRoot, 'data'), workspace = path.join(testRoot, 'workspace'), second = path.join(testRoot, 'workspace-two'), outside = path.join(testRoot, 'outside')
await Promise.all([userData,workspace,second,outside].map(folder=>fs.mkdir(folder)))
const bundled = path.join(testRoot,'skills-test.mjs')
await build({entryPoints:[path.resolve('src/main/capabilities/skills.ts')],bundle:true,platform:'node',format:'esm',outfile:bundled,logLevel:'warning',banner:{js:"import { createRequire as createSkillsTestRequire } from 'node:module';const require=createSkillsTestRequire(import.meta.url);"},plugins:[{name:'isolated-electron-path',setup(context){context.onResolve({filter:/^electron$/},()=>({path:'electron',namespace:'skills-test'}));context.onLoad({filter:/.*/,namespace:'skills-test'},()=>({contents:`export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}}`}))}}]})
const api = await import(pathToFileURL(bundled).href), checks=[]
const check=(name,actual,expected)=>{assert.deepEqual(actual,expected,name);checks.push(name)}
const doc=(name,extra='',body='Controlled skill instructions: print LOCAL_SKILL_PROOF.')=>`---\nname: ${JSON.stringify(name)}\ndescription: >-\n Controlled multi-line\n description\nversion: 1.2\n${extra}---\n${body}\n`
async function fixture(folder,name,extra=''){await fs.mkdir(path.join(folder,'references'),{recursive:true});await fs.writeFile(path.join(folder,'SKILL.md'),doc(name,extra));await fs.writeFile(path.join(folder,'references','guide.txt'),'OWNED_RESOURCE_PROOF');return folder}
const source=await fixture(path.join(testRoot,'source'),'skill-one')
check('initial list is empty',await api.listSkills(),[])
const imported=await api.importSkill(source)
check('managed import copies metadata and YAML folded semantics',[imported.name,imported.description,imported.version,imported.source,imported.scope],['skill-one','Controlled multi-line description','1.2','imported','user'])
assert.ok(imported.path.startsWith(path.join(userData,'capabilities','skills','packages')+path.sep));checks.push('managed path is contained in isolated app data')
check('import leaves source unchanged',await fs.readFile(path.join(source,'references','guide.txt'),'utf8'),'OWNED_RESOURCE_PROOF')
check('read returns package files',(await api.readSkill(imported.id)).files,['SKILL.md','references/guide.txt'])
check('resource reads selected package content',await api.readSkillResource(imported.id,'references/guide.txt',workspace),'OWNED_RESOURCE_PROOF')
await assert.rejects(api.importSkill(source),/同名/);checks.push('duplicate import requires explicit overwrite')
await api.setSkillEnabled(imported.id,false)
check('disabled state persists',(await api.listSkills())[0].enabled,false)
await assert.rejects(api.buildSkillContext([imported.id],workspace),/禁用/)
await assert.rejects(api.loadSkillContext(imported.id,workspace),/自动调用/)
check('automatic directory excludes disabled skills',JSON.parse(await api.buildAvailableSkills(workspace)),[])
await api.setSkillEnabled(imported.id,true)
check('manual context contains real instructions',(await api.buildSkillContext([imported.id,imported.id],workspace)).match(/LOCAL_SKILL_PROOF/g).length,1)
check('automatic loader contains real instructions',(await api.loadSkillContext(imported.id,workspace)).includes('LOCAL_SKILL_PROOF'),true)
check('automatic directory uses stable IDs',JSON.parse(await api.buildAvailableSkills(workspace)).map(item=>item.id),[imported.id])
await fs.appendFile(path.join(imported.path,'references','guide.txt'),'\nUSER_EDIT')
check('managed local edits are detected',(await api.listSkills())[0].userModified,true)
await fs.writeFile(path.join(source,'SKILL.md'),doc('skill-one','','UPDATED_PROOF'))
const replaced=await api.importSkill(source,true)
check('overwrite keeps identity and commits new content',[replaced.id,replaced.userModified,(await api.readSkill(replaced.id)).content.includes('UPDATED_PROOF')],[imported.id,false,true])
check('atomic install leaves no staging/backup folders',(await fs.readdir(path.dirname(imported.path))).filter(name=>name.startsWith('.')),[])
const exported=path.join(testRoot,'exported.zip');await api.exportSkill(imported.id,exported)
await assert.rejects(api.exportSkill(imported.id,exported),/EEXIST/);checks.push('export never overwrites an existing file')
await assert.rejects(api.exportSkill(imported.id,path.join(imported.path,'bad.zip')),/覆盖/);checks.push('export cannot overwrite managed package')
await api.removeSkill(imported.id)
check('remove deletes managed copy only',[await api.listSkills(),await fs.readFile(path.join(source,'SKILL.md'),'utf8')], [[],doc('skill-one','','UPDATED_PROOF')])
const roundtrip=await api.importSkill(exported)
check('ZIP export/import round trip preserves resource',await api.readSkillResource(roundtrip.id,'references/guide.txt',workspace),'OWNED_RESOURCE_PROOF')
const wsA=await fixture(path.join(workspace,'.agents','skills','same'),'workspace-skill'),wsB=await fixture(path.join(second,'.claude','skills','same'),'workspace-skill')
const firstScope=(await api.listSkills(workspace)).find(item=>item.scope==='workspace'),secondScope=(await api.listSkills(second)).find(item=>item.scope==='workspace')
assert.notEqual(firstScope.id,secondScope.id);checks.push('same-name workspace packages have isolated IDs')
await assert.rejects(api.readSkill(firstScope.id,second),/不存在/);checks.push('workspace ID cannot read through another cwd')
await assert.rejects(api.removeSkill(firstScope.id),/受管/);checks.push('workspace package cannot be uninstalled by managed API')
await api.setSkillEnabled(firstScope.id,false,workspace)
check('workspace disabled override does not alter source YAML',[(await api.listSkills(workspace)).find(item=>item.id===firstScope.id).enabled,await fs.readFile(path.join(wsA,'SKILL.md'),'utf8')],[false,doc('workspace-skill')])
await api.setSkillEnabled(firstScope.id,true,workspace)
const manual=await api.importSkill(await fixture(path.join(testRoot,'manual'),'manual-only','disable-model-invocation: true\n'))
check('manual-only skill allowed in explicit context',(await api.buildSkillContext([manual.id],workspace)).includes('LOCAL_SKILL_PROOF'),true)
await assert.rejects(api.loadSkillContext(manual.id,workspace),/自动调用/)
check('manual-only skill omitted from automatic directory',JSON.parse(await api.buildAvailableSkills(workspace)).some(item=>item.id===manual.id),false)
const automatic=await api.importSkill(await fixture(path.join(testRoot,'automatic'),'automatic-only','user-invocable: false\n'))
await assert.rejects(api.buildSkillContext([automatic.id],workspace),/手动调用/)
check('automatic-only skill allowed for model loading',(await api.loadSkillContext(automatic.id,workspace)).includes('LOCAL_SKILL_PROOF'),true)
for(const bad of ['../escape.txt','/absolute.txt','C:/drive.txt','references\\escape.txt','references/CON.txt']){
await assert.rejects(api.readSkillResource(roundtrip.id,bad,workspace),/路径/);checks.push('resource path rejected: '+bad)
}
for(const key of ['../escape.txt','/absolute.txt','C:/drive.txt','bad\\path.txt']){
const file=path.join(testRoot,'bad-'+checks.length+'.zip');await fs.writeFile(file,zipSync({'SKILL.md':strToU8(doc('bad-archive')),[key]:strToU8('outside')}));await assert.rejects(api.importSkill(file),/路径/);checks.push('ZIP traversal rejected: '+key)
}
const duplicateZip=path.join(testRoot,'duplicate.zip');await fs.writeFile(duplicateZip,zipSync({'SKILL.md':strToU8(doc('duplicate')),'A.txt':strToU8('a'),'a.txt':strToU8('b')}));await assert.rejects(api.importSkill(duplicateZip),/重复路径/);checks.push('ZIP case-insensitive collisions rejected')
const wrappedZip=path.join(testRoot,'wrapped.zip');await fs.writeFile(wrappedZip,zipSync({'wrapper/SKILL.md':strToU8(doc('wrapped')),'wrapper/reference.txt':strToU8('wrapped text')}));check('single enclosing ZIP folder is supported',(await api.importSkill(wrappedZip)).name,'wrapped')
const symlinkZip=Buffer.from(zipSync({'SKILL.md':strToU8(doc('zip-link')),'link':strToU8('../../outside')}))
for(let offset=0;offset+46<symlinkZip.length;offset++)if(symlinkZip.readUInt32LE(offset)===0x02014b50&&symlinkZip.subarray(offset+46,offset+46+symlinkZip.readUInt16LE(offset+28)).toString()==='link')symlinkZip.writeUInt32LE(0xa1ff0000,offset+38)
const symlinkFile=path.join(testRoot,'symlink.zip');await fs.writeFile(symlinkFile,symlinkZip);await assert.rejects(api.importSkill(symlinkFile),/符号链接/);checks.push('ZIP symlink type rejected before decompression')
const bomb=Buffer.from(zipSync({'SKILL.md':strToU8(doc('zip-bomb'))}))
for(let offset=0;offset+46<bomb.length;offset++)if(bomb.readUInt32LE(offset)===0x02014b50){bomb.writeUInt32LE(51*1024*1024,offset+24);break}
const bombFile=path.join(testRoot,'bomb.zip');await fs.writeFile(bombFile,bomb);await assert.rejects(api.importSkill(bombFile),/50MB/);checks.push('ZIP declared expansion rejected before inflation')
const lying=Buffer.from(zipSync({'SKILL.md':strToU8(doc('lying-size')),'large.txt':new Uint8Array(2*1024*1024)}))
for(let offset=0;offset+46<lying.length;offset++)if(lying.readUInt32LE(offset)===0x02014b50&&lying.subarray(offset+46,offset+46+lying.readUInt16LE(offset+28)).toString()==='large.txt')lying.writeUInt32LE(1,offset+24)
const lyingFile=path.join(testRoot,'lying-size.zip');await fs.writeFile(lyingFile,lying);await assert.rejects(api.importSkill(lyingFile),/实际解压大小/);checks.push('ZIP actual output is bounded even when declared size lies')
await fs.writeFile(path.join(outside,'secret.txt'),'OUTSIDE_ONLY')
await fs.symlink(outside,path.join(source,'redirect'),'junction')
await assert.rejects(api.importSkill(source,true),/符号链接/);checks.push('directory import cannot follow child junction')
await fs.symlink(outside,path.join(roundtrip.path,'redirect'),'junction')
await assert.rejects(api.readSkillResource(roundtrip.id,'redirect/secret.txt',workspace),/符号链接/);checks.push('installed resource cannot follow child junction')
await fs.unlink(path.join(roundtrip.path,'redirect'))
await fs.symlink(outside,path.join(second,'.agents'),'junction')
check('workspace discovery ignores redirected search root',(await api.listSkills(second)).filter(item=>item.scope==='workspace').map(item=>item.path),[await fs.realpath(wsB)])
const invalid=path.join(testRoot,'invalid');await fs.mkdir(invalid);await fs.writeFile(path.join(invalid,'SKILL.md'),'---\nname: bad\nname: duplicate\ndescription: x\n---\n')
await assert.rejects(api.importSkill(invalid),/YAML/);checks.push('duplicate YAML keys rejected')
await fs.writeFile(path.join(invalid,'SKILL.md'),doc('bad-boolean','user-invocable: "false"\n'));await assert.rejects(api.importSkill(invalid),/布尔/);checks.push('YAML string booleans do not bypass flags')
await api.setSkillEnabled(manual.id,false)
const restarted=await import(pathToFileURL(bundled).href+'?restart=1')
check('new module instance reads persisted disabled flag',(await restarted.listSkills()).find(item=>item.id===manual.id).enabled,false)
const damaged=await api.importSkill(await fixture(path.join(testRoot,'damaged'),'damaged'))
await fs.writeFile(path.join(damaged.path,'SKILL.md'),'broken metadata')
const listedDamage=(await api.listSkills(workspace)).find(item=>item.id===damaged.id)
check('damaged managed registration is visible and disabled',[listedDamage.name,listedDamage.enabled,!!listedDamage.error],['damaged',false,true])
check('damaged package does not suppress valid list entries',(await api.listSkills(workspace)).some(item=>item.id===automatic.id),true)
check('ordinary unselected context survives unrelated damaged package',await api.buildSkillContext([],workspace),'')
check('automatic catalog excludes invalid packages',JSON.parse(await api.buildAvailableSkills(workspace)).some(item=>item.id===damaged.id),false)
await assert.rejects(api.loadSkillContext(damaged.id,workspace),/元数据/);checks.push('invalid package cannot be loaded')
await api.importSkill(path.join(testRoot,'damaged'),true)
await fs.unlink(path.join(damaged.path,'SKILL.md'))
check('missing document is visible as a per-package error',!!(await api.listSkills()).find(item=>item.id===damaged.id).error,true)
await fs.rm(damaged.path,{recursive:true})
check('missing whole package retains uninstallable registration',(await api.listSkills()).find(item=>item.id===damaged.id).enabled,false)
await api.removeSkill(damaged.id)
check('uninstall cleans a missing package registration',(await api.listSkills()).some(item=>item.id===damaged.id),false)
const redirected=await api.importSkill(await fixture(path.join(testRoot,'redirected'),'redirected'))
await fs.rm(redirected.path,{recursive:true});await fs.symlink(outside,redirected.path,'junction')
check('redirected managed registration is unavailable',!!(await api.listSkills()).find(item=>item.id===redirected.id).error,true)
await api.removeSkill(redirected.id)
check('uninstall removes redirected registration without following it',await fs.readFile(path.join(outside,'secret.txt'),'utf8'),'OUTSIDE_ONLY')
await fs.writeFile(path.join(wsA,'SKILL.md'),'invalid')
check('invalid workspace skill is visible and disabled',[(await api.listSkills(workspace)).find(item=>item.id===firstScope.id).enabled,!!(await api.listSkills(workspace)).find(item=>item.id===firstScope.id).error],[false,true])
check('missing cwd still returns managed skills',(await api.listSkills(path.join(testRoot,'missing-workspace'))).some(item=>item.id===automatic.id),true)
check('no traversal output escaped test root',await fs.stat(path.join(path.dirname(testRoot),'escape.txt')).then(()=>true,()=>false),false)
await fs.writeFile(path.join(testRoot,'report.json'),JSON.stringify({result:'PASS',scope:'Actual temporary fs + production skills module; only Electron userData path injection',checks},null,2))
console.log(JSON.stringify({result:'PASS',checks:checks.length,directory:testRoot}))