localVmRoutes.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import Koa from 'koa'
  4. import {bodyParser} from '@koa/bodyparser'
  5. import request from 'supertest'
  6. import {mkdtempSync,rmSync} from 'node:fs'
  7. import {tmpdir} from 'node:os'
  8. import {join} from 'node:path'
  9. import {randomUUID} from 'node:crypto'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {LocalVmService} from '../../src/server/localVm'
  12. import {SharedComputers} from '../../src/server/sharedComputers'
  13. import {HttpError} from '../../src/server/errors'
  14. import {loadServerConfig} from '../../src/server/config'
  15. it('protects preparation, removes the old catalog API and scopes sharing to the account and profile',async()=>{
  16. const home=mkdtempSync(join(tmpdir(),'local-vm-routes-')),store=new WorkspaceStore(home)
  17. let version=1,active=true
  18. const auth:any={require:(ctx:any)=>({id:ctx.get('x-owner')||'alice'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes'||!active)throw new HttpError(403,'需要管理员权限','admin_required');return {id:'alice'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
  19. const runner={id:randomUUID(),enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
  20. const nodes:any={requireSource:vi.fn()}
  21. const hub:any={records:()=>[runner],summary:()=>({online:true,features:['local-vm-v1']}),localVm:vi.fn(async()=>({configured:true,daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),computer:vi.fn(async()=>({container:'running',ready:true}))}
  22. const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,{home} as any),app=new Koa()
  23. app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
  24. try{
  25. const first=store.createAgent('alice',{name:'甲',profile:'default',execution:'computer'}),second=store.createAgent('alice',{name:'乙',profile:'default',execution:'computer'})
  26. const chatting=store.createAgent('alice',{name:'普通聊天成员',profile:'server'})
  27. store.put('alice','turn','ordinary-chat',{agentId:chatting.id,status:'running'})
  28. const other=store.createAgent('bob',{name:'其他账号',profile:'default',execution:'computer'})
  29. const id=randomUUID(),base='/api/app/admin/local-vm'
  30. await request(app.callback()).put(base+'/idle-policy').send({requestId:randomUUID(),idleStopMinutes:0}).expect(403)
  31. for(const minutes of [-1,0.5,10081,null])await request(app.callback()).put(base+'/idle-policy').set('x-admin','yes').send({requestId:randomUUID(),idleStopMinutes:minutes}).expect(400)
  32. await request(app.callback()).put(base+'/idle-policy').set('x-admin','yes').send({requestId:randomUUID(),idleStopMinutes:0}).expect(200)
  33. expect(hub.localVm).toHaveBeenLastCalledWith('alice',runner.id,expect.objectContaining({op:'idle-policy',idleStopMinutes:0}))
  34. await request(app.callback()).post(base+'/prepare').send({requestId:id}).expect(403)
  35. await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id,imageId:'arbitrary'}).expect(400)
  36. await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id}).expect(200)
  37. await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:randomUUID(),imageKey:'cursor'}).expect(200)
  38. expect(hub.localVm).toHaveBeenLastCalledWith('alice',runner.id,expect.objectContaining({op:'prepare',imageKey:'cursor'}))
  39. await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'cursor'}).expect(200)
  40. expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({id:first.id}),'image',{imageKey:'cursor'},expect.any(Function))
  41. await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'arbitrary'}).expect(400)
  42. await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).set('x-owner','bob').send({imageKey:'cursor'}).expect(404)
  43. expect(service.allowed(id,runner.id)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
  44. version++;expect(service.allowed(id,runner.id)).toBe(false);version--
  45. await request(app.callback()).get(`/api/app/admin/runners/${runner.id}/images`).set('x-admin','yes').expect(404)
  46. await request(app.callback()).get(`/api/app/agents/${first.id}/local-vm`).set('x-owner','bob').expect(404)
  47. await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(200)
  48. const a=store.require<any>('alice','agent',first.id),b=store.require<any>('alice','agent',second.id)
  49. expect(a.computerEnvironmentId).toBeTruthy();expect(b.computerEnvironmentId).toBe(a.computerEnvironmentId)
  50. // Switching isolation stops the per-bot desktops these members no longer use,
  51. // so they cannot occupy the concurrency quota and block the shared desktop.
  52. expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:first.id,action:'stop'}))
  53. expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:second.id,action:'stop'}))
  54. await request(app.callback()).put(`/api/app/agents/${second.id}/local-vm/image`).send({imageKey:'standard'}).expect(200)
  55. expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({computerEnvironmentId:a.computerEnvironmentId}),'image',{imageKey:'standard'},expect.any(Function))
  56. expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
  57. const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
  58. expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)
  59. const disabled=await request(app.callback()).put(`/api/app/agents/${later.id}/local-vm`).send({enabled:false}).expect(200)
  60. expect(disabled.body.agent.execution).toBe('profile');expect(disabled.body.agent.computerEnvironmentId).toBeUndefined()
  61. expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBe(a.computerEnvironmentId)
  62. expect(store.require<any>('bob','agent',other.id).computerEnvironmentId).toBeUndefined()
  63. await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'per-bot',maxInstances:2}).expect(200)
  64. expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBeUndefined()
  65. expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:a.computerEnvironmentId,action:'stop'}))
  66. expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
  67. store.put('alice','turn','active-vm',{agentId:first.id,status:'uncertain'})
  68. hub.localVm.mockClear()
  69. const blocked=await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(409)
  70. expect(blocked.body.error).toContain('请先停止「甲」')
  71. expect(hub.localVm).not.toHaveBeenCalled()
  72. store.remove('alice','turn','active-vm')
  73. const helper=store.createAgent('alice',{name:'临时任务助手',profile:'default',execution:'computer'},{createdByAgentId:first.id,createdFromRunId:randomUUID(),temporaryGoalId:randomUUID()})
  74. await request(app.callback()).post(`/api/app/agents/${helper.id}/local-vm/create`).send({}).expect(409)
  75. active=false;expect(service.allowed(id,runner.id)).toBe(false)
  76. }finally{store.close();rmSync(home,{recursive:true,force:true})}
  77. })
  78. it('uses the external Runner in Docker and reports missing, offline and incompatible nodes without probing the Web container',async()=>{
  79. const home=mkdtempSync(join(tmpdir(),'local-vm-docker-')),store=new WorkspaceStore(home)
  80. let records:any[]=[],online=false,features:string[]=[]
  81. const auth:any={requireAdmin:()=>({id:'alice'})},nodes:any={open:vi.fn(),requireSource:vi.fn()}
  82. const hub:any={records:()=>records,summary:()=>({online,features}),localVm:vi.fn(async()=>({configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),enroll:vi.fn()}
  83. const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_LOCAL_VM_HOST:'runner'})
  84. const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,config)
  85. const discover=vi.spyOn(service as any,'discover')
  86. const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={code:(e as any).code}}});app.use(bodyParser());app.use(service.router().routes())
  87. try{
  88. expect((await service.status('alice'))).toMatchObject({configured:false,executionHost:'runner',setupRequired:'runner'})
  89. await request(app.callback()).post('/api/app/admin/local-vm/prepare').send({requestId:randomUUID()}).expect(409,{code:'local_vm_runner_required'})
  90. expect(hub.enroll).not.toHaveBeenCalled()
  91. records=[{id:randomUUID(),name:'Hermes 所在电脑',enabled:true,sourceNodeId:'local',sourceOwner:'_system'}]
  92. expect(await service.status('alice')).toMatchObject({setupRequired:'runner',runnerName:'Hermes 所在电脑'})
  93. online=true
  94. expect(await service.status('alice')).toMatchObject({setupRequired:'worker'})
  95. features=['local-vm-v1']
  96. expect(await service.status('alice')).toMatchObject({executionHost:'runner',image:true,daemonUp:true})
  97. // Migrated desktop state must not launch a host-specific Python path in Docker.
  98. store.put('_system','local-vm-managed','local',{sealed:'unusable-desktop-config'})
  99. await service.start('http://127.0.0.1:15300')
  100. expect(nodes.open).not.toHaveBeenCalled();expect(discover).not.toHaveBeenCalled()
  101. }finally{await service.close();store.close();rmSync(home,{recursive:true,force:true})}
  102. })