computerProvider.test.ts 6.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. // @vitest-environment node
  2. import {afterEach,expect,it} from 'vitest'
  3. import {randomUUID} from 'node:crypto'
  4. import {mkdtemp,rm,symlink,mkdir} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {ContainerComputerProvider,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
  8. const homes:string[]=[]
  9. afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
  10. async function fixture(runtime:'docker'|'podman'='docker'){
  11. const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-'));homes.push(home)
  12. const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:`sha256:${'a'.repeat(64)}`}
  13. let detail:any
  14. const calls:string[][]=[]
  15. const run:ContainerCommand=async(_runtime,args)=>{
  16. calls.push(args)
  17. if(args[0]==='context')return {stdout:JSON.stringify('unix:///tmp/fixture-engine.sock'),stderr:''}
  18. if(args[0]==='system')return {stdout:'[]',stderr:''}
  19. if(args[0]==='image')return {stdout:JSON.stringify([{Id:spec.imageId,Config:{Labels:{'com.openmausbot.cua-driver':'0.20.0','com.openmausbot.image-layer':'5'}}}]),stderr:''}
  20. if(args[0]==='inspect'){
  21. if(!detail)throw Object.assign(new Error('missing'),{stderr:'No such object'})
  22. return {stdout:JSON.stringify([detail]),stderr:''}
  23. }
  24. if(args[0]==='create'){
  25. const value=(flag:string)=>args[args.indexOf(flag)+1]!
  26. const values=(flag:string)=>args.flatMap((arg,i)=>arg===flag?[args[i+1]!]:[])
  27. const workspace=/source=([^,]+)/.exec(value('--mount'))![1]
  28. detail={Id:'b'.repeat(64),Image:spec.imageId,Config:{Labels:Object.fromEntries(values('--label').map(label=>label.split('=')))},State:{Running:false},
  29. HostConfig:{Memory:4096*1024*1024,MemorySwap:4096*1024*1024,NanoCpus:2e9,PidsLimit:512,Privileged:false,IpcMode:'private',CgroupnsMode:'private',NetworkMode:'none',RestartPolicy:{Name:'no'},CapAdd:values('--cap-add'),CapDrop:values('--cap-drop'),SecurityOpt:values('--security-opt'),PortBindings:{}},
  30. Mounts:[{Type:'bind',Source:workspace,Destination:'/home/cua/workspace',RW:true}],NetworkSettings:{Ports:{}}}
  31. return {stdout:detail.Id,stderr:''}
  32. }
  33. if(args[0]==='start')detail.State.Running=true
  34. if(args[0]==='stop')detail.State.Running=false
  35. if(args[0]==='rm')detail=undefined
  36. return {stdout:'fixture',stderr:''}
  37. }
  38. const provider=new ContainerComputerProvider(runtime,randomUUID(),home,run,{})
  39. return {provider,spec,calls,home,run,get detail(){return detail}}
  40. }
  41. it.each(['docker','podman'] as const)('uses the same private ownership and execution boundary for %s',async runtime=>{
  42. const f=await fixture(runtime),state=await f.provider.ensure(f.spec,()=>{})
  43. expect(state).toMatchObject({running:true,isolation:'container'})
  44. await f.provider.execute(f.spec,['/bin/sh','-c','echo hello'],{authorize:()=>{}})
  45. expect(f.calls.find(args=>args[0]==='exec')).toEqual(['exec','--user','1000:1000','--workdir','/home/cua/workspace','--',state.containerId,'/bin/sh','-c','echo hello'])
  46. await expect(f.provider.ensure({...f.spec,ownerKey:'different-account'},()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  47. await f.provider.remove(f.spec)
  48. expect(f.calls.find(args=>args[0]==='rm')).toEqual(['rm','--',state.containerId])
  49. })
  50. it.each([
  51. (d:any)=>{d.HostConfig.Privileged=true},
  52. (d:any)=>{d.HostConfig.CapAdd.push('SYS_ADMIN')},
  53. (d:any)=>{d.HostConfig.Memory=0},
  54. (d:any)=>{d.HostConfig.NetworkMode='host'},
  55. (d:any)=>{d.HostConfig.PidMode='host'},
  56. (d:any)=>{d.HostConfig.SecurityOpt=['seccomp=unconfined']},
  57. (d:any)=>{d.Mounts[0].Source='/Users/private'},
  58. (d:any)=>{d.Mounts.push({Source:'/var/run/docker.sock'})},
  59. (d:any)=>{d.HostConfig.PortBindings['6901/tcp']=[{HostIp:'127.0.0.1',HostPort:'12345'}]},
  60. (d:any)=>{d.Image=`sha256:${'c'.repeat(64)}`},
  61. ])('refuses execution when an existing container no longer matches its constraints (%#)',async mutate=>{
  62. const f=await fixture();await f.provider.ensure(f.spec,()=>{});mutate(f.detail)
  63. await expect(f.provider.execute(f.spec,['true'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_configuration_unsafe'})
  64. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  65. await f.provider.stop(f.spec)
  66. expect(f.detail.State.Running).toBe(false)
  67. })
  68. it('does not stop or remove an identically named container belonging to another node',async()=>{
  69. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  70. f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()
  71. await expect(f.provider.stop(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  72. await expect(f.provider.remove(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  73. expect(f.calls.some(args=>['stop','rm'].includes(args[0]!))).toBe(false)
  74. })
  75. it('rejects workspace links and remote engine contexts before creating a computer',async()=>{
  76. const f=await fixture(),outside=await mkdtemp(join(tmpdir(),'yaoyao-computer-outside-'));homes.push(outside)
  77. await mkdir(join(f.home,'computer-workspaces'));await symlink(outside,join(f.home,'computer-workspaces',f.spec.id))
  78. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_path_unsafe'})
  79. const remote=new ContainerComputerProvider('docker',randomUUID(),f.home,f.run,{DOCKER_HOST:'tcp://remote.invalid:2375'})
  80. await expect(remote.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_remote_engine'})
  81. expect(f.calls.some(args=>args[0]==='create')).toBe(false)
  82. })
  83. it('checks authority again after asynchronous inspection, before a guest command starts',async()=>{
  84. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  85. let active=true
  86. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  87. const result=await f.run(runtime,args,options);if(args[0]==='inspect')active=false;return result
  88. },{})
  89. await expect(provider.execute(f.spec,['true'],{authorize:()=>{if(!active)throw new Error('revoked')}})).rejects.toThrow('revoked')
  90. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  91. })