computerProvider.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. // @vitest-environment node
  2. import {afterEach,expect,it} from 'vitest'
  3. import {randomUUID} from 'node:crypto'
  4. import {mkdtemp,rm,symlink,mkdir,rename,writeFile,realpath} 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',onCommand?:(args:string[])=>Promise<void>){
  11. const home=await realpath(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. if(onCommand)await onCommand(args)
  17. calls.push(args)
  18. if(args[0]==='context')return {stdout:JSON.stringify('unix:///tmp/fixture-engine.sock'),stderr:''}
  19. if(args[0]==='system')return {stdout:'[]',stderr:''}
  20. 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:''}
  21. if(args[0]==='inspect'){
  22. if(!detail)throw Object.assign(new Error('missing'),{stderr:'No such object'})
  23. return {stdout:JSON.stringify([detail]),stderr:''}
  24. }
  25. if(args[0]==='create'){
  26. const value=(flag:string)=>args[args.indexOf(flag)+1]!
  27. const values=(flag:string)=>args.flatMap((arg,i)=>arg===flag?[args[i+1]!]:[])
  28. const workspace=/source=([^,]+)/.exec(value('--mount'))![1]
  29. detail={Id:'b'.repeat(64),Image:spec.imageId,Config:{Labels:Object.fromEntries(values('--label').map(label=>label.split('=')))},State:{Running:false},
  30. 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:{}},
  31. Mounts:[{Type:'bind',Source:workspace,Destination:'/home/cua/workspace',RW:true}],NetworkSettings:{Ports:{}}}
  32. return {stdout:detail.Id,stderr:''}
  33. }
  34. if(args[0]==='start')detail.State.Running=true
  35. if(args[0]==='stop')detail.State.Running=false
  36. if(args[0]==='rm')detail=undefined
  37. return {stdout:'fixture',stderr:''}
  38. }
  39. const provider=new ContainerComputerProvider(runtime,randomUUID(),home,run,{})
  40. return {provider,spec,calls,home,run,get detail(){return detail}}
  41. }
  42. it.each(['docker','podman'] as const)('uses the same private ownership and execution boundary for %s',async runtime=>{
  43. const f=await fixture(runtime),state=await f.provider.ensure(f.spec,()=>{})
  44. expect(state).toMatchObject({running:true,isolation:'container'})
  45. await f.provider.execute(f.spec,['/bin/sh','-c','echo hello'],{authorize:()=>{}})
  46. expect(f.calls.find(args=>args[0]==='exec')).toEqual(['exec','--user','1000:1000','--workdir','/home/cua/workspace','--',state.containerId,'/bin/sh','-c','echo hello'])
  47. await expect(f.provider.ensure({...f.spec,ownerKey:'different-account'},()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  48. await f.provider.remove(f.spec)
  49. expect(f.calls.find(args=>args[0]==='rm')).toEqual(['rm','--',state.containerId])
  50. })
  51. it.each([
  52. (d:any)=>{d.HostConfig.Privileged=true},
  53. (d:any)=>{d.HostConfig.CapAdd.push('SYS_ADMIN')},
  54. (d:any)=>{d.HostConfig.Memory=0},
  55. (d:any)=>{d.HostConfig.NetworkMode='host'},
  56. (d:any)=>{d.HostConfig.PidMode='host'},
  57. (d:any)=>{d.HostConfig.SecurityOpt=['seccomp=unconfined']},
  58. (d:any)=>{d.Mounts[0].Source='/Users/private'},
  59. (d:any)=>{d.Mounts.push({Source:'/var/run/docker.sock'})},
  60. (d:any)=>{d.HostConfig.PortBindings['6901/tcp']=[{HostIp:'127.0.0.1',HostPort:'12345'}]},
  61. (d:any)=>{d.Image=`sha256:${'c'.repeat(64)}`},
  62. ])('refuses execution when an existing container no longer matches its constraints (%#)',async mutate=>{
  63. const f=await fixture();await f.provider.ensure(f.spec,()=>{});mutate(f.detail)
  64. await expect(f.provider.execute(f.spec,['true'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_configuration_unsafe'})
  65. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  66. await f.provider.stop(f.spec)
  67. expect(f.detail.State.Running).toBe(false)
  68. })
  69. it('does not stop or remove an identically named container belonging to another node',async()=>{
  70. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  71. f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()
  72. await expect(f.provider.stop(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  73. await expect(f.provider.remove(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  74. expect(f.calls.some(args=>['stop','rm'].includes(args[0]!))).toBe(false)
  75. })
  76. it('rejects workspace links and remote engine contexts before creating a computer',async()=>{
  77. const f=await fixture(),outside=await mkdtemp(join(tmpdir(),'yaoyao-computer-outside-'));homes.push(outside)
  78. await mkdir(join(f.home,'computer-workspaces'));await symlink(outside,join(f.home,'computer-workspaces',f.spec.id))
  79. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_path_unsafe'})
  80. const remote=new ContainerComputerProvider('docker',randomUUID(),f.home,f.run,{DOCKER_HOST:'tcp://remote.invalid:2375'})
  81. await expect(remote.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_remote_engine'})
  82. expect(f.calls.some(args=>args[0]==='create')).toBe(false)
  83. })
  84. it('checks authority again after asynchronous inspection, before a guest command starts',async()=>{
  85. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  86. let active=true
  87. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  88. const result=await f.run(runtime,args,options);if(args[0]==='inspect')active=false;return result
  89. },{})
  90. await expect(provider.execute(f.spec,['true'],{authorize:()=>{if(!active)throw new Error('revoked')}})).rejects.toThrow('revoked')
  91. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  92. })
  93. it('recreates a migrated mount while retaining the stopped original container and persistent workspace',async()=>{
  94. const f=await fixture(),oldRoot=join(f.home,'.hermes-yaoyao'),newRoot=join(f.home,'.yaoyao')
  95. const oldHome=join(oldRoot,'runner-state/r1'),newHome=join(newRoot,'runner-state/r1')
  96. await mkdir(oldHome,{recursive:true})
  97. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,oldHome,f.run,{})
  98. await provider.ensure(f.spec,()=>{});await provider.stop(f.spec)
  99. await writeFile(join(oldHome,'computer-workspaces',f.spec.id,'keep.txt'),'keep workspace')
  100. await rename(oldRoot,newRoot)
  101. await writeFile(join(newRoot,'.data-home-migration.json'),JSON.stringify({source:oldRoot,target:newRoot,phase:'complete'}))
  102. let renamed=false
  103. const next=new ContainerComputerProvider('docker',f.provider.runnerId,newHome,async(runtime,args,options)=>{
  104. if(args[0]==='rename'){renamed=true;expect(args[1]).toBe(f.detail.Id);expect(args[2]).toContain('-before-data-move-');return {stdout:'',stderr:''}}
  105. if(args[0]==='inspect'&&renamed&&args[1]!==f.detail.Id)throw Object.assign(new Error('missing'),{stderr:'No such object'})
  106. if(args[0]==='create')renamed=false
  107. return f.run(runtime,args,options)
  108. },{})
  109. const state=await next.ensure(f.spec,()=>{})
  110. expect(state.workspace).toContain('/.yaoyao/')
  111. await expect((await import('node:fs/promises')).readFile(join(state.workspace,'keep.txt'),'utf8')).resolves.toBe('keep workspace')
  112. expect(f.calls.filter(args=>args[0]==='rm')).toHaveLength(0)
  113. })
  114. it('runs command lanes concurrently, serializes desktop input and drains every lane before lifecycle operations',async()=>{
  115. const order:string[]=[],held:Array<()=>void>=[]
  116. let active=0,peak=0
  117. const f=await fixture('docker',async args=>{
  118. if(args[0]==='exec'){
  119. const marker=String(args.at(-1))
  120. active++;peak=Math.max(peak,active)
  121. try{
  122. if(marker.startsWith('hold'))await new Promise<void>(resolve=>held.push(resolve))
  123. order.push(`exec:${marker}`)
  124. }finally{active--}
  125. }
  126. if(args[0]==='stop'||args[0]==='rm')order.push(args[0]!)
  127. })
  128. await f.provider.ensure(f.spec,()=>{})
  129. // File commands from different holders overlap on the exec lane.
  130. const slowFile=f.provider.execute(f.spec,['/bin/sh','-c','hold-a'],{authorize:()=>{}})
  131. await f.provider.execute(f.spec,['/bin/sh','-c','plain-b'],{authorize:()=>{}})
  132. expect(order).toContain('exec:plain-b')
  133. // Desktop input is serialized: the second GUI action waits for the first.
  134. const slowGui=f.provider.execute(f.spec,['driver','hold-gui-1'],{authorize:()=>{},lane:'gui'})
  135. let guiTwoDone=false
  136. const guiTwo=f.provider.execute(f.spec,['driver','plain-gui-2'],{authorize:()=>{},lane:'gui'}).then(()=>{guiTwoDone=true})
  137. await new Promise(resolve=>setTimeout(resolve,25))
  138. expect(guiTwoDone).toBe(false)
  139. // A lifecycle operation drains both lanes before touching the container.
  140. let stopped=false
  141. const stopping=f.provider.stop(f.spec).then(()=>{stopped=true})
  142. await new Promise(resolve=>setTimeout(resolve,25))
  143. expect(stopped).toBe(false)
  144. for(const release of held.splice(0))release()
  145. await Promise.all([slowFile,slowGui,guiTwo,stopping])
  146. expect(guiTwoDone).toBe(true)
  147. expect(peak).toBeGreaterThanOrEqual(2)
  148. expect(order.filter(entry=>entry.startsWith('exec:')).indexOf('exec:plain-gui-2')).toBeGreaterThan(order.indexOf('exec:hold-gui-1'))
  149. expect(order.indexOf('stop')).toBeGreaterThan(order.indexOf('exec:plain-gui-2'))
  150. })
  151. it('keeps a shared desktop alive when one holder cancels an unacknowledged operation',async()=>{
  152. let stopped=0
  153. const f=await fixture('docker',async args=>{
  154. if(args[0]==='stop')stopped++
  155. if(args[0]==='exec'&&String(args.at(-1)).startsWith('boom'))throw Object.assign(new Error('unacknowledged'),{killed:true})
  156. })
  157. await f.provider.ensure(f.spec,()=>{})
  158. // Another holder still shares the desktop: the ambiguous operation must not close it.
  159. await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-shared'],{authorize:()=>{},mayFence:()=>false})).rejects.toThrow('unacknowledged')
  160. await new Promise(resolve=>setTimeout(resolve,25))
  161. expect(stopped).toBe(0)
  162. await f.provider.execute(f.spec,['/bin/sh','-c','after'],{authorize:()=>{}})
  163. // A sole holder keeps the original hard fence.
  164. await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-alone'],{authorize:()=>{}})).rejects.toThrow('unacknowledged')
  165. await new Promise(resolve=>setTimeout(resolve,25))
  166. expect(stopped).toBe(1)
  167. })