dockerPlatform.live.test.ts 7.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. // @vitest-environment node
  2. import {it,expect} from 'vitest'
  3. import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import {randomUUID} from 'node:crypto'
  7. import {createServer} from 'node:http'
  8. import {execFile,spawn} from 'node:child_process'
  9. import {promisify} from 'node:util'
  10. import request from 'supertest'
  11. const exec=promisify(execFile)
  12. it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE||!process.env.YAOYAO_HERMES_PYTHON)('executes an isolated task through the packaged Docker control plane and an external Runner',async()=>{
  13. const home=await mkdtemp(join(tmpdir(),'yaoyao-docker-platform-')),name=`yaoyao-platform-${randomUUID()}`,webHome=join(home,'web'),hermesHome=join(home,'hermes')
  14. await mkdir(webHome);await mkdir(hermesHome)
  15. const upstream=createServer(async(req,res)=>{
  16. res.setHeader('Content-Type','application/json')
  17. if(req.method==='GET'){res.end(JSON.stringify(req.url?.startsWith('/api/profiles')?{profiles:[{name:'default'}]}:req.url?.startsWith('/api/config')?{terminal:{cwd:'/workspace/docker-proof'}}:{ok:true}));return}
  18. let body='';for await(const chunk of req)body+=chunk
  19. const input=JSON.parse(body||'{}'),called=input.messages?.some((message:any)=>message.role==='tool'),exported=input.messages?.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
  20. const message=exported?{role:'assistant',content:'Docker 控制面任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:called?'computer_export':'computer_shell',arguments:JSON.stringify(called?{path:'docker-proof.txt'}:{command:'printf docker-control-plane-proof > docker-proof.txt; id -u'})}}]}
  21. if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool,index)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
  22. else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}]}))
  23. })
  24. await new Promise<void>(done=>upstream.listen(0,'0.0.0.0',done));const upstreamPort=(upstream.address() as {port:number}).port
  25. let child:ReturnType<typeof spawn>|undefined,exited:Promise<void>|undefined
  26. try{
  27. await exec('docker',['run','-d','--name',name,'--init','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','--add-host','host.docker.internal:host-gateway','-p','127.0.0.1::15300','--mount',`type=volume,source=${name}-data,target=/var/lib/hermes-yaoyao`,'-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=localhost,127.0.0.1','-e',`HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:${upstreamPort}`,process.env.YAOYAO_PLATFORM_IMAGE!])
  28. const detail=JSON.parse((await exec('docker',['inspect',name])).stdout)[0],port=detail.NetworkSettings.Ports['15300/tcp'][0].HostPort,address=`http://127.0.0.1:${port}`,client=request.agent(address)
  29. await expect.poll(async()=>{try{return (await client.get('/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
  30. expect(detail.Config.User).toBe('node');expect(detail.HostConfig.ReadonlyRootfs).toBe(true);expect(detail.HostConfig.CapDrop).toContain('ALL')
  31. expect((await exec('docker',['exec',name,'id','-u'])).stdout.trim()).toBe('1000')
  32. const bootstrap=await client.get('/api/app/bootstrap').expect(200)
  33. const setup=await client.post('/api/app/setup').set('Origin',address).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'docker-fixture',password:'docker-fixture-password'}).expect(200)
  34. expect(setup.body.user.id).toBeTruthy()
  35. const fresh=await client.get('/api/app/bootstrap').expect(200)
  36. const post=(path:string,body:unknown)=>client.post(path).set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send(body)
  37. const registration=await post('/api/app/admin/runners',{name:'容器外执行节点',allowedProfiles:['default']}).expect(201)
  38. const runnerID=registration.body.runner.id
  39. await writeFile(join(hermesHome,'config.yaml'),`model:\n provider: custom\n default: fixture-model\n base_url: http://127.0.0.1:${upstreamPort}/v1\n api_key: fixture-key\n api_mode: chat_completions\nterminal:\n cwd: /workspace/docker-proof\n`,{mode:0o600})
  40. const config={protocol:1,serverURL:address,runnerId:runnerID,token:registration.body.token,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',network:'none',imageId:process.env.YAOYAO_COMPUTER_IMAGE,python:process.env.YAOYAO_HERMES_PYTHON,hermesSource:process.env.YAOYAO_HERMES_SOURCE,hermesHome}}
  41. const path=join(home,'runner.json');await writeFile(path,JSON.stringify(config),{mode:0o600})
  42. child=spawn(process.execPath,[resolve('.runner-build/runner.mjs'),'--config',path],{stdio:'ignore'})
  43. exited=new Promise(done=>child!.once('exit',()=>done()))
  44. await expect.poll(async()=>(await client.get('/api/app/admin/runners')).body.runners?.some((runner:any)=>runner.id===runnerID&&runner.online),{timeout:15000}).toBe(true)
  45. const created=await post('/api/app/agents',{name:'Docker 隔离成员',profile:'default',execution:'computer'}).expect(201)
  46. const agentID=created.body.agent.id,conversations=await client.get('/api/app/conversations').expect(200),conversation=conversations.body.conversations.find((item:any)=>item.memberIds.includes(agentID))
  47. const sent=await post(`/api/app/conversations/${conversation.id}/messages`,{requestId:randomUUID(),content:'执行当前隔离工具并导出结果。'}).expect(202)
  48. let result:any,message:any
  49. await expect.poll(async()=>{result=(await client.get(`/api/app/conversations/${conversation.id}`)).body;message=result.messages?.find((item:any)=>item.role==='assistant'&&item.runId===sent.body.run.id);return message?.status??'pending'},{timeout:45000}).toMatch(/complete|failed|uncertain/)
  50. expect(message.status,JSON.stringify(result)).toBe('complete')
  51. expect(message.runId).toBe(sent.body.run.id)
  52. const file=message.attachments?.[0]
  53. expect(file?.id).toBeTruthy()
  54. expect((await client.get(`/api/app/files/${file.id}/download`).expect(200)).text).toBe('docker-control-plane-proof')
  55. expect(await readFile(join(home,'state',runnerID,'computer-workspaces',agentID,'docker-proof.txt'),'utf8')).toBe('docker-control-plane-proof')
  56. const identity=await client.get('/api/status').expect(200);expect(identity.body.server_kind).toBe('yaoyao-web')
  57. }catch(error){const logs=await exec('docker',['logs','--tail','35',name]).catch(()=>({stdout:'',stderr:''}));console.error(logs.stdout,logs.stderr);throw error}finally{
  58. child?.kill('SIGTERM');await exited
  59. await exec('docker',['rm','-f',name]).catch(()=>{})
  60. await exec('docker',['volume','rm',name+'-data']).catch(()=>{})
  61. await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
  62. await rm(home,{recursive:true,force:true})
  63. }
  64. },100000)