profileSkills.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtemp,mkdir,writeFile,readFile,rm,realpath,symlink,rename} from 'node:fs/promises'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import {execFile} from 'node:child_process'
  7. import {promisify} from 'node:util'
  8. import {ProfileSkillSession} from '../../src/runner/worker/skills'
  9. import {HermesWorkerProcess} from '../../src/runner/worker/process'
  10. const exec=promisify(execFile)
  11. let root:string,home:string,source:string,allowed:boolean
  12. const script=resolve('src/runner/worker/hermes_worker.py')
  13. const spec=(name:string,body='Use the tested script.')=>'---\n'+JSON.stringify({name,description:'Reusable fixture procedure'})+'\n---\n'+body
  14. beforeEach(async()=>{
  15. root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-skills-')));home=join(root,'profile');source=join(root,'source');allowed=true
  16. await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
  17. await writeFile(join(home,'config.yaml'),'{}')
  18. await writeFile(join(source,'dotenv.py'),'def dotenv_values(path): return {}\n')
  19. await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
  20. await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
  21. // JSON frontmatter is valid YAML. Native PyYAML interoperability is covered
  22. // separately by the real-Hermes live test; no system Python dependency here.
  23. await writeFile(join(source,'yaml.py'),'import json\ndef safe_load(text): return json.loads(text)\ndef safe_dump(value, **kwargs): return json.dumps(value,ensure_ascii=False)+"\\n"\n')
  24. })
  25. afterEach(async()=>{vi.restoreAllMocks();await rm(root,{recursive:true,force:true})})
  26. async function skill(directory:string,name='fixture-skill',body?:string){
  27. await mkdir(join(directory,'scripts'),{recursive:true})
  28. await writeFile(join(directory,'SKILL.md'),spec(name,body))
  29. await writeFile(join(directory,'scripts','proof.py'),'print("verified procedure")\n')
  30. }
  31. function session(profile='default'){
  32. const installs:any[]=[]
  33. const authorize=async()=>{if(!allowed)throw new Error('revoked')}
  34. const service=new ProfileSkillSession({python:'python3',script,hermesSource:source,hermesHome:home,profile,ownerKey:'owner',agentId:'bot-1',taskId:'task-1',signal:new AbortController().signal,authorize,
  35. execute:async(argv)=>{await authorize();return exec(argv[0]!,argv.slice(1),{env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'},maxBuffer:10*1024*1024})},
  36. install:async(bundle)=>{await authorize();installs.push(bundle);return {path:'/opt/yaoyao-skills/'+bundle.namespace+'/'+bundle.revision,revision:bundle.revision}},
  37. })
  38. return {service,installs,call:(name:string,args:unknown={})=>service.call('computer_'+name,args) as Promise<any>}
  39. }
  40. async function publish(f:ReturnType<typeof session>,directory:string,expected_revision:string|null=null){
  41. return f.call('skill_publish',{name:'fixture-skill',directory,expected_revision,validation_command:'python3 scripts/proof.py'})
  42. }
  43. it('discovers only selected Profile and configured external roots, respecting disabled names and preserving package resources',async()=>{
  44. const external=join(root,'external');await skill(join(external,'outside'),'outside-skill')
  45. await skill(join(home,'skills','category','fixture-skill'))
  46. await writeFile(join(home,'skills','category','fixture-skill','操作说明.md'),'可复用的操作步骤')
  47. await skill(join(home,'skills','disabled'),'disabled-skill')
  48. await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{disabled:['disabled-skill'],external_dirs:[external]}}))
  49. const named=join(home,'profiles','writer');await mkdir(named,{recursive:true});await writeFile(join(named,'config.yaml'),'{}');await skill(join(named,'skills','writer'),'writer-skill')
  50. const f=session(),catalog=await f.call('skills_list')
  51. expect(catalog.skills.map((s:any)=>s.name)).toEqual(['fixture-skill','outside-skill'])
  52. expect((await session('writer').call('skills_list')).skills.map((s:any)=>s.name)).toEqual(['writer-skill'])
  53. const view=await f.call('skill_view',{name:'fixture-skill'})
  54. expect(view).toMatchObject({readOnly:true,execution:'linux-vm',files:expect.arrayContaining(['SKILL.md','scripts/proof.py','操作说明.md'])})
  55. expect(JSON.stringify(view)).not.toContain(home)
  56. expect((await f.call('skill_view',{name:'fixture-skill',file_path:'scripts/proof.py'})).content).toContain('verified procedure')
  57. expect((await f.call('skill_view',{name:'disabled-skill'})).code).toBe('skill_missing')
  58. })
  59. it('publishes a validated package into the native Profile, freezes current-turn reads and records versioned rollback',async()=>{
  60. const draft=join(root,'draft');await skill(draft)
  61. const f=session(),first=await publish(f,draft)
  62. expect(first).toMatchObject({published:true,profileAvailable:true,previous:null})
  63. const formal=join(home,'skills','bot-learned','fixture-skill')
  64. const text=await readFile(join(formal,'SKILL.md'),'utf8')
  65. expect(text).toContain('computer_shell');expect(text).not.toContain('"platforms"')
  66. expect(await readFile(join(formal,'scripts','proof.py'),'utf8')).toContain('verified procedure')
  67. const frozen=await f.call('skill_view',{name:'fixture-skill'})
  68. await writeFile(join(draft,'scripts','proof.py'),'print("second procedure")\n')
  69. const second=await publish(f,draft,first.revision)
  70. expect(second.revision).not.toBe(first.revision)
  71. expect((await f.call('skill_view',{name:'fixture-skill'})).revision).toBe(frozen.revision)
  72. expect((await session().call('skill_view',{name:'fixture-skill'})).revision).toBe(second.revision)
  73. expect((await publish(f,draft,first.revision)).code).toBe('skill_revision_conflict')
  74. const history=await f.call('skill_history',{name:'fixture-skill'})
  75. expect(history.history).toHaveLength(2)
  76. expect(history.history[1]).toMatchObject({agentId:'bot-1',taskId:'task-1',previous:first.revision,validation:expect.stringContaining('exit: 0')})
  77. const restored=await f.call('skill_restore',{name:'fixture-skill',revision:first.revision,expected_revision:second.revision,reason:'Restore previously validated procedure'})
  78. expect(restored.revision).toBe(first.revision)
  79. expect(await readFile(join(formal,'scripts','proof.py'),'utf8')).toContain('verified procedure')
  80. })
  81. it('serializes concurrent publishers and refuses to overwrite user-owned or external skills',async()=>{
  82. const draft=join(root,'draft');await skill(draft)
  83. const results=await Promise.all([publish(session(),draft),publish(session(),draft)])
  84. expect(results.filter(result=>result.published)).toHaveLength(1)
  85. expect(results.filter(result=>result.code==='skill_revision_conflict')).toHaveLength(1)
  86. await skill(join(home,'skills','user-skill'),'user-skill')
  87. await skill(draft,'user-skill')
  88. expect(await session().call('skill_publish',{name:'user-skill',directory:draft,expected_revision:null,validation_command:'true'})).toMatchObject({code:'skill_not_managed'})
  89. })
  90. it('refuses failed validation, changing drafts, traversal, links, foreign Profile fields and revoked calls',async()=>{
  91. const draft=join(root,'draft');await skill(draft);const f=session()
  92. expect(await f.call('skill_publish',{name:'fixture-skill',directory:draft,expected_revision:null,validation_command:'exit 1'})).toMatchObject({code:'skill_validation_failed'})
  93. expect(await f.call('skill_publish',{name:'fixture-skill',directory:draft,expected_revision:null,validation_command:'printf changed > scripts/proof.py'})).toMatchObject({code:'skill_draft_changed'})
  94. await expect(f.call('skill_view',{name:'../escape'})).rejects.toThrow()
  95. await expect(f.call('skill_view',{name:'fixture-skill',profile:'writer'})).rejects.toThrow()
  96. await expect(f.call('skill_view',{name:'fixture-skill',file_path:'../secret'})).rejects.toThrow()
  97. await symlink(join(home,'config.yaml'),join(draft,'linked.yaml'))
  98. expect((await publish(f,draft)).code).toBe('skill_draft_invalid')
  99. allowed=false;await expect(f.call('skills_list')).rejects.toThrow('revoked')
  100. expect((await readFile(join(home,'config.yaml'),'utf8'))).toBe('{}')
  101. })
  102. it('respects Profile write approval and does not adopt untracked Bot-category directories',async()=>{
  103. const draft=join(root,'draft');await skill(draft)
  104. await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{write_approval:'true'}}))
  105. expect((await publish(session(),draft)).code).toBe('skill_write_disabled')
  106. await writeFile(join(home,'config.yaml'),'{}')
  107. await skill(join(home,'skills','bot-learned','fixture-skill'))
  108. expect((await publish(session(),draft)).code).toBe('skill_not_managed')
  109. })
  110. it('runs configured scanning before publication and preserves a native edit racing the commit',async()=>{
  111. const draft=join(root,'draft');await skill(draft)
  112. await mkdir(join(source,'tools'));await writeFile(join(source,'tools','skills_guard.py'),'def scan_skill(*args, **kwargs): return {}\ndef should_allow_install(result): return False,"blocked"\n')
  113. await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{guard_agent_created:true}}))
  114. expect((await publish(session(),draft)).code).toBe('skill_scan_blocked')
  115. await expect(readFile(join(home,'skills','bot-learned','fixture-skill','SKILL.md'))).rejects.toMatchObject({code:'ENOENT'})
  116. await writeFile(join(home,'config.yaml'),'{}')
  117. const first=await publish(session(),draft),formal=join(home,'skills','bot-learned','fixture-skill','SKILL.md')
  118. const worker=new HermesWorkerProcess('python3',script,{mode:'skills',profile:'default',hermesSource:source,hermesHome:home,action:'publish',arguments:{name:'fixture-skill',files:{'SKILL.md':Buffer.from(spec('fixture-skill','new version')).toString('base64')},expectedRevision:first.revision,validation:'passed'},provenance:{ownerKey:'owner'}})
  119. worker.onTool=async()=>{await writeFile(formal,spec('fixture-skill','native edit wins'));return {authorized:true}}
  120. try{expect((await worker.wait('skills')).result.code).toBe('skill_revision_conflict')}finally{await worker.close()}
  121. expect(await readFile(formal,'utf8')).toContain('native edit wins')
  122. })
  123. it('rechecks authority at host commit and recovers an interrupted directory replacement',async()=>{
  124. const draft=join(root,'draft');await skill(draft)
  125. const f=session(),first=await publish(f,draft),formal=join(home,'skills','bot-learned','fixture-skill')
  126. await rename(formal,join(home,'.yaoyao-skills','fixture-skill.previous'))
  127. // Discovery also repairs an interrupted commit; it does not hide the skill.
  128. expect((await session().call('skills_list')).skills.map((s:any)=>s.name)).toContain('fixture-skill')
  129. await writeFile(join(draft,'scripts','proof.py'),'print("after recovery")\n')
  130. const next=await publish(f,draft,first.revision)
  131. expect(next).toMatchObject({published:true,previous:first.revision})
  132. const files={'SKILL.md':Buffer.from(spec('blocked-skill')).toString('base64')}
  133. const worker=new HermesWorkerProcess('python3',script,{mode:'skills',profile:'default',hermesSource:source,hermesHome:home,action:'publish',arguments:{name:'blocked-skill',files,expectedRevision:null,validation:'passed'},provenance:{ownerKey:'owner'}})
  134. worker.onTool=async()=>{throw new Error('revoked just before commit')}
  135. try{expect((await worker.wait('skills')).result.code).toBe('skill_cancelled')}finally{await worker.close()}
  136. await expect(readFile(join(home,'skills','bot-learned','blocked-skill','SKILL.md'))).rejects.toMatchObject({code:'ENOENT'})
  137. })