desktopEnvironments.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID,createHash} from 'node:crypto'
  7. import Koa from 'koa'
  8. import {bodyParser} from '@koa/bodyparser'
  9. import request from 'supertest'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {DesktopEnvironments} from '../../src/server/desktopEnvironments'
  12. import type {WorkspaceAgent} from '../../src/shared/workspace'
  13. import {HttpError} from '../../src/server/errors'
  14. import {acquireServiceInstance} from '../../src/server/serviceInstance'
  15. let home:string,store:WorkspaceStore,service:DesktopEnvironments,agent:WorkspaceAgent,version:number,host:any,poller:ReturnType<typeof setInterval>|undefined
  16. const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-user'))throw new HttpError(403,'仅限管理员','admin_required')},pushAuthorizationVersion:()=>version} as any
  17. const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
  18. const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
  19. beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Desktop',nodeId:'local',profile:'default',computer:'local'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
  20. const pollers=new Set<ReturnType<typeof setInterval>>()
  21. afterEach(()=>{clearInterval(poller);for(const p of pollers)clearInterval(p);pollers.clear();service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
  22. const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
  23. const base=()=>'/api/app/agents/'+agent.id
  24. it('adds an authorized native desktop to hybrid tools while leaving the default viewer on the VM',async()=>{
  25. agent=store.updateAgent('owner',agent.id,{computer:'vm',execution:'computer',allowHostEnvironment:true})
  26. expect(agent.execution).toBe('computer');expect(service.toolMode('owner',agent)).toBeUndefined()
  27. drive();expect(service.selected('owner',agent)).toBeUndefined();expect(service.toolMode('owner',agent)).toBe('local')
  28. const result=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
  29. expect(result.content[0].type).toBe('image')
  30. agent=store.updateAgent('owner',agent.id,{computer:'vm',allowHostEnvironment:false});expect(service.toolMode('owner',agent)).toBeUndefined()
  31. await expect(service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)).rejects.toMatchObject({code:'computer_control_expired'})
  32. })
  33. function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});const p=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
  34. const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
  35. function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});const p=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
  36. it('defaults bot work to the customer machine and uses that binding while the server desktop is also online',async()=>{
  37. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  38. const remoteHost={...host,name:'客厅 Mac Pro'}
  39. driveRemote(HOST_A,remoteHost)
  40. drive()
  41. const state=(await request(app()).get(base()+'/desktop-environment')).body
  42. expect(state.hostName??state.host?.name).toBe('客厅 Mac Pro')
  43. const named=(await request(app()).get(base()+'/computer?backend=desktop&host='+encodeURIComponent('客厅 Mac Pro'))).body
  44. expect(named.hostName).toBe('客厅 Mac Pro')
  45. vi.useFakeTimers()
  46. clearInterval(poller)
  47. vi.advanceTimersByTime(9000)
  48. const fallback=(await request(app()).get(base()+'/desktop-environment')).body
  49. expect(fallback.hostName??fallback.host?.name).toBe('客厅 Mac Pro')
  50. const epoch=service.hostEpoch('owner',agent)
  51. expect(epoch).toBe(remoteHost.id)
  52. const result=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',epoch)
  53. expect(result.content[0].type).toBe('image')
  54. expect(JSON.parse(result.content[1].text).host).toBe('客厅 Mac Pro')
  55. vi.useRealTimers()
  56. const hosts=(await request(app()).get(base()+'/desktop-environment')).body
  57. expect(hosts.hosts.map((h:any)=>h.id)).toEqual(['local',HOST_A])
  58. expect(hosts.hosts.find((h:any)=>h.id===HOST_A)).toMatchObject({name:'客厅 Mac Pro',online:true})
  59. })
  60. it('keeps 本机 on the computer running YaoYao when another Mac is online',async()=>{
  61. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  62. drive()
  63. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  64. const state=(await request(app()).get(base()+'/desktop-environment')).body
  65. expect(state.host.id).toBe('local')
  66. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  67. expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
  68. })
  69. it('requires an explicit binding when several remote hosts are connected',async()=>{
  70. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  71. service.remoteExchange(HOST_A,{host:{...host,name:'书房'},results:[]})
  72. service.remoteExchange(HOST_B,{host:{...host,name:'客厅'},results:[]})
  73. const state=(await request(app()).get(base()+'/desktop-environment')).body
  74. expect(state.online).toBe(false)
  75. expect(state.hosts).toHaveLength(2)
  76. await request(app()).get(base()+'/computer/frame').expect(409)
  77. agent=store.updateAgent('owner',agent.id,{desktopHost:HOST_B})
  78. driveRemote(HOST_B,{...host,name:'客厅'})
  79. const status=(await request(app()).get(base()+'/computer')).body
  80. expect(status.hostName).toBe('客厅')
  81. const ticket=await take()
  82. await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)
  83. })
  84. it('lets the manual viewer take over any connected host via the host parameter',async()=>{
  85. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_B})
  86. driveRemote(HOST_A,{...host,name:'书房'},c=>c.operation==='view'?{data:Buffer.from('study frame').toString('base64'),width:1280,height:800}:{ok:true})
  87. driveRemote(HOST_B,{...host,name:'客厅'},c=>c.operation==='view'?{data:Buffer.from('living frame').toString('base64'),width:1280,height:800}:{ok:true})
  88. const study='?backend=desktop&host='+encodeURIComponent(HOST_A)
  89. const status=(await request(app()).get(base()+'/computer'+study)).body
  90. expect(status.hostName).toBe('书房');expect(status.mode).toBe('idle')
  91. const ticket=(await request(app()).post(base()+'/computer/take'+study).send({requestId:randomUUID()}).expect(200)).body
  92. expect(ticket.hostName).toBe('书房')
  93. const bound=(await request(app()).get(base()+'/computer?backend=desktop')).body
  94. expect(bound.hostName).toBe('客厅');expect(bound.mode).toBe('idle')
  95. const frame=await request(app()).get(base()+'/computer/frame'+study).expect(200)
  96. expect(Buffer.from(frame.body.data,'base64').toString()).toBe('study frame')
  97. await request(app()).post(base()+'/computer/giveback'+study).send({controlId:ticket.controlId,token:ticket.token}).expect(200)
  98. const response=await request(app()).get(base()+'/computer?backend=desktop&host=nope').expect(409)
  99. expect(response.body.code).toBe('desktop_host_unknown')
  100. })
  101. it('revoking a desktop host fences only its dispatched commands',async()=>{
  102. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  103. service.remoteExchange(HOST_A,{host,results:[]})
  104. const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))
  105. await vi.waitFor(()=>{expect(service.remoteExchange(HOST_A,{host,results:[]}).commands).toHaveLength(1)})
  106. service.dropHost(HOST_A)
  107. await expect(call).rejects.toMatchObject({code:'desktop_disconnected'})
  108. })
  109. it('gates file and shell tools behind the separate full grant',async()=>{
  110. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  111. service.exchange({host,results:[]})
  112. expect(service.fileTools('owner',agent)).toBe(false)
  113. await expect(service.call('owner',agent.id,'desktop_file_read',{path:'notes.txt'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({code:'desktop_full_required'})
  114. await expect(service.call('owner',agent.id,'desktop_shell',{command:'ls'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({code:'desktop_full_required'})
  115. const seen:any[]=[]
  116. drive(c=>{seen.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{op:'list',entries:[]}})
  117. host.full=[ownerKey('owner')]
  118. service.exchange({host,results:[]})
  119. expect(service.fileTools('owner',agent)).toBe(true)
  120. const listing=await service.call('owner',agent.id,'desktop_file_list',{path:'Documents'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))
  121. expect(listing.entries).toEqual([])
  122. expect(seen.find(c=>c.operation==='file')?.action).toMatchObject({op:'list',path:'Documents'})
  123. await expect(service.call('owner',agent.id,'desktop_file_write',{path:'a.txt',data:'!!!not base64!!!'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({status:400})
  124. })
  125. it('passes the authorize scope through to the desktop host',async()=>{
  126. const seen:any[]=[]
  127. drive(c=>{seen.push(c);return {ok:true}})
  128. await request(app()).post(base()+'/desktop-environment/authorize').send({scope:'full'}).expect(200)
  129. expect(seen.find(c=>c.operation==='authorize')?.scope).toBe('full')
  130. })
  131. it('routes parallel desktop tools to the host named in the call',async()=>{
  132. agent=store.updateAgent('owner',agent.id,{computer:'local',envs:{desktop:true,cloud:true}})
  133. const study={...host,name:'书房 iMac'},living={...host,id:randomUUID(),name:'客厅 Mac mini'}
  134. driveRemote(HOST_A,study)
  135. let resultsB:any[]=[]
  136. service.remoteExchange(HOST_B,{host:living,results:[]})
  137. const pollerB=setInterval(()=>{const value=service.remoteExchange(HOST_B,{host:living,results:resultsB});resultsB=value.commands.map(c=>({id:c.id,value:c.operation==='view'?{data:Buffer.from('living-room').toString('base64'),width:1280,height:800}:{ok:true}}))},5)
  138. try{
  139. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  140. const frame=await service.call('owner',agent.id,'desktop_environment_view',{host:'客厅 Mac mini'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  141. expect(Buffer.from(frame.content[0].data,'base64').toString()).toBe('living-room')
  142. await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'书房 iMac'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).resolves.toMatchObject({content:[{type:'image'},{type:'text'}]})
  143. await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'地下室 MacBook'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toMatchObject({code:'desktop_host_unknown'})
  144. expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
  145. }finally{clearInterval(pollerB)}
  146. })
  147. it('ignores a stored browser environment and keeps desktop tools',async()=>{
  148. agent=store.updateAgent('owner',agent.id,{envs:{vm:true,desktop:true,browser:true,cloud:false}})
  149. drive()
  150. expect(service.selected('owner',agent)).toBe('local')
  151. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  152. await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).rejects.toMatchObject({status:404,code:'tool_not_found'})
  153. const view=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
  154. expect(view.content[0].type).toBe('image')
  155. })
  156. it('degrades envs desktop tools when no host is ready or online',async()=>{
  157. agent=store.updateAgent('owner',agent.id,{computer:'off',envs:{desktop:true,browser:true}})
  158. expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:false})
  159. service.exchange({host,results:[]})
  160. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  161. host.full=[ownerKey('owner')]
  162. service.exchange({host,results:[]})
  163. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:true})
  164. })
  165. it('keeps manual takeover scoped per desktop host',async()=>{
  166. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  167. const second=store.createAgent('owner',{name:'Second',nodeId:'local',profile:'default',computer:'local',desktopHost:HOST_B})
  168. driveRemote(HOST_A,{...host,name:'书房'})
  169. let resultsB:any[]=[]
  170. const pollerB=setInterval(()=>{const value=service.remoteExchange(HOST_B,{host:{...host,name:'客厅'},results:resultsB});resultsB=value.commands.map(c=>({id:c.id,value:{ok:true}}))},5)
  171. try{
  172. const ticketA=await take()
  173. const ticketB=(await request(app()).post('/api/app/agents/'+second.id+'/computer/take').send({requestId:randomUUID()}).expect(200)).body
  174. expect(ticketB.hostName).toBe('客厅')
  175. const records=store.list<any>('_system','computer-control')
  176. expect(records).toHaveLength(2)
  177. expect(new Set(records.map(r=>r.environmentId.split(':')[1])).size).toBe(2)
  178. await request(app()).post(base()+'/computer/renew').send(ticketA).expect(200)
  179. await request(app()).post(base()+'/computer/giveback').send(ticketA).expect(200)
  180. await request(app()).post('/api/app/agents/'+second.id+'/computer/giveback').send(ticketB).expect(200)
  181. }finally{clearInterval(pollerB)}
  182. })
  183. async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
  184. async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
  185. it('keeps capability private and refuses origins before parsing native commands',async()=>{const instance=acquireServiceInstance(home,'0.4.3'),a=new Koa().use(instance.middleware(async()=>{},()=>true,ctx=>service.bridge(ctx)));try{await request(a.callback()).post('/desktop/environment').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).set('Origin','http://evil.test').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).send({host,results:[]}).expect(200);expect(service.online).toBe(true)}finally{instance.release()}})
  186. it('auto selects only an approved and ready local host; observing never opens a browser',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'auto'});expect(service.selected('owner',agent)).toBeUndefined();expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBeUndefined();service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBeUndefined();await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
  187. it('shares the physical desktop across owners and robots but scopes manual tokens to their owner and robot',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'local'});const other=store.createAgent('other',{name:'Other',nodeId:'local',profile:'default',computer:'local'});host.approved.push(ownerKey('other'));drive();const ticket=await take();await request(app()).post('/api/app/agents/'+other.id+'/computer/take').set('x-user','other').send({requestId:randomUUID()}).expect(409);await request(app()).post('/api/app/agents/'+other.id+'/computer/renew').set('x-user','other').send(ticket).expect(410);const state=(await request(app()).get('/api/app/agents/'+other.id+'/computer').set('x-user','other')).body;expect(state.controlId).toBeUndefined();await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)})
  188. it('rejects stale frames and replays an input result exactly once',async()=>{const calls:any[]=[];drive(c=>{calls.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{ok:true}});const t=await take(),f=await frame(),body={controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'click',x:10,y:10}};await request(app()).post(base()+'/computer/input').send({...body,frameId:randomUUID()}).expect(409);await request(app()).post(base()+'/computer/input').send(body).expect(200);await request(app()).post(base()+'/computer/input').send(body).expect(200);expect(calls.filter(c=>c.operation==='input')).toHaveLength(1);await request(app()).post(base()+'/computer/input').send({...body,action:{kind:'text',text:'different'}}).expect(409);await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
  189. it('expired control remains paused until a new takeover is explicitly returned',async()=>{drive();const t=await take();const original=Date.now;const time=vi.spyOn(Date,'now').mockImplementation(()=>original()+31000);service.exchange({host,results:[]});await request(app()).post(base()+'/computer/renew').send(t).expect(410);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('error');time.mockRestore();const next=await take();await request(app()).post(base()+'/computer/giveback').send({...next,notes:'已完成登录'}).expect(200);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('idle')})
  190. it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'local',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'local',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
  191. it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
  192. it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(409);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
  193. it('resolves 本机 to the messaging device when deviceHost is a script machine',async()=>{
  194. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  195. drive()
  196. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  197. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  198. expect(JSON.parse(view.content[1].text).host).toBe('客厅 Mac')
  199. const omitted=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  200. expect(JSON.parse(omitted.content[1].text).host).toBe('客厅 Mac')
  201. })
  202. it('keeps 本机 on the server when deviceHost is local even if a script machine is bound',async()=>{
  203. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  204. drive()
  205. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  206. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
  207. expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
  208. })
  209. it('deviceContextLine names the messaging host',()=>{
  210. drive()
  211. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  212. expect(service.deviceContextLine('owner',HOST_A)).toContain('客厅 Mac')
  213. expect(service.deviceContextLine('owner',HOST_A)).toContain('本机')
  214. expect(service.onlineHostsLine('owner',HOST_A)).toContain('本机')
  215. })