profileComputer.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {createServer} from 'node:http'
  4. import {DatabaseSync} from 'node:sqlite'
  5. import {mkdtemp,rm,writeFile} from 'node:fs/promises'
  6. import {tmpdir} from 'node:os'
  7. import {join} from 'node:path'
  8. import {randomUUID} from 'node:crypto'
  9. import {WebSocketServer,type WebSocket} from 'ws'
  10. import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
  11. import {UpstreamClient} from '../../src/server/upstream'
  12. import {UpstreamServiceSession} from '../../src/server/localAuth'
  13. import type {GatewayFrame} from '../../src/server/workspaceGateway'
  14. async function fixture(){
  15. const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(':memory:')
  16. const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
  17. const sessions=new Map<string,{stored:string;running:boolean}>()
  18. let socket:WebSocket,interruptStops=true,allowed=true
  19. const server=createServer(async(req,res)=>{
  20. let body='';for await(const chunk of req)body+=chunk
  21. if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
  22. res.setHeader('Content-Type','application/json')
  23. res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true}))
  24. })
  25. await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
  26. const url=new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`)
  27. const ws=new WebSocketServer({server})
  28. const emit=(type:string,payload:Record<string,unknown>={},runtimeId=[...sessions.keys()].at(-1)!)=>socket.send(JSON.stringify({method:'event',params:{type,session_id:runtimeId,payload}}))
  29. ws.on('connection',connected=>{
  30. socket=connected;emit('gateway.ready')
  31. connected.on('message',raw=>{
  32. const request=JSON.parse(String(raw));calls.push(request)
  33. let result:any={ok:true}
  34. if(['session.create','session.resume'].includes(request.method)){
  35. const id=randomUUID(),stored=request.method==='session.resume'?request.params.session_id:randomUUID()
  36. sessions.set(id,{stored,running:false})
  37. result={session_id:id,stored_session_id:stored,running:false,info:{profile_name:request.params.profile,cwd:home}}
  38. }else if(request.method==='session.active_list')result={sessions:[...sessions].map(([id,s])=>({id,status:s.running?'working':'idle'}))}
  39. else if(request.method==='session.usage')result={context_used:123,context_max:32000}
  40. else if(request.method==='file.attach')result={ref_text:`[host attachment](${home}/input.txt)`}
  41. else if(request.method==='prompt.submit')sessions.get(request.params.session_id)!.running=true
  42. else if(request.method==='session.interrupt'&&interruptStops){sessions.get(request.params.session_id)!.running=false;emit('message.complete',{status:'interrupted'},request.params.session_id)}
  43. connected.send(JSON.stringify({id:request.id,result}))
  44. })
  45. })
  46. const client=new UpstreamClient(url),target={url,client,session:new UpstreamServiceSession(client,()=>undefined)}
  47. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/must-not-start-worker',hermesSource:home,hermesHome:home,network:'none'}},home)
  48. await runtime.ready;runtime.retainDesktops=true
  49. const resolver=vi.spyOn(runtime,'resolve').mockRejectedValue(new Error('isolated Worker must not be used'))
  50. vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home})
  51. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
  52. vi.spyOn(runtime.provider,'inspect').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
  53. vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  54. vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'image',width:100,height:80,capturedAt:Date.now()})
  55. const execute=vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm result',stderr:''})
  56. const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner',profileSession:true,hostAccess:true}
  57. const gateway=()=>{
  58. const instance=new ComputerGateway(runtime,meta,randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}),target)
  59. instance.onEvent=frame=>frames.push(frame);gateways.push(instance);return instance
  60. }
  61. const post=async(path:string,body:unknown)=>{
  62. const binding=bindings.at(-1)
  63. const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
  64. return {status:response.status,body:await response.json() as any}
  65. }
  66. const tool=async(name:string,args:unknown)=>{
  67. const catalog=await post('/tools/list',{})
  68. const found=catalog.body.tools.find((t:any)=>t.name===name)
  69. expect(found,JSON.stringify(catalog)).toBeDefined()
  70. return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
  71. }
  72. return {home,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
  73. setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
  74. async close(){
  75. interruptStops=true;for(const session of sessions.values())session.running=false
  76. await Promise.all(gateways.map(g=>g.close().catch(()=>{})))
  77. await runtime.controls.close();await runtime.pool.close();db.close();client.close()
  78. for(const c of ws.clients)c.terminate()
  79. await new Promise<void>(resolve=>ws.close(()=>resolve()))
  80. await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  81. vi.restoreAllMocks();await rm(home,{recursive:true,force:true})
  82. },
  83. }
  84. }
  85. it('uses a native Profile session and bridge for VM tools, preserves rotated history and keeps Worker sessions separate',async()=>{
  86. const f=await fixture()
  87. try{
  88. const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
  89. g.installTeamLease('team',[{id:'team_test',name:'team_test',description:'team fixture',inputSchema:{}}],async()=>({proof:'team-result'}))
  90. const attached=await g.rpc('file.attach',{session_id:opened.session_id,name:'input.txt',data_url:'data:text/plain;base64,aGk='})
  91. expect(attached.ref_text).toContain(f.home)
  92. await g.rpc('prompt.submit',{session_id:opened.session_id,text:'Use the Profile and its local VM'})
  93. expect(f.resolver).not.toHaveBeenCalled();expect(f.runtime.workers.size).toBe(0)
  94. expect(f.calls.find(c=>c.method==='session.create')?.params).toMatchObject({profile:'default',close_on_disconnect:false})
  95. expect(f.calls.find(c=>c.method==='prompt.submit')?.params.text).toContain('本机协作模式')
  96. expect(f.bindings.at(-1)).toMatchObject({native_tools:true,profile:'default'})
  97. const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
  98. expect(catalog).toEqual(expect.arrayContaining(['computer_shell','computer_copy_file','team_test']))
  99. expect(catalog).not.toContain('host_shell')
  100. expect((await f.tool('computer_shell',{command:'pwd'})).body.structuredContent).toMatchObject({stdout:'vm result'})
  101. expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
  102. expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})
  103. await writeFile(join(f.home,'source.bin'),Buffer.from([0,1,255]))
  104. await f.tool('computer_copy_file',{from:'host',source:'source.bin',destination:'target.bin'})
  105. expect(f.execute.mock.calls.at(-1)?.[2]?.input).toEqual(Buffer.from([0,1,255]))
  106. const rotated=randomUUID();f.emit('session.info',{stored_session_id:rotated})
  107. await vi.waitFor(()=>expect(f.runtime.session(opened.session_id,f.meta,'default').profileSessionId).toBe(rotated))
  108. expect(f.frames.at(-1)?.payload.stored_session_id).toBe(opened.session_id)
  109. f.sessions.values().next().value!.running=false;f.emit('message.complete',{text:'Profile finished',status:'complete'})
  110. await vi.waitFor(()=>expect(f.frames.at(-1)?.payload.text).toBe('Profile finished'))
  111. expect((await f.runtime.controls.status(f.meta)).mode).toBe('idle')
  112. expect(await g.rpc('session.usage',{session_id:opened.session_id})).toMatchObject({context_used:123})
  113. await g.close()
  114. const resumed=f.gateway();await resumed.connect();await resumed.rpc('session.resume',{profile:'default',session_id:opened.session_id})
  115. expect(f.calls.findLast(c=>c.method==='session.resume')?.params.session_id).toBe(rotated)
  116. expect(()=>f.runtime.session(opened.session_id,{...f.meta,profileSession:false},'default')).toThrow('执行方式已改变')
  117. }finally{await f.close()}
  118. })
  119. it('waits for native termination and VM calls before human takeover, then resumes only after giveback',async()=>{
  120. const f=await fixture()
  121. try{
  122. const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
  123. await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work'})
  124. let finishTool!:()=>void
  125. f.execute.mockImplementationOnce(async()=>{await new Promise<void>(done=>{finishTool=done});return {stdout:'finished',stderr:''}})
  126. const pending=f.tool('computer_shell',{command:'long command'})
  127. await vi.waitFor(()=>expect(finishTool).toBeTypeOf('function'))
  128. f.setInterruptStops(false)
  129. const controlId=randomUUID();await f.runtime.controls.take(f.meta,'default',controlId,async()=>{})
  130. await vi.waitFor(()=>expect(f.calls.some(c=>c.method==='session.interrupt')).toBe(true))
  131. expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
  132. for(const native of f.sessions.values())native.running=false
  133. f.emit('message.complete',{status:'interrupted'})
  134. await new Promise(done=>setTimeout(done,150))
  135. expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
  136. finishTool();await pending
  137. await g.takeControl(controlId,()=>{})
  138. await vi.waitFor(async()=>expect((await f.runtime.controls.status(f.meta)).mode).toBe('human'))
  139. expect(f.frames.some(frame=>frame.type==='message.complete')).toBe(false)
  140. expect((await f.tool('computer_shell',{command:'must not execute'})).body.isError).toBe(true)
  141. expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(1)
  142. await f.runtime.controls.giveBack(f.meta,controlId,'已完成登录')
  143. expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(2)
  144. expect(f.calls.at(-1)?.params.text).toContain('已完成登录')
  145. expect((await f.runtime.controls.status(f.meta)).mode).toBe('agent')
  146. f.setInterruptStops(true);await g.rpc('session.interrupt',{session_id:session.session_id})
  147. expect((await f.runtime.controls.status(f.meta)).mode).toBe('off')
  148. await expect(f.post('/tools/list',{})).rejects.toThrow()
  149. }finally{await f.close()}
  150. })