hostComputerTools.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,writeFile,readFile,rm,mkdir} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
  9. import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
  10. import {VM_FILE_TRANSFER_SCRIPT} from '../../src/runner/worker/fileTransfer'
  11. import {hostTool} from '../../src/runner/worker/hostTools'
  12. it('runs host commands and files in the explicitly selected working directory and interrupts child processes',async()=>{
  13. const home=await mkdtemp(join(tmpdir(),'yaoyao-host-tools-')),controller=new AbortController()
  14. const context={cwd:home,signal:controller.signal,authorize:()=>{}}
  15. try{
  16. await hostTool('host_write_file',{path:'nested/source.txt',content:'host-proof'},context)
  17. expect(await hostTool('host_read_file',{path:'nested/source.txt'},context)).toEqual({stdout:'host-proof'})
  18. expect(await hostTool('host_shell',{command:'pwd; cat nested/source.txt'},context)).toMatchObject({exitCode:0,stdout:expect.stringContaining('host-proof')})
  19. const running=hostTool('host_shell',{command:'sleep 1; printf escaped > late.txt'},context)
  20. setTimeout(()=>controller.abort(),50);await expect(running).rejects.toThrow('中断')
  21. await new Promise(resolve=>setTimeout(resolve,1100));await expect(readFile(join(home,'late.txt'))).rejects.toMatchObject({code:'ENOENT'})
  22. await expect(hostTool('host_read_file',{path:'nested/source.txt'},context)).rejects.toThrow()
  23. }finally{await rm(home,{recursive:true,force:true})}
  24. })
  25. it('exposes host tools only with an explicit hybrid grant and removes access on the next VM-only turn',async()=>{
  26. const home=await mkdtemp(join(tmpdir(),'yaoyao-hybrid-gateway-')),db=new DatabaseSync(':memory:'),script=join(home,'probe.py')
  27. await writeFile(script,`import json,sys
  28. boot=json.loads(sys.stdin.readline())
  29. print(json.dumps({"nonce":boot["nonce"],"type":"probe","boot":boot}),flush=True)
  30. for line in sys.stdin:
  31. if json.loads(line).get("type")=="interrupt": break
  32. `)
  33. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,hermesHome:home,network:'none'}},home,script)
  34. const vmRoot=join(home,'vm');await mkdir(vmRoot);const vmFiles=new FileTransferFiles(vmRoot)
  35. const id=randomUUID(),gateways:ComputerGateway[]=[]
  36. let allowed=true,sessionId:string|undefined
  37. try{
  38. await runtime.ready;await writeFile(join(home,'private.txt'),'explicit-host-proof')
  39. vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home,model:{provider:'custom',api_mode:'chat_completions',model:'fixture'}})
  40. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:'fixture',running:true,workspace:home,isolation:'container'})
  41. vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  42. const execute=vi.spyOn(runtime.provider,'execute').mockImplementation(async(_spec,argv,options)=>argv[2]===VM_FILE_TRANSFER_SCRIPT?{stdout:JSON.stringify(await vmFiles.call(JSON.parse(options.input!.toString()))),stderr:''}:{stdout:'vm-proof',stderr:''})
  43. for(const hostAccess of [false,true,false]){
  44. const gateway=new ComputerGateway(runtime,{environmentId:id,agentId:id,ownerKey:'owner',...(hostAccess?{hostAccess:true}:{})},randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}))
  45. gateways.push(gateway);await gateway.connect()
  46. const opened=await gateway.rpc(sessionId?'session.resume':'session.create',{profile:'default',...(sessionId?{session_id:sessionId}:{})});sessionId=opened.session_id
  47. await gateway.rpc('prompt.submit',{session_id:sessionId,text:'exercise both environments'})
  48. const worker=runtime.workers.get(gateway.workId)!.process,probe=await worker.wait('probe',5000),names=probe.boot.tools.map((tool:any)=>tool.name)
  49. expect(names.includes('host_shell')).toBe(hostAccess);expect(names.includes('computer_copy_file')).toBe(hostAccess)
  50. if(hostAccess){
  51. expect(probe.boot.host.cwd).toBe(home)
  52. expect(await worker.onTool('host_read_file',{path:'private.txt'},randomUUID())).toEqual({stdout:'explicit-host-proof'})
  53. await worker.onTool('computer_copy_file',{from:'host',source:'private.txt',destination:'import.txt'},randomUUID())
  54. expect(await readFile(join(vmRoot,'import.txt'),'utf8')).toBe('explicit-host-proof')
  55. expect(await worker.onTool('computer_copy_file',{from:'vm',source:'import.txt',destination:'from-vm.txt'},randomUUID())).toEqual({ok:true})
  56. expect(await readFile(join(home,'from-vm.txt'),'utf8')).toBe('explicit-host-proof')
  57. }else{
  58. expect(probe.boot.host).toBeUndefined()
  59. await expect(worker.onTool('host_read_file',{path:join(home,'private.txt')},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
  60. await expect(worker.onTool('computer_copy_file',{from:'host',source:join(home,'private.txt'),destination:'bad.txt'},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
  61. }
  62. expect(await worker.onTool('computer_shell',{command:'pwd'},randomUUID())).toMatchObject({stdout:'vm-proof'})
  63. expect(execute).toHaveBeenLastCalledWith(expect.anything(),['/bin/bash','-lc','pwd'],expect.objectContaining({user:'cua'}))
  64. await worker.onTool('computer_shell',{command:'id -u',user:'root'},randomUUID())
  65. expect(execute).toHaveBeenLastCalledWith(expect.anything(),['/bin/bash','-lc','id -u'],expect.objectContaining({user:'root'}))
  66. if(!hostAccess&&gateways.length===3){allowed=false;await expect(worker.onTool('computer_shell',{command:'no'},randomUUID())).rejects.toThrow('revoked')}
  67. await gateway.close()
  68. }
  69. }finally{await Promise.all(gateways.map(g=>g.close()));await vmFiles.close();await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})}
  70. })