desktopEnvironments.test.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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,DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_FILE_TOOL_IDS} from '../../src/server/desktopEnvironments'
  12. import type {WorkspaceAgent} from '../../src/shared/workspace'
  13. import {saveHostTools} from '../../src/server/hostToolSettings'
  14. import {HttpError} from '../../src/server/errors'
  15. import {acquireServiceInstance} from '../../src/server/serviceInstance'
  16. let home:string,store:WorkspaceStore,service:DesktopEnvironments,agent:WorkspaceAgent,version:number,host:any,poller:ReturnType<typeof setInterval>|undefined
  17. const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner',username:'测试账号'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-user'))throw new HttpError(403,'仅限管理员','admin_required')},pushAuthorizationVersion:()=>version} as any
  18. const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
  19. const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
  20. 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')]}})
  21. const pollers=new Set<ReturnType<typeof setInterval>>()
  22. afterEach(()=>{clearInterval(poller);for(const p of pollers)clearInterval(p);pollers.clear();service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
  23. 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()}
  24. const base=()=>'/api/app/agents/'+agent.id
  25. it('uses global desktop capabilities for legacy, modern and temporary Bots',async()=>{
  26. drive()
  27. for(const patch of [{computer:'off',envs:undefined},{computer:'vm',execution:'computer'},{envs:{}},{temporaryGoalId:randomUUID()}]){
  28. const candidate={...agent,...patch} as WorkspaceAgent
  29. expect(service.selected('owner',candidate)).toBe('local')
  30. expect(service.envTools('owner',candidate).view).toBe(true)
  31. }
  32. saveHostTools(home,{scriptMachine:false,serverComputer:false})
  33. expect(service.envTools('owner',agent).view).toBe(false)
  34. await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'server'},new AbortController().signal,()=>{},'local',service.epoch)).rejects.toMatchObject({code:'desktop_host_disabled'})
  35. })
  36. 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}
  37. const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
  38. it('admits Windows computers with independent screen and file grants and preserves source routing',async()=>{
  39. const seen:any[]=[]
  40. const win={...host,platform:'win32',name:'Windows 工作电脑',full:[ownerKey('owner')],screen:false,accessibility:false}
  41. driveRemote(HOST_A,win,c=>{seen.push(c);return {entries:[],path:c.action?.path}})
  42. const status=service.hostStates('owner').find(h=>h.id===HOST_A)!
  43. expect(status.local).toMatchObject({supported:true,ready:false,fullAuthorized:true})
  44. const listing=await service.call('owner',agent.id,'desktop_file_list',{path:'C:\\Users\\测试\\Documents'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  45. expect(listing.entries).toEqual([])
  46. expect(seen[0].action.path).toBe('C:\\Users\\测试\\Documents')
  47. win.screen=true;win.accessibility=true
  48. service.remoteExchange(HOST_A,{host:win,results:[]})
  49. expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local.ready).toBe(true)
  50. win.full=[];win.approved=[]
  51. service.remoteExchange(HOST_A,{host:win,results:[]})
  52. expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local).toMatchObject({ready:false,fullAuthorized:false})
  53. await expect(service.call('owner',agent.id,'desktop_file_list',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)).rejects.toMatchObject({code:'desktop_full_required'})
  54. })
  55. it('unrecognized desktop platforms remain unsupported',()=>{
  56. service.remoteExchange(HOST_A,{host:{...host,platform:'unknown',full:[ownerKey('owner')]},results:[]})
  57. expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local).toMatchObject({supported:false,ready:false})
  58. })
  59. 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}
  60. it('defaults bot work to the customer machine and uses that binding while the server desktop is also online',async()=>{
  61. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  62. const remoteHost={...host,name:'客厅 Mac Pro'}
  63. driveRemote(HOST_A,remoteHost)
  64. drive()
  65. const state=(await request(app()).get(base()+'/desktop-environment')).body
  66. expect(state.hostName??state.host?.name).toBe('客厅 Mac Pro')
  67. const named=(await request(app()).get(base()+'/computer?backend=desktop&host='+encodeURIComponent('客厅 Mac Pro'))).body
  68. expect(named.hostName).toBe('客厅 Mac Pro')
  69. vi.useFakeTimers()
  70. clearInterval(poller)
  71. vi.advanceTimersByTime(9000)
  72. const fallback=(await request(app()).get(base()+'/desktop-environment')).body
  73. expect(fallback.hostName??fallback.host?.name).toBe('客厅 Mac Pro')
  74. const epoch=service.hostEpoch('owner',agent)
  75. expect(epoch).toBe(remoteHost.id)
  76. const result=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',epoch,HOST_A)
  77. expect(result.content[0].type).toBe('image')
  78. expect(JSON.parse(result.content[1].text).host).toBe('客厅 Mac Pro')
  79. vi.useRealTimers()
  80. const hosts=(await request(app()).get(base()+'/desktop-environment')).body
  81. expect(hosts.hosts.map((h:any)=>h.id)).toEqual(['local',HOST_A])
  82. expect(hosts.hosts.find((h:any)=>h.id===HOST_A)).toMatchObject({name:'客厅 Mac Pro',online:true})
  83. })
  84. it('keeps 本机 on the computer running YaoYao when another Mac is online',async()=>{
  85. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  86. drive()
  87. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  88. const state=(await request(app()).get(base()+'/desktop-environment')).body
  89. expect(state.host.id).toBe('local')
  90. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
  91. expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
  92. })
  93. it('requires an explicit binding when several remote hosts are connected',async()=>{
  94. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  95. service.remoteExchange(HOST_A,{host:{...host,name:'书房'},results:[]})
  96. service.remoteExchange(HOST_B,{host:{...host,name:'客厅'},results:[]})
  97. const state=(await request(app()).get(base()+'/desktop-environment')).body
  98. expect(state.online).toBe(false)
  99. expect(state.hosts).toHaveLength(2)
  100. await request(app()).get(base()+'/computer/frame').expect(409)
  101. agent=store.updateAgent('owner',agent.id,{desktopHost:HOST_B})
  102. driveRemote(HOST_B,{...host,name:'客厅'})
  103. const status=(await request(app()).get(base()+'/computer')).body
  104. expect(status.hostName).toBe('客厅')
  105. const ticket=await take()
  106. await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)
  107. })
  108. it('lets the manual viewer take over any connected host via the host parameter',async()=>{
  109. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_B})
  110. driveRemote(HOST_A,{...host,name:'书房'},c=>c.operation==='view'?{data:Buffer.from('study frame').toString('base64'),width:1280,height:800}:{ok:true})
  111. driveRemote(HOST_B,{...host,name:'客厅'},c=>c.operation==='view'?{data:Buffer.from('living frame').toString('base64'),width:1280,height:800}:{ok:true})
  112. const study='?backend=desktop&host='+encodeURIComponent(HOST_A)
  113. const status=(await request(app()).get(base()+'/computer'+study)).body
  114. expect(status.hostName).toBe('书房');expect(status.mode).toBe('idle')
  115. const ticket=(await request(app()).post(base()+'/computer/take'+study).send({requestId:randomUUID()}).expect(200)).body
  116. expect(ticket.hostName).toBe('书房')
  117. const bound=(await request(app()).get(base()+'/computer?backend=desktop')).body
  118. expect(bound.hostName).toBe('客厅');expect(bound.mode).toBe('idle')
  119. const frame=await request(app()).get(base()+'/computer/frame'+study).expect(200)
  120. expect(Buffer.from(frame.body.data,'base64').toString()).toBe('study frame')
  121. await request(app()).post(base()+'/computer/giveback'+study).send({controlId:ticket.controlId,token:ticket.token}).expect(200)
  122. const response=await request(app()).get(base()+'/computer?backend=desktop&host=nope').expect(409)
  123. expect(response.body.code).toBe('desktop_host_unknown')
  124. })
  125. it('revoking a computer fences only its dispatched commands',async()=>{
  126. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  127. service.remoteExchange(HOST_A,{host,results:[]})
  128. const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent),HOST_A)
  129. await vi.waitFor(()=>{expect(service.remoteExchange(HOST_A,{host,results:[]}).commands).toHaveLength(1)})
  130. service.dropHost(HOST_A)
  131. await expect(call).rejects.toMatchObject({code:'desktop_disconnected'})
  132. })
  133. it('gates file and shell tools behind the separate full grant',async()=>{
  134. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  135. service.exchange({host,results:[]})
  136. expect(service.fileTools('owner',agent)).toBe(false)
  137. await expect(service.call('owner',agent.id,'desktop_file_read',{host:'server',path:'notes.txt'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({code:'desktop_full_required'})
  138. await expect(service.call('owner',agent.id,'desktop_shell',{host:'server',command:'ls'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({code:'desktop_full_required'})
  139. const seen:any[]=[]
  140. drive(c=>{seen.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{op:'list',entries:[]}})
  141. host.full=[ownerKey('owner')]
  142. service.exchange({host,results:[]})
  143. expect(service.fileTools('owner',agent)).toBe(true)
  144. const listing=await service.call('owner',agent.id,'desktop_file_list',{host:'server',path:'Documents'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))
  145. expect(listing.entries).toEqual([])
  146. expect(seen.find(c=>c.operation==='file')?.action).toMatchObject({op:'list',path:'Documents'})
  147. await expect(service.call('owner',agent.id,'desktop_file_write',{host:'server',path:'a.txt',data:'!!!not base64!!!'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({status:400})
  148. })
  149. it('rejects remote authorization for every scope and target without dispatching a command',async()=>{
  150. const seen:any[]=[]
  151. drive(c=>{seen.push(c);return {ok:true}})
  152. for(const hostId of [undefined,'local',randomUUID()])for(const scope of ['screen','full']){
  153. const result=await request(app()).post(base()+'/desktop-environment/authorize').send({hostId,scope}).expect(403)
  154. expect(result.body.code).toBe('desktop_local_authorization_required')
  155. }
  156. expect(seen).toEqual([])
  157. })
  158. it('reads devices and the native authorization identity without needing a Bot',async()=>{
  159. service.exchange({host,results:[]})
  160. const offlineId=randomUUID(),revokedId=randomUUID()
  161. for(const [id,enabled] of [[offlineId,true],[revokedId,false]] as const)store.put('_system','desktop-host',id,{id,enabled,name:'其他电脑',createdAt:1,tokenHash:'secret'})
  162. const state=(await request(app()).get('/api/app/desktop-environment').expect(200)).body
  163. expect(state.hosts).toEqual(expect.arrayContaining([expect.objectContaining({id:'local',online:true}),expect.objectContaining({id:offlineId,name:'其他电脑',online:false})]))
  164. expect(state.hosts.some((h:any)=>h.id===revokedId)).toBe(false)
  165. expect(JSON.stringify(state)).not.toContain('secret')
  166. expect((await request(app()).get('/api/app/desktop-environment/authorization').expect(200)).body).toEqual({owner:ownerKey('owner'),account:'测试账号'})
  167. for(const path of ['/api/app/desktop-environment','/api/app/desktop-environment/authorization'])await request(app()).get(path).set('x-user','other').expect(403)
  168. })
  169. it('copies files between the explicitly named hosts and server without chat attachments or model payloads',async()=>{
  170. const bytes=Buffer.alloc(300_000);for(let i=0;i<bytes.length;i++)bytes[i]=i%256
  171. const seen:Array<{host:string;action:any}>=[]
  172. const handle=(id:string)=>(c:any)=>{seen.push({host:id,action:c.action});return c.action.op==='read'?{path:c.action.path,size:bytes.length,data:bytes.toString('base64')}:{path:c.action.path,size:Buffer.from(c.action.data,'base64').length,sha256:createHash('sha256').update(Buffer.from(c.action.data,'base64')).digest('hex')}}
  173. host.full=[ownerKey('owner')]
  174. drive(handle('server'))
  175. driveRemote(HOST_A,{...host,id:randomUUID(),name:'mac1'},handle('mac1'))
  176. driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},handle('mac2'))
  177. for(const [sourceHost,targetHost,targetId] of [['mac1','mac2',HOST_B],['mac1','server','local'],['server','本机',HOST_A]]){
  178. const result=await service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a.txt',targetHost,targetPath:'Desktop/a.txt'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  179. expect(result).toMatchObject({copied:true,target:{host:targetId,path:'Desktop/a.txt'},size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')})
  180. expect(JSON.stringify(result).length).toBeLessThan(700)
  181. }
  182. expect(seen.map(({host,action})=>[host,action.op])).toEqual([['mac1','read'],['mac2','receive'],['mac1','read'],['server','receive'],['server','read'],['mac1','receive']])
  183. for(const {action} of seen.filter(item=>item.action.op==='receive')){expect(action.overwrite).toBe(false);expect(Buffer.from(action.data,'base64')).toEqual(bytes)}
  184. expect(store.list('owner','file')).toEqual([])
  185. expect(DESKTOP_FILE_TOOL_IDS.has('desktop_file_copy')).toBe(true)
  186. expect(DESKTOP_ENVIRONMENT_TOOLS.some(tool=>tool.id==='desktop_file_copy')).toBe(true)
  187. })
  188. it('serializes opposing transfers without deadlocking or changing the target',async()=>{
  189. host.full=[ownerKey('owner')]
  190. const handle=(c:any)=>c.action.op==='read'?{size:1,data:'YQ=='}:{path:c.action.path,size:1,sha256:c.action.sha256}
  191. driveRemote(HOST_A,{...host,name:'mac1'},handle);driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},handle)
  192. const copy=(sourceHost:string,targetHost:string)=>service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a',targetHost,targetPath:'Desktop/b'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  193. const values=await Promise.all([copy('mac1','mac2'),copy('mac2','mac1'),copy('mac1','mac1')])
  194. expect(values.map(value=>value.target.host)).toEqual([HOST_B,HOST_A,HOST_A])
  195. })
  196. it('requires both host grants and never falls back for missing or ambiguous hosts',async()=>{
  197. host.full=[ownerKey('owner')];drive()
  198. driveRemote(HOST_A,{...host,id:randomUUID(),name:'mac1',full:[]})
  199. const copy=(sourceHost:string,targetHost:string)=>service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a',targetHost,targetPath:'Desktop/a'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  200. await expect(copy('server','mac1')).rejects.toMatchObject({code:'desktop_full_required'})
  201. await expect(copy('mac1','server')).rejects.toMatchObject({code:'desktop_full_required'})
  202. await expect(copy('server','本机')).rejects.toMatchObject({code:'desktop_source_required'})
  203. await expect(copy('missing','server')).rejects.toMatchObject({code:'desktop_host_unknown'})
  204. driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac1'})
  205. await expect(copy('mac1','server')).rejects.toMatchObject({code:'desktop_host_ambiguous'})
  206. })
  207. it.each(['disconnect','cancel','revoke','corrupt','oversized'])('does not write the target when the source transfer is %s',async failure=>{
  208. host.full=[ownerKey('owner')];const controller=new AbortController(),writes:any[]=[]
  209. driveRemote(HOST_A,{...host,name:'mac1'},()=>{
  210. if(failure==='disconnect')service.dropHost(HOST_B)
  211. if(failure==='cancel')controller.abort()
  212. if(failure==='revoke')version++
  213. return {size:failure==='oversized'?10*1024*1024+1:failure==='corrupt'?2:1,data:'YQ=='}
  214. })
  215. driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},c=>{writes.push(c);return {}})
  216. await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a'},controller.signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toThrow()
  217. expect(writes).toEqual([])
  218. })
  219. it('reports a failed destination receipt without claiming success or retrying',async()=>{
  220. host.full=[ownerKey('owner')];const receives:any[]=[]
  221. driveRemote(HOST_A,{...host,name:'mac1'},()=>({size:0,data:''}))
  222. driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},c=>{receives.push(c);return {path:c.action.path,size:0,sha256:'wrong'}})
  223. await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a',overwrite:true},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toMatchObject({code:'desktop_transfer_unconfirmed'})
  224. expect(receives).toHaveLength(1);expect(receives[0].action.overwrite).toBe(true)
  225. })
  226. it('waits for a target taken over during the source read and resumes after giveback',async()=>{
  227. host.full=[ownerKey('owner')]
  228. const source={...host,name:'mac1'},target={...host,id:randomUUID(),name:'mac2'},writes:any[]=[]
  229. service.remoteExchange(HOST_A,{host:source,results:[]})
  230. driveRemote(HOST_B,target,c=>{if(c.operation!=='file')return {ok:true};writes.push(c);return {path:c.action.path,size:1,sha256:c.action.sha256}})
  231. const copying=service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  232. let read:any
  233. await vi.waitFor(()=>{read=service.remoteExchange(HOST_A,{host:source,results:[]}).commands[0];expect(read).toBeDefined()})
  234. const query='?backend=desktop&host='+HOST_B
  235. const ticket=(await request(app()).post(base()+'/computer/take'+query).send({requestId:randomUUID()}).expect(200)).body
  236. service.remoteExchange(HOST_A,{host:source,results:[{id:read.id,value:{size:1,data:'YQ=='}}]})
  237. await new Promise(resolve=>setTimeout(resolve,180));expect(writes).toEqual([])
  238. await request(app()).post(base()+'/computer/giveback'+query).send({controlId:ticket.controlId,token:ticket.token,notes:'已交还 mac2'}).expect(200)
  239. expect(await copying).toMatchObject({copied:true,handoffNotes:['mac2:已交还 mac2']});expect(writes).toHaveLength(1)
  240. })
  241. it('routes parallel desktop tools to the host named in the call',async()=>{
  242. agent=store.updateAgent('owner',agent.id,{computer:'local',envs:{desktop:true,cloud:true}})
  243. const study={...host,name:'书房 iMac'},living={...host,id:randomUUID(),name:'客厅 Mac mini'}
  244. driveRemote(HOST_A,study)
  245. let resultsB:any[]=[]
  246. service.remoteExchange(HOST_B,{host:living,results:[]})
  247. 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)
  248. try{
  249. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  250. const frame=await service.call('owner',agent.id,'desktop_environment_view',{host:'客厅 Mac mini'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
  251. expect(Buffer.from(frame.content[0].data,'base64').toString()).toBe('living-room')
  252. 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'}]})
  253. 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'})
  254. expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
  255. }finally{clearInterval(pollerB)}
  256. })
  257. it('ignores a stored browser environment and keeps desktop tools',async()=>{
  258. agent=store.updateAgent('owner',agent.id,{envs:{vm:true,desktop:true,browser:true,cloud:false}})
  259. drive()
  260. expect(service.selected('owner',agent)).toBe('local')
  261. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  262. 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'})
  263. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'server'},new AbortController().signal,()=>{},'local',service.epoch)
  264. expect(view.content[0].type).toBe('image')
  265. })
  266. it('degrades envs desktop tools when no host is ready or online',async()=>{
  267. agent=store.updateAgent('owner',agent.id,{computer:'off',envs:{desktop:true,browser:true}})
  268. expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:false})
  269. service.exchange({host,results:[]})
  270. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  271. host.full=[ownerKey('owner')]
  272. service.exchange({host,results:[]})
  273. expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:true})
  274. })
  275. it('keeps manual takeover scoped per computer',async()=>{
  276. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  277. const second=store.createAgent('owner',{name:'Second',nodeId:'local',profile:'default',computer:'local',desktopHost:HOST_B})
  278. driveRemote(HOST_A,{...host,name:'书房'})
  279. let resultsB:any[]=[]
  280. 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)
  281. try{
  282. const ticketA=await take()
  283. const ticketB=(await request(app()).post('/api/app/agents/'+second.id+'/computer/take').send({requestId:randomUUID()}).expect(200)).body
  284. expect(ticketB.hostName).toBe('客厅')
  285. const records=store.list<any>('_system','computer-control')
  286. expect(records).toHaveLength(2)
  287. expect(new Set(records.map(r=>r.environmentId.split(':')[1])).size).toBe(2)
  288. await request(app()).post(base()+'/computer/renew').send(ticketA).expect(200)
  289. await request(app()).post(base()+'/computer/giveback').send(ticketA).expect(200)
  290. await request(app()).post('/api/app/agents/'+second.id+'/computer/giveback').send(ticketB).expect(200)
  291. }finally{clearInterval(pollerB)}
  292. })
  293. async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
  294. async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
  295. 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()}})
  296. 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)).toBe('local');expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBe('local');service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
  297. 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)})
  298. 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)})
  299. 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')})
  300. 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',{host:'server'},signal,()=>{},'local',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{host:'server',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)})
  301. 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',{host:'server'},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('已登录测试账号')})
  302. 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)})
  303. it('resolves 本机 to the messaging device when deviceHost is a computer',async()=>{
  304. agent=store.updateAgent('owner',agent.id,{computer:'local'})
  305. drive()
  306. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  307. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  308. expect(JSON.parse(view.content[1].text).host).toBe('客厅 Mac')
  309. const omitted=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  310. expect(JSON.parse(omitted.content[1].text).host).toBe('客厅 Mac')
  311. })
  312. it('keeps 本机 on the server when deviceHost is local even if a computer is bound',async()=>{
  313. agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  314. drive()
  315. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  316. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
  317. expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
  318. })
  319. it('deviceContextLine names the messaging host',()=>{
  320. drive()
  321. driveRemote(HOST_A,{...host,name:'客厅 Mac'})
  322. expect(service.deviceContextLine('owner',HOST_A)).toContain('客厅 Mac')
  323. expect(service.deviceContextLine('owner',HOST_A)).toContain('本机')
  324. expect(service.onlineHostsLine('owner',HOST_A)).toContain('本机')
  325. })
  326. it('never falls back when the sending computer is unknown, disconnected or disabled',async()=>{
  327. drive()
  328. driveRemote(HOST_A,{...host,id:randomUUID(),name:'发送电脑'})
  329. for(const args of [{},{host:'本机'},{host:'local'}]){
  330. await expect(service.call('owner',agent.id,'desktop_environment_view',args,new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toMatchObject({code:'desktop_source_required'})
  331. }
  332. service.dropHost(HOST_A)
  333. for(const args of [{},{host:'本机'}])await expect(service.call('owner',agent.id,'desktop_environment_view',args,new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)).rejects.toMatchObject({code:'desktop_offline'})
  334. driveRemote(HOST_A,{...host,id:randomUUID(),name:'发送电脑'})
  335. saveHostTools(home,{scriptMachine:false,serverComputer:true})
  336. await expect(service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)).rejects.toMatchObject({code:'desktop_host_disabled'})
  337. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'服务器'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  338. expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
  339. })
  340. it('can use files and commands without screen or accessibility authorization',async()=>{
  341. host.screen=false;host.accessibility=false;host.approved=[];host.full=[ownerKey('owner')]
  342. const seen:any[]=[];drive(c=>{seen.push(c);return {ok:true}})
  343. expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:true})
  344. for(const [id,args] of [['desktop_file_read',{path:'notes.txt'}],['desktop_shell',{command:'pwd'}]] as const){
  345. await expect(service.call('owner',agent.id,id,args,new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')).resolves.toEqual({ok:true})
  346. }
  347. expect(seen.map(c=>c.operation)).toEqual(['file','shell'])
  348. await expect(service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')).rejects.toMatchObject({code:'desktop_permission_required'})
  349. })
  350. it('keeps concurrent message origins separate and lets explicit targets override them',async()=>{
  351. drive();driveRemote(HOST_A,{...host,id:randomUUID(),name:'电脑 A'});driveRemote(HOST_B,{...host,id:randomUUID(),name:'电脑 B'})
  352. const view=(source:string,args:object={})=>service.call('owner',agent.id,'desktop_environment_view',args,new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),source).then(v=>JSON.parse(v.content[1].text).host)
  353. expect(await Promise.all([view(HOST_A),view(HOST_B),view(HOST_A,{host:'server'})])).toEqual(['电脑 A','电脑 B','测试 Mac'])
  354. })
  355. it('rejects malformed or ambiguous explicit targets instead of using the message source',async()=>{
  356. drive();driveRemote(HOST_A,{...host,id:randomUUID(),name:'重复名称'});driveRemote(HOST_B,{...host,id:randomUUID(),name:'重复名称'})
  357. for(const target of [17,null,''])await expect(service.call('owner',agent.id,'desktop_environment_view',{host:target},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')).rejects.toMatchObject({status:400})
  358. await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'重复名称'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')).rejects.toMatchObject({code:'desktop_host_ambiguous'})
  359. const view=await service.call('owner',agent.id,'desktop_environment_view',{host:HOST_A},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
  360. expect(JSON.parse(view.content[1].text).host).toBe('重复名称')
  361. })
  362. it('routes chunk copies from any connected computer to the Bot VM and snapshots the configured limit',async()=>{
  363. host.full=[ownerKey('owner')];host.fileTransferVersion=1
  364. const bytes=Buffer.alloc(524289,9),sha256=createHash('sha256').update(bytes).digest('hex'),actions:any[]=[]
  365. driveRemote(HOST_A,{...host,name:'书房'},c=>{
  366. const a=c.action;actions.push(a)
  367. if(a.op==='transfer-read-open'){saveHostTools(home,{fileTransferMaxMiB:1});return {size:bytes.length,sha256,path:a.path}}
  368. if(a.op==='transfer-read')return {offset:a.offset,data:bytes.subarray(a.offset,a.offset+524288).toString('base64')}
  369. return {ok:true}
  370. })
  371. saveHostTools(home,{fileTransferMaxMiB:2})
  372. const received:Buffer[]=[],vmActions:any[]=[]
  373. const vm=async(a:Record<string,any>)=>{
  374. vmActions.push(a)
  375. if(a.op==='transfer-write-open')return {}
  376. if(a.op==='transfer-append'){received.push(Buffer.from(a.data,'base64'));return {received:Buffer.concat(received).length}}
  377. if(a.op==='transfer-finish')return {complete:true,path:'target.bin',size:bytes.length,sha256}
  378. return {ok:true}
  379. }
  380. const result=await service.call('owner',agent.id,'desktop_file_copy',{sourceHost:HOST_A,sourcePath:'source.bin',targetHost:'vm',targetPath:'target.bin'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A,vm)
  381. expect(result).toMatchObject({copied:true,target:{host:'vm'}})
  382. expect(vmActions[0]).toMatchObject({maxBytes:2*1024*1024,path:'target.bin',overwrite:false})
  383. expect(Buffer.concat(received).equals(bytes)).toBe(true)
  384. expect(actions.filter(a=>a.op==='transfer-read')).toHaveLength(2)
  385. expect(actions.at(-1).op).toBe('transfer-abort')
  386. await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:HOST_A,sourcePath:'source.bin',targetHost:'vm',targetPath:'target.bin',maxBytes:100*1024*1024},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A,vm)).rejects.toThrow()
  387. })
  388. it('lists stable tool targets and separate per-host capabilities without claiming browser availability', () => {
  389. const filesOnly = { ...host, id: randomUUID(), name: '同名电脑', screen: false, accessibility: false, approved: [], full: [ownerKey('owner')] }
  390. service.exchange({ host: { ...host, name: '同名电脑' }, results: [] })
  391. service.remoteExchange(HOST_A, { host: filesOnly, results: [] })
  392. service.remoteExchange(HOST_B, { host: { ...host, id: randomUUID(), name: 'Linux', platform: 'linux', full: [ownerKey('owner')] }, results: [] })
  393. const text = service.onlineHostsLine('owner', HOST_A)
  394. expect(text).toContain('host="server"')
  395. expect(text).toContain(`host="${HOST_A}"`)
  396. expect(text).toContain('电脑·本机')
  397. expect(text).toContain('屏幕控制=就绪;文件与命令=未授权')
  398. expect(text).toContain('屏幕控制=未授权;文件与命令=就绪')
  399. expect(text).toContain('屏幕控制=不支持;文件与命令=不支持')
  400. expect(text).not.toContain('浏览器可用')
  401. const ungranted = service.onlineHostsLine('other', HOST_A)
  402. expect(ungranted).not.toContain('=就绪')
  403. saveHostTools(home, { scriptMachine: false })
  404. expect(service.snapshot('owner', agent, HOST_A).hosts.find(host => host.id === HOST_A)?.capabilities.shell.status).toBe('disabled')
  405. expect(service.onlineHostsLine('owner', HOST_A)).toContain('host="server"')
  406. })
  407. const environmentMetadata={version:1 as const,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/fixture',defaultCwd:'/Users/fixture',fileRoots:['/Users/fixture'],shellScope:'user' as const,timezone:'Asia/Shanghai'}
  408. it('snapshots negotiated host facts per owner without inventing paths for old clients',()=>{
  409. const result=service.exchange({host:{...host,full:[ownerKey('owner')],fileTransferVersion:1,environment:environmentMetadata},results:[]})
  410. expect(result.capabilities).toEqual({environmentMetadata:1,desktopPlatforms:['darwin','win32']})
  411. service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'旧客户端'},results:[]})
  412. const snapshot=service.snapshot('owner',agent,'local'),server=snapshot.hosts.find(host=>host.id==='local')!
  413. expect(server.metadata).toEqual(environmentMetadata)
  414. expect(server.capabilities.shell).toEqual({enabled:true,status:'ready'})
  415. expect(server.transfer).toEqual({protocol:'chunked',readMaxMiB:25,writeMaxMiB:25})
  416. expect(snapshot.hosts.find(host=>host.id===HOST_A)?.metadata).toBeUndefined()
  417. expect(JSON.stringify(service.snapshot('other',agent,'local'))).not.toContain('/Users/fixture')
  418. expect(JSON.stringify(snapshot)).not.toContain(ownerKey('owner'))
  419. server.metadata!.fileRoots!.push('/changed')
  420. expect(service.snapshot('owner',agent).hosts.find(host=>host.id==='local')?.metadata?.fileRoots).toEqual(['/Users/fixture'])
  421. })
  422. it('keeps enabled offline registered computers visible with actionable reasons',()=>{
  423. store.put('_system','desktop-host',HOST_B,{id:HOST_B,name:'尚未连接',enabled:true,tokenHash:'private',createdAt:1})
  424. service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'连接电脑',full:[ownerKey('owner')],environment:environmentMetadata},results:[]})
  425. const saved=service.snapshot('owner',agent,HOST_A)
  426. vi.useFakeTimers();vi.advanceTimersByTime(16000)
  427. const offline=service.snapshot('owner',agent,HOST_A)
  428. expect(offline.hosts.find(host=>host.id===HOST_A)).toMatchObject({source:true,online:false,capabilities:{shell:{enabled:false,status:'offline'}}})
  429. expect(offline.hosts.find(host=>host.id===HOST_A)?.metadata).toBeUndefined()
  430. expect(offline.hosts.find(host=>host.id===HOST_B)).toMatchObject({name:'尚未连接',online:false,open:true,capabilities:{view:{enabled:false,status:'offline'}}})
  431. expect(saved.hosts.find(host=>host.id===HOST_A)?.capabilities.shell.status).toBe('ready')
  432. expect(service.deviceContextLine('owner',HOST_A)).toContain('不能回退到服务器')
  433. expect(JSON.stringify(offline)).not.toContain('private')
  434. })
  435. it.each([false,true])('excludes revoked computers from Bot inventory even with a stale session: %s',staleSession=>{
  436. const record={id:HOST_B,name:'旧配对电脑',enabled:false,tokenHash:'private',createdAt:1}
  437. store.put('_system','desktop-host',HOST_B,record)
  438. service.exchange({host,results:[]})
  439. service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'studio'},results:[]})
  440. if(staleSession)service.remoteExchange(HOST_B,{host:{...host,id:randomUUID(),name:record.name},results:[]})
  441. for(const source of [HOST_A,HOST_B]){
  442. const snapshot=service.snapshot('owner',agent,source)
  443. expect(snapshot.hosts.map(host=>host.id)).toEqual(['local',HOST_A])
  444. expect(snapshot.sourceHost).toBe(source)
  445. const inventory=service.onlineHostsLine('owner',source)
  446. expect(inventory).toContain('host="server"')
  447. expect(inventory).toContain(`host="${HOST_A}"`)
  448. expect(inventory).not.toContain(HOST_B)
  449. expect(inventory).not.toContain(record.name)
  450. }
  451. expect(service.deviceContextLine('owner',HOST_B)).toContain('不能回退到服务器')
  452. expect(store.get('_system','desktop-host',HOST_B)).toEqual(record)
  453. store.put('_system','desktop-host',HOST_B,{...record,enabled:true})
  454. expect(service.snapshot('owner',agent).hosts.map(host=>host.id)).toEqual(['local',HOST_A,HOST_B])
  455. })
  456. it('distinguishes human takeover from grants and rechecks revocation after collecting a snapshot',async()=>{
  457. host.full=[ownerKey('owner')];drive()
  458. const ticket=await take(),snapshot=service.snapshot('owner',agent,'local'),server=snapshot.hosts[0]!
  459. expect(server.capabilities.shell).toEqual({enabled:true,status:'human_control'})
  460. await request(app()).post(base()+'/computer/giveback').send({controlId:ticket.controlId,token:ticket.token}).expect(200)
  461. expect(service.snapshot('owner',agent).hosts[0]?.capabilities.shell.status).toBe('ready')
  462. host.full=[];service.exchange({host,results:[]})
  463. const epochs=Object.fromEntries(snapshot.hosts.map(host=>[host.id,host.epoch!]))
  464. await expect(service.call('owner',agent.id,'desktop_file_list',{host:'server',path:'.'},new AbortController().signal,()=>{},'local',epochs,'local')).rejects.toMatchObject({code:'desktop_full_required'})
  465. expect(snapshot.hosts[0]?.capabilities.shell.enabled).toBe(true)
  466. expect(service.snapshot('owner',agent).hosts[0]?.capabilities.shell).toEqual({enabled:false,status:'not_authorized'})
  467. })
  468. it('rejects malformed metadata without replacing the last admitted host state',()=>{
  469. service.exchange({host,results:[]})
  470. expect(()=>service.exchange({host:{...host,environment:{...environmentMetadata,homeDirectory:'bad\npath'}},results:[]})).toThrow()
  471. expect(service.snapshot('owner',agent).hosts[0]?.epoch).toBe(host.id)
  472. expect(service.snapshot('owner',agent).hosts[0]?.metadata).toBeUndefined()
  473. })
  474. it('does not dispatch to a newly connected target outside the captured host set',async()=>{
  475. drive()
  476. const snapshot=service.snapshot('owner',agent,'local'),epochs=Object.fromEntries(snapshot.hosts.map(host=>[host.id,host.epoch!]))
  477. const commands:unknown[]=[]
  478. driveRemote(HOST_A,{...host,id:randomUUID()},command=>{commands.push(command);return {ok:true}})
  479. await expect(service.call('owner',agent.id,'desktop_environment_view',{host:HOST_A},new AbortController().signal,()=>{},'local',epochs,'local')).rejects.toMatchObject({code:'desktop_context_changed'})
  480. expect(commands).toEqual([])
  481. })