workerProxy.live.test.ts 6.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. // @vitest-environment node
  2. import {afterEach,expect,it,vi} from 'vitest'
  3. import {createServer,type IncomingMessage,type ServerResponse,type Server} from 'node:http'
  4. import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join,resolve} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {HermesWorkerProcess} from '../../src/runner/worker/process'
  9. afterEach(()=>vi.unstubAllEnvs())
  10. const enabled=!!process.env.YAOYAO_HERMES_PYTHON&&!!process.env.YAOYAO_HERMES_SOURCE
  11. const listen=(server:Server)=>new Promise<number>(resolve=>server.listen(0,'127.0.0.1',()=>resolve((server.address() as {port:number}).port)))
  12. const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  13. it.skipIf(!enabled).each(['uppercase','lowercase','no-proxy','unconfigured'] as const)('uses the actual Hermes loop with Profile proxy policy: %s',async mode=>{
  14. const root=await mkdtemp(join(tmpdir(),'yaoyao-worker-proxy-live-')),hermesHome=join(root,'hermes'),workerHome=join(root,'worker')
  15. const profile=mode==='uppercase'?'default':'writer',profileHome=profile==='default'?hermesHome:join(hermesHome,'profiles',profile)
  16. await mkdir(profileHome,{recursive:true})
  17. const proxyRequests:Array<{url:string;auth?:string}>=[],directRequests:string[]=[],modelRequests:any[]=[]
  18. const reply=async(req:IncomingMessage,res:ServerResponse)=>{
  19. const url=new URL(req.url!,'http://fixture')
  20. if(req.method==='GET'&&url.pathname==='/v1/models'){
  21. res.setHeader('Content-Type','application/json');res.end(JSON.stringify({data:[{id:'fixture-model',object:'model',context_length:65536}]}));return
  22. }
  23. if(req.method!=='POST'||url.pathname!=='/v1/chat/completions'){res.statusCode=404;res.end('{}');return}
  24. let body='';for await(const chunk of req)body+=chunk
  25. const input=JSON.parse(body);modelRequests.push(input)
  26. if(req.headers.authorization!=='Bearer fixture-model-key'){res.statusCode=401;res.end('{}');return}
  27. const message={role:'assistant',content:'代理链路已完成完整回复。'}
  28. if(input.stream){
  29. res.setHeader('Content-Type','text/event-stream')
  30. res.end('data: '+JSON.stringify({id:'fixture-response',object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:message,finish_reason:'stop'}]})+'\n\ndata: [DONE]\n\n')
  31. }else{
  32. res.setHeader('Content-Type','application/json')
  33. res.end(JSON.stringify({id:'fixture-response',object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
  34. }
  35. }
  36. // The HTTP proxy terminates a fixture-only origin. The .invalid name cannot
  37. // resolve directly, so a successful model response proves proxy traversal.
  38. const proxy=createServer((req,res)=>{
  39. proxyRequests.push({url:req.url!,auth:req.headers['proxy-authorization']})
  40. if(!req.url?.startsWith('http://worker-proxy.invalid/')){res.statusCode=502;res.end('{}');return}
  41. void reply(req,res).catch(()=>{res.statusCode=500;res.end('{}')})
  42. })
  43. const direct=createServer((req,res)=>{directRequests.push(req.url!);void reply(req,res).catch(()=>{res.statusCode=500;res.end('{}')})})
  44. const workers:HermesWorkerProcess[]=[]
  45. let diagnostics=''
  46. try{
  47. const [proxyPort,directPort]=await Promise.all([listen(proxy),listen(direct)])
  48. const proxyURL=`http://fixture-user:fixture-proxy-password@127.0.0.1:${proxyPort}`
  49. const proxied=mode==='uppercase'||mode==='lowercase'
  50. const baseURL=proxied?'http://worker-proxy.invalid/v1':`http://127.0.0.1:${directPort}/v1`
  51. // A Runner-level proxy must neither override a Profile nor become a fallback.
  52. for(const key of ['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy'])vi.stubEnv(key,proxyURL)
  53. for(const key of ['NO_PROXY','no_proxy'])vi.stubEnv(key,'')
  54. await writeFile(join(profileHome,'config.yaml'),`model:\n provider: custom\n default: fixture-model\n base_url: ${baseURL}\n api_key: \${WORKER_FIXTURE_KEY}\n api_mode: chat_completions\nterminal:\n cwd: /home/cua/workspace\n`,{mode:0o600})
  55. const proxyVars=mode==='uppercase'?`HTTP_PROXY=\${FIXTURE_PROXY_URL}\nHTTPS_PROXY=\${FIXTURE_PROXY_URL}\nNO_PROXY=\n`:mode==='lowercase'?`http_proxy=\${FIXTURE_PROXY_URL}\nhttps_proxy=\${FIXTURE_PROXY_URL}\nno_proxy=\n`:mode==='no-proxy'?`HTTP_PROXY=\${FIXTURE_PROXY_URL}\nHTTPS_PROXY=\${FIXTURE_PROXY_URL}\nNO_PROXY=127.0.0.1,localhost\n`:''
  56. await writeFile(join(profileHome,'.env'),`WORKER_FIXTURE_KEY=fixture-model-key\nFIXTURE_PROXY_URL=${proxyURL}\nUNRELATED_SECRET=fixture-unrelated-secret\n${proxyVars}`,{mode:0o600})
  57. const resolver=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{mode:'resolve',profile,hermesHome,hermesSource:process.env.YAOYAO_HERMES_SOURCE})
  58. workers.push(resolver)
  59. const resolved=await resolver.wait('resolved');await resolver.close()
  60. expect(resolved.proxyEnv??{}).not.toHaveProperty('UNRELATED_SECRET')
  61. expect(resolved.proxyEnv??{}).not.toHaveProperty('FIXTURE_PROXY_URL')
  62. if(mode==='unconfigured')expect(resolved).not.toHaveProperty('proxyEnv')
  63. const worker=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{diagnostic:true,mode:'run',home:workerHome,hermesSource:process.env.YAOYAO_HERMES_SOURCE,model:resolved.model,proxyEnv:resolved.proxyEnv,sessionId:randomUUID(),taskId:randomUUID(),cwd:resolved.cwd,tools:[],prompt:'返回测试回复。',history:[]},text=>{diagnostics+=text})
  64. workers.push(worker)
  65. const frames:unknown[]=[];worker.onEvent=frame=>frames.push(frame)
  66. const complete=await worker.wait('complete',45000).catch(error=>{throw new Error(`${String(error)}\n${diagnostics}`)})
  67. expect(complete).toMatchObject({text:'代理链路已完成完整回复。',completed:true})
  68. expect(modelRequests.length).toBeGreaterThan(0)
  69. if(proxied){
  70. expect(proxyRequests.some(request=>request.url==='http://worker-proxy.invalid/v1/chat/completions')).toBe(true)
  71. expect(proxyRequests.find(request=>request.url.endsWith('/chat/completions'))?.auth).toBe('Basic '+Buffer.from('fixture-user:fixture-proxy-password').toString('base64'))
  72. expect(directRequests).toEqual([])
  73. }else{
  74. expect(directRequests).toContain('/v1/chat/completions')
  75. expect(proxyRequests).toEqual([])
  76. }
  77. expect(JSON.stringify(frames)+diagnostics).not.toMatch(/fixture-proxy-password|fixture-unrelated-secret|proxyEnv/)
  78. }finally{
  79. await Promise.all(workers.map(worker=>worker.close()))
  80. await Promise.all([close(proxy),close(direct)])
  81. await rm(root,{recursive:true,force:true})
  82. }
  83. },60000)