localVmRoutes.test.ts 8.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  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. await request(app.callback()).put(`/api/app/agents/${second.id}/local-vm/image`).send({imageKey:'standard'}).expect(200)
  51. expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({computerEnvironmentId:a.computerEnvironmentId}),'image',{imageKey:'standard'},expect.any(Function))
  52. expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
  53. const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
  54. expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)
  55. const disabled=await request(app.callback()).put(`/api/app/agents/${later.id}/local-vm`).send({enabled:false}).expect(200)
  56. expect(disabled.body.agent.execution).toBe('profile');expect(disabled.body.agent.computerEnvironmentId).toBeUndefined()
  57. expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBe(a.computerEnvironmentId)
  58. expect(store.require<any>('bob','agent',other.id).computerEnvironmentId).toBeUndefined()
  59. await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'per-bot',maxInstances:2}).expect(200)
  60. expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBeUndefined()
  61. expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
  62. store.put('alice','turn','active-vm',{agentId:first.id,status:'uncertain'})
  63. hub.localVm.mockClear()
  64. const blocked=await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(409)
  65. expect(blocked.body.error).toContain('请先停止「甲」')
  66. expect(hub.localVm).not.toHaveBeenCalled()
  67. store.remove('alice','turn','active-vm')
  68. const helper=store.createAgent('alice',{name:'临时任务助手',profile:'default',execution:'computer'},{createdByAgentId:first.id,createdFromRunId:randomUUID(),temporaryGoalId:randomUUID()})
  69. await request(app.callback()).post(`/api/app/agents/${helper.id}/local-vm/create`).send({}).expect(409)
  70. active=false;expect(service.allowed(id,runner.id)).toBe(false)
  71. }finally{store.close();rmSync(home,{recursive:true,force:true})}
  72. })
  73. it('uses the external Runner in Docker and reports missing, offline and incompatible nodes without probing the Web container',async()=>{
  74. const home=mkdtempSync(join(tmpdir(),'local-vm-docker-')),store=new WorkspaceStore(home)
  75. let records:any[]=[],online=false,features:string[]=[]
  76. const auth:any={requireAdmin:()=>({id:'alice'})},nodes:any={open:vi.fn(),requireSource:vi.fn()}
  77. 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()}
  78. const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_LOCAL_VM_HOST:'runner'})
  79. const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,config)
  80. const discover=vi.spyOn(service as any,'discover')
  81. 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())
  82. try{
  83. expect((await service.status('alice'))).toMatchObject({configured:false,executionHost:'runner',setupRequired:'runner'})
  84. await request(app.callback()).post('/api/app/admin/local-vm/prepare').send({requestId:randomUUID()}).expect(409,{code:'local_vm_runner_required'})
  85. expect(hub.enroll).not.toHaveBeenCalled()
  86. records=[{id:randomUUID(),name:'Hermes 所在电脑',enabled:true,sourceNodeId:'local',sourceOwner:'_system'}]
  87. expect(await service.status('alice')).toMatchObject({setupRequired:'runner',runnerName:'Hermes 所在电脑'})
  88. online=true
  89. expect(await service.status('alice')).toMatchObject({setupRequired:'worker'})
  90. features=['local-vm-v1']
  91. expect(await service.status('alice')).toMatchObject({executionHost:'runner',image:true,daemonUp:true})
  92. // Migrated desktop state must not launch a host-specific Python path in Docker.
  93. store.put('_system','local-vm-managed','local',{sealed:'unusable-desktop-config'})
  94. await service.start('http://127.0.0.1:15300')
  95. expect(nodes.open).not.toHaveBeenCalled();expect(discover).not.toHaveBeenCalled()
  96. }finally{await service.close();store.close();rmSync(home,{recursive:true,force:true})}
  97. })