sharedComputers.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. // @vitest-environment node
  2. import {expect,it} from 'vitest'
  3. import {mkdtempSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID} from 'node:crypto'
  7. import {WorkspaceStore} from '../../src/server/workspaceStore'
  8. import {WorkspaceNodes} from '../../src/server/workspaceGateway'
  9. import {loadServerConfig} from '../../src/server/config'
  10. import {SharedComputers} from '../../src/server/sharedComputers'
  11. import type {WorkspaceAgent} from '../../src/shared/workspace'
  12. it('requires an explicit human grant, one compatible source and idle permanent members',()=>{
  13. const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
  14. const service=new SharedComputers(store,{} as any,nodes,{sharedComputerRunner:()=>({id:'runner'})} as any)
  15. try{
  16. const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'}),second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'default'})
  17. const command={requestId:randomUUID(),name:'可信研发电脑',memberIds:[first.id,second.id],trusted:true}
  18. expect(()=>service.create('owner',{...command,trusted:false})).toThrow()
  19. expect(()=>store.createAgent('owner',{name:'模型新建',profile:'default',computerEnvironmentId:randomUUID()})).toThrow()
  20. store.put('owner','turn','busy',{agentId:first.id,status:'queued'})
  21. expect(()=>service.create('owner',command)).toThrow('先停止')
  22. store.remove('owner','turn','busy')
  23. const shared=service.create('owner',command)
  24. expect(service.create('owner',command).id).toBe(shared.id)
  25. const linked=store.require<WorkspaceAgent>('owner','agent',first.id)
  26. expect(linked.computerEnvironmentId).toBe(shared.id)
  27. expect(()=>nodes.requireSource('other',linked)).toThrow()
  28. expect(()=>nodes.requireSource('owner',{...linked,id:randomUUID()})).toThrow()
  29. expect(()=>store.updateAgent('owner',first.id,{execution:'profile'})).toThrow('解除电脑共享')
  30. store.put('_system','computer-control','fixture',{owner:'owner',agentId:first.id,environmentId:shared.id,expiresAt:Date.now()+30000})
  31. expect(()=>service.remove('owner',shared.id)).toThrow('交还')
  32. store.remove('_system','computer-control','fixture')
  33. service.remove('owner',shared.id)
  34. expect(store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId).toBeUndefined()
  35. expect(()=>nodes.requireSource('owner',linked)).toThrow('授权')
  36. expect(store.require<any>('owner','shared-computer',shared.id).archived).toBe(true)
  37. }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
  38. })
  39. it('shares one desktop across authorized profiles and rejects temporary helpers',()=>{
  40. const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{sharedComputerRunner:()=>({id:'runner'})} as any)
  41. try{
  42. const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'}),second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'different'})
  43. const command={requestId:randomUUID(),name:'共享',memberIds:[first.id,second.id],trusted:true}
  44. const group=service.create('owner',command)
  45. expect(store.require<WorkspaceAgent>('owner','agent',second.id).computerEnvironmentId).toBe(group.id)
  46. nodes.requireSource('owner',store.require<WorkspaceAgent>('owner','agent',second.id))
  47. service.remove('owner',group.id)
  48. command.requestId=randomUUID()
  49. store.put('owner','agent',second.id,{...second,profile:'default',temporaryGoalId:randomUUID()})
  50. expect(()=>service.create('owner',command)).toThrow('持久隔离成员')
  51. }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
  52. })
  53. it('keeps one shared group across runner re-enrollment instead of stranding members',()=>{
  54. const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-runner-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
  55. try{
  56. const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'})
  57. service.attachLocalVm('owner',first,'runner-one');store.put('owner','agent',first.id,first)
  58. const original=store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId!
  59. // The managed runner is removed and re-enrolled with a new identity.
  60. const second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'default'})
  61. service.attachLocalVm('owner',second,'runner-two');store.put('owner','agent',second.id,second)
  62. const groups=store.list<WorkspaceAgent>('owner','shared-computer').filter((group:any)=>!group.archived)
  63. expect(groups).toHaveLength(1)
  64. expect((groups[0] as any).runnerId).toBe('runner-two')
  65. expect((groups[0] as any).memberIds).toEqual(expect.arrayContaining([first.id,second.id]))
  66. expect(store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId).toBe(original)
  67. expect(store.require<WorkspaceAgent>('owner','agent',second.id).computerEnvironmentId).toBe(original)
  68. expect(service.reconcileLocalVmGroups('owner','runner-two')!.id).toBe(original)
  69. }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
  70. })
  71. it('reports desktops orphaned by an isolation switch so the caller can stop them',()=>{
  72. const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-retire-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
  73. try{
  74. const vm=store.createAgent('owner',{name:'虚拟机成员',execution:'computer',profile:'default'})
  75. // Entering shared mode retires this member's previous per-bot desktop.
  76. expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([vm.id])
  77. const group=store.require<WorkspaceAgent>('owner','agent',vm.id).computerEnvironmentId!
  78. // Leaving shared mode retires the now unused shared desktop.
  79. expect(service.setLocalVmMode('owner','per-bot','runner').retiredEnvironmentIds).toEqual([group])
  80. // Re-entering shared mode retires whatever per-bot desktop this member used meanwhile.
  81. expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([vm.id])
  82. expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([])
  83. }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
  84. })
  85. it('changes only persistent local VM members and leaves unrelated running chats untouched',()=>{
  86. const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-scope-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
  87. try{
  88. const vm=store.createAgent('owner',{name:'虚拟机成员',execution:'computer',profile:'default'})
  89. const chat=store.createAgent('owner',{name:'正在聊天',profile:'server'})
  90. const archived=store.createAgent('owner',{name:'已归档',execution:'computer',profile:'default'})
  91. store.put('owner','agent',archived.id,{...archived,archived:true})
  92. const helper=store.createAgent('owner',{name:'临时助手',execution:'computer',profile:'default'},{createdByAgentId:vm.id,createdFromRunId:randomUUID(),temporaryGoalId:randomUUID()})
  93. const remote=store.createAgent('owner',{name:'远端成员',execution:'computer',profile:'default'})
  94. store.put('owner','agent',remote.id,{...remote,nodeId:randomUUID()})
  95. const unaffected=[chat,archived,helper,remote].map(a=>store.require<WorkspaceAgent>('owner','agent',a.id))
  96. for(const agent of unaffected)store.put('owner','turn',agent.id,{agentId:agent.id,status:'running'})
  97. service.setLocalVmMode('owner','shared','runner')
  98. const linked=store.require<WorkspaceAgent>('owner','agent',vm.id)
  99. expect(linked.computerEnvironmentId).toBeTruthy()
  100. for(const agent of unaffected)expect(store.require('owner','agent',agent.id)).toEqual(agent)
  101. service.setLocalVmMode('owner','shared','runner')
  102. expect(store.require('owner','agent',vm.id)).toEqual(linked)
  103. // Explicit per-Agent execution changes must still guard an ordinary chat.
  104. expect(()=>service.assertLocalVmIdle('owner',[chat.id])).toThrow('正在聊天')
  105. for(const status of ['queued','running','waiting','uncertain','cancelling']){
  106. store.put('owner','turn','active-vm',{agentId:vm.id,status})
  107. expect(()=>service.setLocalVmMode('owner','per-bot','runner')).toThrow('请先停止「虚拟机成员」')
  108. expect(store.require('owner','agent',vm.id)).toEqual(linked)
  109. }
  110. store.remove('owner','turn','active-vm')
  111. // A controller acquired through another member still owns this same VM.
  112. store.put('_system','computer-control','human',{owner:'owner',agentId:archived.id,environmentId:linked.computerEnvironmentId,expiresAt:Date.now()+30000})
  113. expect(()=>service.setLocalVmMode('owner','per-bot','runner')).toThrow('交还')
  114. store.remove('_system','computer-control','human')
  115. service.setLocalVmMode('owner','per-bot','runner')
  116. expect(store.require<WorkspaceAgent>('owner','agent',vm.id).computerEnvironmentId).toBeUndefined()
  117. for(const agent of unaffected)expect(store.require('owner','agent',agent.id)).toEqual(agent)
  118. }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
  119. })