computerProvider.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. // @vitest-environment node
  2. import {afterEach,expect,it,vi} 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,CUA_DRIVER,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
  8. import {COMMAND_ROOT,GUEST_COMMAND} from '../../src/runner/computers/commandProcess'
  9. import {CLOSE_DESKTOP} from '../../src/runner/computers/desktopClose'
  10. const homes:string[]=[]
  11. afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
  12. async function fixture(runtime:'docker'|'podman'='docker',onCommand?:(args:string[])=>Promise<void>){
  13. const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-')));homes.push(home)
  14. const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:`sha256:${'a'.repeat(64)}`}
  15. let detail:any
  16. const calls:string[][]=[]
  17. const run:ContainerCommand=async(_runtime,args)=>{
  18. if(onCommand)await onCommand(args)
  19. calls.push(args)
  20. if(args[0]==='context')return {stdout:JSON.stringify('unix:///tmp/fixture-engine.sock'),stderr:''}
  21. if(args[0]==='system')return {stdout:'[]',stderr:''}
  22. 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:''}
  23. if(args[0]==='inspect'){
  24. if(!detail)throw Object.assign(new Error('missing'),{stderr:'No such object'})
  25. return {stdout:JSON.stringify([detail]),stderr:''}
  26. }
  27. if(args[0]==='create'){
  28. const value=(flag:string)=>args[args.indexOf(flag)+1]!
  29. const values=(flag:string)=>args.flatMap((arg,i)=>arg===flag?[args[i+1]!]:[])
  30. const workspace=/source=([^,]+)/.exec(value('--mount'))![1]
  31. detail={Id:'b'.repeat(64),Image:spec.imageId,Config:{Labels:Object.fromEntries(values('--label').map(label=>label.split('=')))},State:{Running:false},
  32. 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:{}},
  33. Mounts:values('--mount').map(mount=>({Type:'bind',Source:/source=([^,]+)/.exec(mount)![1],Destination:/target=([^,]+)/.exec(mount)![1],RW:!mount.includes(',readonly')})),NetworkSettings:{Ports:{}}}
  34. return {stdout:detail.Id,stderr:''}
  35. }
  36. if(args[0]==='start')detail.State.Running=true
  37. if(args[0]==='stop')detail.State.Running=false
  38. if(args[0]==='rm')detail=undefined
  39. return {stdout:'fixture',stderr:''}
  40. }
  41. const provider=new ContainerComputerProvider(runtime,randomUUID(),home,run,{})
  42. return {provider,spec,calls,home,run,get detail(){return detail}}
  43. }
  44. it.each(['docker','podman'] as const)('uses the same private ownership and execution boundary for %s',async runtime=>{
  45. const f=await fixture(runtime),state=await f.provider.ensure(f.spec,()=>{})
  46. expect(state).toMatchObject({running:true,isolation:'container'})
  47. await f.provider.execute(f.spec,['/bin/sh','-c','echo hello'],{authorize:()=>{}})
  48. expect(f.calls.find(args=>args[0]==='exec')).toEqual(['exec','--user','0:0','--env','HOME=/home/cua','--env','USER=cua','--workdir','/home/cua/workspace','--',state.containerId,'python3','-u','-c',GUEST_COMMAND,'run',COMMAND_ROOT,expect.any(String),'cua','30','/bin/sh','-c','echo hello'])
  49. await f.provider.execute(f.spec,['/bin/sh','-c','id -u'],{authorize:()=>{},user:'root'})
  50. expect(f.calls.find(args=>args.includes('HOME=/root'))).toEqual(['exec','--user','0:0','--env','HOME=/root','--env','USER=root','--workdir','/home/cua/workspace','--',state.containerId,'python3','-u','-c',GUEST_COMMAND,'run',COMMAND_ROOT,expect.any(String),'root','30','/bin/sh','-c','id -u'])
  51. await expect(f.provider.ensure({...f.spec,ownerKey:'different-account'},()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  52. await f.provider.remove(f.spec)
  53. expect(f.calls.find(args=>args[0]==='rm')).toEqual(['rm','--',state.containerId])
  54. })
  55. it('waits for the desktop driver on a cold start before sending a GUI command',async()=>{
  56. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  57. let checks=0
  58. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  59. if(args.includes('health_report')){
  60. checks++
  61. if(checks<3)throw Object.assign(new Error('driver starting'),{stderr:'Cua Driver daemon is not running on /run/user/1000/openmausbot-cua.sock.'})
  62. }
  63. return f.run(runtime,args,options)
  64. },{})
  65. await provider.execute(f.spec,[CUA_DRIVER,'call','get_desktop_state','{}'],{authorize:()=>{},lane:'gui'})
  66. expect(checks).toBe(3)
  67. expect(f.calls.filter(args=>args.includes('health_report'))).toHaveLength(1)
  68. expect(f.calls.some(args=>args.includes('get_desktop_state'))).toBe(true)
  69. })
  70. it.each([
  71. (d:any)=>{d.HostConfig.Privileged=true},
  72. (d:any)=>{d.HostConfig.CapAdd.push('SYS_ADMIN')},
  73. (d:any)=>{d.HostConfig.Memory=0},
  74. (d:any)=>{d.HostConfig.NetworkMode='host'},
  75. (d:any)=>{d.HostConfig.PidMode='host'},
  76. (d:any)=>{d.HostConfig.SecurityOpt=['seccomp=unconfined']},
  77. (d:any)=>{d.Mounts[0].Source='/Users/private'},
  78. (d:any)=>{d.Mounts.push({Source:'/var/run/docker.sock'})},
  79. (d:any)=>{d.HostConfig.PortBindings['6901/tcp']=[{HostIp:'127.0.0.1',HostPort:'12345'}]},
  80. (d:any)=>{d.Image=`sha256:${'c'.repeat(64)}`},
  81. ])('refuses execution when an existing container no longer matches its constraints (%#)',async mutate=>{
  82. const f=await fixture();await f.provider.ensure(f.spec,()=>{});mutate(f.detail)
  83. await expect(f.provider.execute(f.spec,['true'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_configuration_unsafe'})
  84. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  85. await f.provider.stop(f.spec)
  86. expect(f.detail.State.Running).toBe(false)
  87. })
  88. it('replaces an existing desktop created with the old no-new-privileges flag',async()=>{
  89. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  90. f.detail.HostConfig.SecurityOpt=['no-new-privileges:true'];f.detail.State.Running=true
  91. const state=await f.provider.ensure(f.spec,()=>{})
  92. expect(state.running).toBe(true)
  93. expect(f.detail.HostConfig.SecurityOpt).toEqual([])
  94. expect(f.calls.filter(args=>args[0]==='create')).toHaveLength(3)
  95. expect(f.calls.some(args=>args[0]==='rm')).toBe(true)
  96. expect(f.calls.flat().includes('no-new-privileges:true')).toBe(false)
  97. })
  98. it('does not stop or remove an identically named container belonging to another node',async()=>{
  99. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  100. f.calls.splice(0);f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()
  101. await expect(f.provider.stop(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  102. await expect(f.provider.remove(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
  103. expect(f.calls.some(args=>['stop','rm'].includes(args[0]!))).toBe(false)
  104. })
  105. it('rejects workspace links and remote engine contexts before creating a computer',async()=>{
  106. const f=await fixture(),outside=await mkdtemp(join(tmpdir(),'yaoyao-computer-outside-'));homes.push(outside)
  107. await mkdir(join(f.home,'computer-workspaces'));await symlink(outside,join(f.home,'computer-workspaces',f.spec.id))
  108. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_path_unsafe'})
  109. const remote=new ContainerComputerProvider('docker',randomUUID(),f.home,f.run,{DOCKER_HOST:'tcp://remote.invalid:2375'})
  110. await expect(remote.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_remote_engine'})
  111. expect(f.calls.some(args=>args[0]==='create')).toBe(false)
  112. })
  113. it('checks authority again after asynchronous inspection, before a guest command starts',async()=>{
  114. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  115. let active=true
  116. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  117. const result=await f.run(runtime,args,options);if(args[0]==='inspect')active=false;return result
  118. },{})
  119. await expect(provider.execute(f.spec,['true'],{authorize:()=>{if(!active)throw new Error('revoked')}})).rejects.toThrow('revoked')
  120. expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
  121. })
  122. it('recreates a migrated mount while retaining the stopped original container and persistent workspace',async()=>{
  123. const f=await fixture(),oldRoot=join(f.home,'.hermes-yaoyao'),newRoot=join(f.home,'.yaoyao')
  124. const oldHome=join(oldRoot,'runner-state/r1'),newHome=join(newRoot,'runner-state/r1')
  125. await mkdir(oldHome,{recursive:true})
  126. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,oldHome,f.run,{})
  127. await provider.ensure(f.spec,()=>{});await provider.stop(f.spec);f.calls.splice(0)
  128. await writeFile(join(oldHome,'computer-workspaces',f.spec.id,'keep.txt'),'keep workspace')
  129. await rename(oldRoot,newRoot)
  130. await writeFile(join(newRoot,'.data-home-migration.json'),JSON.stringify({source:oldRoot,target:newRoot,phase:'complete'}))
  131. let renamed=false
  132. const next=new ContainerComputerProvider('docker',f.provider.runnerId,newHome,async(runtime,args,options)=>{
  133. if(args[0]==='rename'){renamed=true;expect(args[1]).toBe(f.detail.Id);expect(args[2]).toContain('-before-data-move-');return {stdout:'',stderr:''}}
  134. if(args[0]==='inspect'&&renamed&&args[1]!==f.detail.Id)throw Object.assign(new Error('missing'),{stderr:'No such object'})
  135. if(args[0]==='create')renamed=false
  136. return f.run(runtime,args,options)
  137. },{})
  138. const state=await next.ensure(f.spec,()=>{})
  139. expect(state.workspace).toContain('/.yaoyao/')
  140. await expect((await import('node:fs/promises')).readFile(join(state.workspace,'keep.txt'),'utf8')).resolves.toBe('keep workspace')
  141. expect(f.calls.filter(args=>args[0]==='rm')).toHaveLength(0)
  142. })
  143. it('runs command lanes concurrently, serializes desktop input and drains every lane before lifecycle operations',async()=>{
  144. const order:string[]=[],held:Array<()=>void>=[]
  145. let active=0,peak=0
  146. const f=await fixture('docker',async args=>{
  147. if(args[0]==='exec'){
  148. const marker=String(args.at(-1))
  149. active++;peak=Math.max(peak,active)
  150. try{
  151. if(marker.startsWith('hold'))await new Promise<void>(resolve=>held.push(resolve))
  152. order.push(`exec:${marker}`)
  153. }finally{active--}
  154. }
  155. if(args[0]==='stop'||args[0]==='rm')order.push(args[0]!)
  156. })
  157. await f.provider.ensure(f.spec,()=>{})
  158. // File commands from different holders overlap on the exec lane.
  159. const slowFile=f.provider.execute(f.spec,['/bin/sh','-c','hold-a'],{authorize:()=>{}})
  160. await f.provider.execute(f.spec,['/bin/sh','-c','plain-b'],{authorize:()=>{}})
  161. expect(order).toContain('exec:plain-b')
  162. // Desktop input is serialized: the second GUI action waits for the first.
  163. const slowGui=f.provider.execute(f.spec,['driver','hold-gui-1'],{authorize:()=>{},lane:'gui'})
  164. let guiTwoDone=false
  165. const guiTwo=f.provider.execute(f.spec,['driver','plain-gui-2'],{authorize:()=>{},lane:'gui'}).then(()=>{guiTwoDone=true})
  166. await new Promise(resolve=>setTimeout(resolve,25))
  167. expect(guiTwoDone).toBe(false)
  168. // A lifecycle operation drains both lanes before touching the container.
  169. let stopped=false
  170. const stopping=f.provider.stop(f.spec).then(()=>{stopped=true})
  171. await new Promise(resolve=>setTimeout(resolve,25))
  172. expect(stopped).toBe(false)
  173. for(const release of held.splice(0))release()
  174. await Promise.all([slowFile,slowGui,guiTwo,stopping])
  175. expect(guiTwoDone).toBe(true)
  176. expect(peak).toBeGreaterThanOrEqual(2)
  177. expect(order.indexOf('exec:plain-gui-2')).toBeGreaterThan(order.indexOf('exec:hold-gui-1'))
  178. expect(order.indexOf('stop')).toBeGreaterThan(order.indexOf('exec:plain-gui-2'))
  179. })
  180. it('keeps a shared desktop alive when one holder cancels an unacknowledged operation',async()=>{
  181. let stopped=0
  182. const f=await fixture('docker',async args=>{
  183. if(args[0]==='stop')stopped++
  184. if(args[0]==='exec'&&String(args.at(-1)).startsWith('boom'))throw Object.assign(new Error('unacknowledged'),{killed:true})
  185. })
  186. await f.provider.ensure(f.spec,()=>{})
  187. // Another holder still shares the desktop: the ambiguous operation must not close it.
  188. await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-shared'],{authorize:()=>{},mayFence:()=>false})).rejects.toMatchObject({code:'computer_cancel_uncertain'})
  189. await new Promise(resolve=>setTimeout(resolve,25))
  190. expect(stopped).toBe(0)
  191. await f.provider.execute(f.spec,['/bin/sh','-c','after'],{authorize:()=>{}})
  192. // A sole holder keeps the original hard fence.
  193. await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-alone'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_cancel_uncertain'})
  194. await new Promise(resolve=>setTimeout(resolve,25))
  195. expect(stopped).toBe(1)
  196. expect(f.calls.some(args=>args.includes(CLOSE_DESKTOP))).toBe(false)
  197. })
  198. it('requests application shutdown before a planned container stop',async()=>{
  199. const f=await fixture();await f.provider.ensure(f.spec,()=>{});f.calls.splice(0)
  200. await f.provider.stop(f.spec)
  201. const close=f.calls.findIndex(args=>args.includes(CLOSE_DESKTOP)),stop=f.calls.findIndex(args=>args[0]==='stop')
  202. expect(close).toBeGreaterThanOrEqual(0);expect(stop).toBeGreaterThan(close)
  203. expect(f.calls[close]).toContain('1000:1000')
  204. })
  205. it('requires a guest stop acknowledgement for a cancelled command and preserves another holder',async()=>{
  206. const f=await fixture(),abort=new AbortController(),calls:string[][]=[]
  207. await f.provider.ensure(f.spec,()=>{})
  208. let acknowledge:()=>void=()=>{}
  209. const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  210. calls.push(args)
  211. if(args.includes(GUEST_COMMAND)&&args.includes('run')){abort.abort();return {stdout:'late',stderr:''}}
  212. if(args.includes(GUEST_COMMAND)&&args.includes('cancel')){
  213. expect(options?.signal).toBeUndefined()
  214. await new Promise<void>(resolve=>{acknowledge=resolve})
  215. return {stdout:JSON.stringify({stopped:true}),stderr:''}
  216. }
  217. return f.run(runtime,args,options)
  218. },{})
  219. let finished=false
  220. const execution=provider.execute(f.spec,['work'],{authorize:()=>{},signal:abort.signal,mayFence:()=>false}).catch(error=>{finished=true;return error})
  221. await vi.waitFor(()=>expect(calls.some(args=>args.includes('cancel'))).toBe(true))
  222. expect(finished).toBe(false)
  223. const running=calls.find(args=>args.includes('run'))!,cancelled=calls.find(args=>args.includes('cancel'))!
  224. expect(cancelled.at(-1)).toBe(running[running.indexOf(COMMAND_ROOT)+1])
  225. acknowledge()
  226. expect(await execution).toMatchObject({code:'computer_cancelled'})
  227. expect(calls.some(args=>args[0]==='stop')).toBe(false)
  228. })
  229. it('retains a stopped legacy container when copying private data fails, then migrates on retry',async()=>{
  230. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  231. f.detail.Mounts=f.detail.Mounts.filter((m:any)=>m.Destination==='/home/cua/workspace')
  232. delete f.detail.Config.Labels['cn.samien.yaoyao.userdata']
  233. await rm(join(f.home,'computer-userdata',f.spec.id),{recursive:true});await rm(join(f.home,'computer-userdata',f.spec.id)+'.required');f.calls.splice(0)
  234. const broken=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  235. if(args[0]==='cp')throw Object.assign(new Error('disk full'),{stderr:'private file detail'})
  236. return f.run(runtime,args,options)
  237. },{})
  238. await expect(broken.remove(f.spec)).rejects.toThrow('原容器已保留')
  239. expect(f.detail.State.Running).toBe(false)
  240. expect(f.calls.some(args=>args[0]==='rm')).toBe(false)
  241. const retry=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
  242. if(args[0]==='cp'&&args[1]?.endsWith('machine-id')){await writeFile(args[2]!, 'f'.repeat(32)+'\n');return {stdout:'',stderr:''}}
  243. return f.run(runtime,args,options)
  244. },{})
  245. await retry.ensure(f.spec,()=>{})
  246. expect(f.detail.State.Running).toBe(true)
  247. expect(f.detail.Mounts.some((m:any)=>m.Destination==='/home/cua')).toBe(true)
  248. expect(await (await import('node:fs/promises')).readFile(join(f.home,'computer-userdata',f.spec.id,'machine-id'),'utf8')).toBe('f'.repeat(32)+'\n')
  249. })
  250. it('keeps user data on remove and deletes it only with the explicit data deletion',async()=>{
  251. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  252. const data=join(f.home,'computer-userdata',f.spec.id)
  253. await writeFile(join(data,'home','authorization'),'fixture')
  254. await f.provider.remove(f.spec)
  255. expect(await (await import('node:fs/promises')).readFile(join(data,'home','authorization'),'utf8')).toBe('fixture')
  256. await f.provider.ensure(f.spec,()=>{})
  257. await expect(f.provider.deleteWorkspace(f.spec)).rejects.toMatchObject({code:'computer_busy'})
  258. await f.provider.remove(f.spec);await f.provider.deleteWorkspace(f.spec)
  259. await expect((await import('node:fs/promises')).lstat(data)).rejects.toMatchObject({code:'ENOENT'})
  260. })
  261. it('refuses replacing missing, linked or differently owned user profiles with an empty profile',async()=>{
  262. const f=await fixture();await f.provider.ensure(f.spec,()=>{});await f.provider.remove(f.spec)
  263. const data=join(f.home,'computer-userdata',f.spec.id)
  264. await writeFile(join(data,'identity.json'),JSON.stringify({protocol:1,environmentId:f.spec.id,owner:'different'}))
  265. f.calls.splice(0)
  266. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('归属不匹配')
  267. expect(f.calls.some(args=>args[0]==='create')).toBe(false)
  268. await rm(data,{recursive:true});await symlink(f.home,data)
  269. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('目录不安全')
  270. })
  271. it('backs up the old image data before a different image can open application databases',async()=>{
  272. const f=await fixture();await f.provider.ensure(f.spec,()=>{})
  273. const fs=await import('node:fs/promises'),data=join(f.home,'computer-userdata',f.spec.id),oldImage=f.spec.imageId
  274. await writeFile(join(data,'home','authorization'),'old-image-credential')
  275. await f.provider.remove(f.spec);f.spec.imageId='sha256:'+'c'.repeat(64)
  276. await f.provider.ensure(f.spec,()=>{})
  277. const backups=join(f.home,'computer-backups',f.spec.id),names=await fs.readdir(backups)
  278. expect(names).toHaveLength(1)
  279. const snapshot=join(backups,names[0]!)
  280. expect(JSON.parse(await fs.readFile(join(snapshot,'snapshot.json'),'utf8')).imageId).toBe(oldImage)
  281. expect(await fs.readFile(join(snapshot,'userdata','home','authorization'),'utf8')).toBe('old-image-credential')
  282. await writeFile(join(data,'home','authorization'),'upgraded')
  283. await f.provider.remove(f.spec);await f.provider.ensure(f.spec,()=>{})
  284. expect(await fs.readdir(backups)).toEqual(names)
  285. expect(await fs.readFile(join(snapshot,'userdata','home','authorization'),'utf8')).toBe('old-image-credential')
  286. })
  287. it('refuses to silently reseed a previously persisted environment after its data disappears',async()=>{
  288. const f=await fixture();await f.provider.ensure(f.spec,()=>{});await f.provider.remove(f.spec)
  289. await rm(join(f.home,'computer-userdata',f.spec.id),{recursive:true});f.calls.splice(0)
  290. await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('用户资料缺失')
  291. expect(f.calls.some(args=>args[0]==='create')).toBe(false)
  292. })