localVm.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {ComputerRuntime} from '../../src/runner/worker/gateway'
  9. import {LocalVmImages} from '../../src/runner/computers/localVm'
  10. import {UNCONFIGURED_COMPUTER_IMAGE} from '../../src/shared/runner'
  11. async function fixture(){
  12. const home=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-')),db=new DatabaseSync(':memory:')
  13. 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:UNCONFIGURED_COMPUTER_IMAGE,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}},home)
  14. await runtime.ready
  15. const running=new Set<string>()
  16. vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue()
  17. vi.spyOn(runtime.provider,'ensure').mockImplementation(async s=>{running.add(s.id);return {id:s.id,containerId:s.id,workspace:'/fixture',running:true,isolation:'container'}})
  18. vi.spyOn(runtime.provider,'stop').mockImplementation(async s=>{running.delete(s.id)})
  19. vi.spyOn(runtime.provider,'remove').mockImplementation(async s=>{running.delete(s.id)})
  20. vi.spyOn(runtime.provider,'health').mockResolvedValue({})
  21. vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1280,height:900,capturedAt:Date.now()})
  22. vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
  23. return {db,runtime,running,async close(){await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}}
  24. }
  25. it('defaults older settings to five minutes and persists never-stop independently of sharing policy',async()=>{
  26. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db)
  27. try{
  28. await manager.ready;expect((await manager.status()).idleStopMinutes).toBe(5)
  29. await manager.idlePolicy(0);await manager.policy('shared',3)
  30. expect(f.runtime.pool.idleStopMinutes).toBe(0)
  31. expect(JSON.parse(String(f.db.prepare('SELECT value FROM local_vm_runtime').get()?.value)).idleStopMinutes).toBe(0)
  32. for(const minutes of [-1,1.5,10081,NaN])await expect(manager.idlePolicy(minutes)).rejects.toMatchObject({code:'local_vm_idle_policy_invalid'})
  33. await manager.close();const restored=new LocalVmImages(f.runtime,f.db)
  34. await restored.ready;expect((await restored.status()).idleStopMinutes).toBe(0)
  35. await restored.idlePolicy(30);expect(f.runtime.pool.idleStopMinutes).toBe(30);await restored.close()
  36. }finally{await manager.close();await f.close()}
  37. })
  38. it('prepares one managed image, verifies the desktop, applies it and persists it without a catalog step',async()=>{
  39. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  40. try{
  41. vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
  42. vi.spyOn(manager.images,'inspect').mockImplementation(async id=>{if(id!==imageId)throw new Error('not ready');return {protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1}})
  43. await manager.ready
  44. expect((await manager.status()).image).toBe(false)
  45. const id=randomUUID(),check=vi.fn(async()=>{})
  46. await manager.prepare(id,check)
  47. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
  48. expect(f.runtime.config.imageId).toBe(imageId)
  49. expect(manager.images.run).toHaveBeenCalledWith('docker',expect.arrayContaining(['build','--file',expect.stringContaining('Dockerfile.network'),'--tag','localhost/yaoyao/network:1']))
  50. expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
  51. expect(f.running.size).toBe(0)
  52. await manager.prepare(id,check)
  53. expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
  54. await manager.close()
  55. const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
  56. expect(f.runtime.config.imageId).toBe(imageId);await resumed.close()
  57. }finally{await manager.close();await f.close()}
  58. })
  59. it('retains completed desktop state but fences the old lease and counts idle desktops against capacity',async()=>{
  60. const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  61. try{
  62. await f.runtime.pool.desktop(spec,'create',()=>{})
  63. expect(f.running.has(spec.id)).toBe(true)
  64. expect((await f.runtime.controls.status({agentId:spec.id,environmentId:spec.id,ownerKey:'owner'})).mode).toBe('idle')
  65. const lease=await f.runtime.pool.acquire(spec,'turn-1',()=>{})
  66. await f.runtime.pool.release(lease,true)
  67. expect(()=>f.runtime.pool.use(lease,async()=>{})).toThrow()
  68. const second={...spec,id:randomUUID()};await f.runtime.pool.desktop(second,'create',()=>{})
  69. await expect(f.runtime.pool.desktop({...spec,id:randomUUID()},'create',()=>{})).rejects.toMatchObject({code:'computer_quota'})
  70. await expect(f.runtime.pool.desktop({...spec,ownerKey:'other'},'remove',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  71. await f.runtime.pool.desktop(spec,'stop',()=>{});expect(f.running.has(spec.id)).toBe(false)
  72. await f.runtime.pool.desktop(spec,'start',()=>{});expect(f.running.has(spec.id)).toBe(true)
  73. await f.runtime.pool.desktop(spec,'recreate',()=>{});expect(f.running.has(spec.id)).toBe(true)
  74. await f.runtime.pool.desktop(spec,'remove',()=>{});expect(f.runtime.pool.definition('owner',spec.id)).toBeUndefined()
  75. expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
  76. }finally{await f.close()}
  77. })
  78. it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
  79. const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  80. try{
  81. const lease=await f.runtime.pool.acquire(spec,'human:test',()=>{})
  82. for(const action of ['stop','remove','recreate'] as const)await expect(f.runtime.pool.desktop(spec,action,()=>{})).rejects.toMatchObject({code:'computer_busy'})
  83. await f.runtime.pool.release(lease)
  84. }finally{await f.close()}
  85. })
  86. it('restores only existing stopped desktops with their saved image and cwd, keeping idle policy and capacity',async()=>{
  87. const f=await fixture(),id=randomUUID(),meta={agentId:id,environmentId:id,ownerKey:'owner'},spec={id,ownerKey:'owner',imageId:'sha256:'+'b'.repeat(64),cwd:'/home/cua/workspace/saved',network:'none' as const}
  88. try{
  89. f.runtime.pool.idleStopMinutes=0
  90. await f.runtime.restoreDesktop(meta,()=>{})
  91. expect(f.runtime.provider.ensure).not.toHaveBeenCalled()
  92. await f.runtime.pool.desktop(spec,'create',()=>{});await f.runtime.pool.desktop(spec,'stop',()=>{})
  93. vi.mocked(f.runtime.provider.ensure).mockClear()
  94. await f.runtime.restoreDesktop(meta,()=>{})
  95. expect(f.runtime.provider.ensure).toHaveBeenCalledWith(expect.objectContaining({imageId:spec.imageId,cwd:spec.cwd}),expect.any(Function))
  96. expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle');expect(f.runtime.pool.idleStopMinutes).toBe(0)
  97. await f.runtime.restoreDesktop(meta,()=>{})
  98. expect(f.runtime.provider.ensure).toHaveBeenCalledOnce()
  99. const lease=await f.runtime.pool.acquire(f.runtime.pool.definition('owner',id)!,'task',()=>{})
  100. await f.runtime.restoreDesktop(meta,()=>{})
  101. expect(f.runtime.pool.status('owner')[0]?.holderIds).toEqual(['task'])
  102. await f.runtime.pool.release(lease)
  103. const other={...spec,id:randomUUID()};f.runtime.pool.limits.concurrent=1
  104. await f.runtime.pool.desktop(other,'start',()=>{})
  105. await expect(f.runtime.restoreDesktop(meta,()=>{})).rejects.toMatchObject({code:'computer_quota'})
  106. await f.runtime.pool.desktop(other,'stop',()=>{})
  107. const removing=f.runtime.pool.desktop(spec,'remove',()=>{}),restoring=f.runtime.restoreDesktop(meta,()=>{})
  108. await removing;await restoring
  109. expect(f.runtime.pool.definition('owner',id)).toBeUndefined()
  110. }finally{await f.close()}
  111. })
  112. it('allows sharing policy changes with idle desktops but preserves active leases and image maintenance guards',async()=>{
  113. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  114. try{
  115. await manager.ready
  116. await f.runtime.pool.desktop(spec,'create',()=>{})
  117. expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle')
  118. await manager.policy('shared',3)
  119. expect(f.running.has(spec.id)).toBe(true)
  120. expect((await manager.status()).mode).toBe('shared')
  121. expect(f.runtime.pool.limits.concurrent).toBe(3)
  122. expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
  123. const lease=await f.runtime.pool.acquire(spec,'human:fixture',()=>{})
  124. await expect(manager.policy('per-bot',2)).rejects.toMatchObject({code:'computer_busy'})
  125. expect((await manager.status()).mode).toBe('shared')
  126. expect(f.runtime.pool.limits.concurrent).toBe(3)
  127. await f.runtime.pool.release(lease,true)
  128. await manager.policy('per-bot',2)
  129. expect((await manager.status()).mode).toBe('per-bot')
  130. expect(f.running.has(spec.id)).toBe(true)
  131. await f.runtime.pool.desktop(spec,'stop',()=>{})
  132. const release=f.runtime.pool.beginMaintenance();release()
  133. }finally{await manager.close();await f.close()}
  134. })
  135. it('creates without a model and can stop or remove an existing desktop after Profile resolution fails',async()=>{
  136. const f=await fixture(),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'owner'}
  137. try{
  138. f.runtime.config.imageId='sha256:'+'a'.repeat(64)
  139. const model=vi.spyOn(f.runtime,'resolve').mockRejectedValue(new Error('model unavailable'))
  140. const workspace=vi.spyOn(f.runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
  141. await f.runtime.desktop(meta,'default','create',()=>{})
  142. expect(f.running.has(meta.environmentId)).toBe(true)
  143. expect(f.runtime.pool.definition('owner',meta.environmentId)?.cwd).toBe('/home/cua/workspace')
  144. workspace.mockRejectedValue(new Error('Profile was removed'))
  145. await f.runtime.desktop(meta,'default','stop',()=>{})
  146. expect(f.running.has(meta.environmentId)).toBe(false)
  147. await f.runtime.desktop(meta,'default','remove',()=>{})
  148. expect(f.runtime.pool.definition('owner',meta.environmentId)).toBeUndefined()
  149. expect(workspace).toHaveBeenCalledOnce()
  150. expect(model).not.toHaveBeenCalled()
  151. expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
  152. }finally{await f.close()}
  153. })
  154. it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime recovers',async()=>{
  155. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  156. let canStop=false
  157. try{
  158. await manager.ready
  159. vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
  160. vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
  161. vi.mocked(f.runtime.provider.remove).mockImplementation(async()=>{if(!canStop)throw new Error('stop uncertain')})
  162. await manager.prepare(randomUUID(),async()=>{})
  163. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
  164. expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
  165. expect(f.runtime.config.imageId).toBe(UNCONFIGURED_COMPUTER_IMAGE)
  166. canStop=true;expect((await manager.status()).busy).toBe(false)
  167. const release=f.runtime.pool.beginMaintenance();release()
  168. }finally{canStop=true;await manager.close();await f.close()}
  169. })
  170. it('keeps two prepared images and binds one immutable image per independent or shared desktop',async()=>{
  171. const f=await fixture(),standard='sha256:'+'a'.repeat(64),cursor='sha256:'+'b'.repeat(64)
  172. f.runtime.config.imageId=standard
  173. const manager=new LocalVmImages(f.runtime,f.db)
  174. const manifest=(id:string)=>({protocol:1 as const,imageId:id,architecture:'amd64' as const,driver:'0.20.0' as const,layer:'5' as const,imageKey:id===cursor?'cursor' as const:'standard' as const,createdAt:1})
  175. try{
  176. vi.spyOn(manager.images,'inspect').mockImplementation(async id=>manifest(id))
  177. vi.spyOn(manager.images,'run').mockResolvedValue(standard+'\n'+cursor)
  178. await manager.prepare(randomUUID(),async()=>{},'cursor')
  179. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
  180. expect((await manager.status()).images?.filter(i=>i.ready).map(i=>i.key)).toEqual(['standard','cursor'])
  181. expect(f.runtime.config.imageId).toBe(standard)
  182. const a={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'},b={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'}
  183. await manager.select(a,'standard',()=>{})
  184. await manager.select(b,'cursor',()=>{})
  185. expect(f.runtime.imageFor(a)).toBe(standard);expect(f.runtime.imageFor(b)).toBe(cursor)
  186. expect(f.runtime.imageFor({...b,agentId:randomUUID()})).toBe(cursor)
  187. await expect(manager.select({...b,ownerKey:'bob'},'standard',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  188. vi.spyOn(f.runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
  189. await f.runtime.desktop(a,'default','create',()=>{})
  190. await f.runtime.desktop(b,'default','create',()=>{})
  191. expect(f.running.size).toBe(2)
  192. expect(f.runtime.pool.definition('alice',a.environmentId)?.imageId).toBe(standard)
  193. expect(f.runtime.pool.definition('alice',b.environmentId)?.imageId).toBe(cursor)
  194. await expect(manager.select(b,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
  195. await f.runtime.desktop(b,'default','stop',()=>{})
  196. await expect(manager.select(b,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
  197. await f.runtime.desktop(b,'default','remove',()=>{})
  198. expect(f.runtime.imageFor(b)).toBe(cursor)
  199. await manager.select(b,'standard',()=>{})
  200. expect(f.runtime.imageFor({...b,agentId:randomUUID()})).toBe(standard)
  201. expect(f.runtime.provider.deleteWorkspace).toHaveBeenCalledTimes(2) // Only preparation scratch space.
  202. await manager.close()
  203. const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
  204. expect(f.runtime.imageFor(b)).toBe(standard)
  205. vi.spyOn(resumed.images,'inspect').mockImplementation(async id=>manifest(id))
  206. expect((await resumed.options()).filter(i=>i.ready)).toHaveLength(2)
  207. await resumed.close()
  208. }finally{await manager.close();await f.close()}
  209. })
  210. it('does not select an unprepared image or mutate a choice after authorization expires',async()=>{
  211. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'}
  212. try{
  213. await expect(manager.select(meta,'cursor',()=>{})).rejects.toMatchObject({code:'computer_image_required'})
  214. expect(f.runtime.imageFor(meta)).toBe(UNCONFIGURED_COMPUTER_IMAGE)
  215. const denied=()=>{throw new Error('expired')}
  216. await expect(manager.select(meta,'standard',denied)).rejects.toThrow('expired')
  217. expect(f.db.prepare('SELECT * FROM computer_image_bindings').all()).toEqual([])
  218. }finally{await manager.close();await f.close()}
  219. })
  220. it('does not fall back to the standard recipe when Cursor preparation fails',async()=>{
  221. const f=await fixture(),standard='sha256:'+'a'.repeat(64)
  222. f.runtime.config.imageId=standard
  223. const manager=new LocalVmImages(f.runtime,f.db)
  224. try{
  225. vi.spyOn(manager.images,'run').mockResolvedValue(standard)
  226. vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId:standard,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
  227. const prepare=vi.spyOn(manager.images,'prepare').mockRejectedValue(new Error('cursor build failed'))
  228. const id=randomUUID()
  229. await manager.prepare(id,async()=>{},'cursor')
  230. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
  231. expect(prepare).toHaveBeenCalledWith(expect.any(String),'cursor')
  232. expect(f.runtime.config.imageId).toBe(standard)
  233. expect((await manager.options()).find(i=>i.key==='cursor')?.ready).toBe(false)
  234. await expect(manager.prepare(id,async()=>{},'standard')).rejects.toMatchObject({code:'idempotency_conflict'})
  235. }finally{await manager.close();await f.close()}
  236. })