workspacePluginIsolation.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {randomUUID} from 'node:crypto'
  4. import {mkdtempSync,rmSync} from 'node:fs'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {WebSocketServer} from 'ws'
  8. import {WorkspaceStore} from '../../src/server/workspaceStore'
  9. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  10. import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
  11. import {WorkspacePlugins} from '../../src/server/botPlugins/workspacePlugins'
  12. import {UploadStore} from '../../src/server/uploads'
  13. import {loadServerConfig} from '../../src/server/config'
  14. import {saveHostTools} from '../../src/server/hostToolSettings'
  15. import type {LocalAuthStore} from '../../src/server/localAuth'
  16. import type {ManagedBrowsers} from '../../src/server/managedBrowsers'
  17. import type {WorkspaceConversation,WorkspaceRun} from '../../src/shared/workspace'
  18. const owner='plugin-isolation-user'
  19. async function fixture(failure:'initialize'|'tools/list'){
  20. const home=mkdtempSync(join(tmpdir(),'yaoyao-plugin-isolation-'))
  21. const store=new WorkspaceStore(home),uploads=new UploadStore(home)
  22. saveHostTools(home,{managedBrowser:true,vm:false,serverComputer:false,scriptMachine:false,cloud:false})
  23. const server=new WebSocketServer({port:0,host:'127.0.0.1'})
  24. await new Promise<void>(resolve=>server.once('listening',resolve))
  25. const bindings=new Map<string,{bridge_url:string;token:string}>(),prompts:string[]=[],catalogs:any[][]=[]
  26. let integrationError:unknown,offline=false
  27. const target={
  28. url:new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`),client:{directAgent:undefined},
  29. session:{webSocketCredential:async()=>({name:'ticket',value:'test'}),request:async(path:string,input?:{body?:any})=>{
  30. if(path.endsWith('/bind'))bindings.set(input?.body.session_id,input?.body)
  31. const value=path.startsWith('/api/plugins/yaoyao-bot-bridge/')?{ok:true,version:1,ready:true,in_process:true,native_tools:true}
  32. :path==='/api/config'?{terminal:{}}:{messages:[]}
  33. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(value))}
  34. }},
  35. } as unknown as GatewayTarget
  36. const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
  37. const runtime=new WorkspaceRuntime(store,nodes,uploads)
  38. const browserOpen=vi.fn()
  39. runtime.managedBrowsers={available:()=>true,openTurn:browserOpen} as unknown as ManagedBrowsers
  40. const response=(body:unknown,status=200)=>new Response(JSON.stringify(body),{status,headers:{'Content-Type':'application/json'}})
  41. const pluginFetch:typeof fetch=async(input,init)=>{
  42. const frame=JSON.parse(String(init?.body))
  43. if(offline&&String(input).includes('offline.example.test')&&frame.method===failure)return response({error:'upstream token must stay private'},503)
  44. if(frame.method==='notifications/initialized')return new Response(null,{status:202})
  45. const result=frame.method==='initialize'?{protocolVersion:'2025-06-18',capabilities:{tools:{}}}
  46. :frame.method==='tools/list'?{tools:[{name:'echo',description:'fixture',inputSchema:{type:'object'}}]}
  47. :{content:[{type:'text',text:'healthy tool response'}]}
  48. return response({jsonrpc:'2.0',id:frame.id,result})
  49. }
  50. const auth={isUserActive:()=>true,isAdminActive:()=>true} as unknown as LocalAuthStore
  51. const plugins=new WorkspacePlugins(store,nodes,auth,runtime,home,pluginFetch)
  52. runtime.plugins=plugins
  53. const bot=store.createAgent(owner,{name:'普通聊天 Bot',profile:'default'})
  54. for(const [name,url] of [['离线服务','https://offline.example.test/mcp'],['健康服务','https://healthy.example.test/mcp']]){
  55. const plugin=plugins.save(owner,{name,transport:'http',url,agentIds:[bot.id]})
  56. await plugins.test(owner,plugin.id)
  57. const entry=store.require<any>(owner,'bot-mcp-plugin',plugin.id)
  58. store.put(owner,'bot-mcp-plugin',plugin.id,{...entry,enabled:true,revision:entry.revision+1})
  59. }
  60. offline=true
  61. server.on('connection',socket=>{
  62. socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
  63. socket.on('message',data=>{
  64. const frame=JSON.parse(String(data)),respond=(result:unknown)=>socket.send(JSON.stringify({id:frame.id,result}))
  65. if(frame.method==='session.create'||frame.method==='session.resume')respond({session_id:randomUUID(),stored_session_id:randomUUID(),running:false,info:{profile_name:frame.params.profile}})
  66. else if(frame.method==='session.cwd.set')respond({cwd:frame.params.cwd})
  67. else if(frame.method==='session.usage')respond({context_used:10,context_max:1000})
  68. else if(frame.method==='prompt.submit'){
  69. prompts.push(frame.params.text);respond({status:'streaming'})
  70. void(async()=>{
  71. try{
  72. const binding=bindings.get(frame.params.session_id)!
  73. const reply=await fetch(binding.bridge_url+'/tools/list',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:'{}'})
  74. expect(reply.status).toBe(200)
  75. catalogs.push((await reply.json() as any).tools)
  76. }catch(error){integrationError=error}
  77. socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:frame.params.session_id,payload:{text:'你好!',status:'complete'}}}))
  78. })()
  79. }else respond({ok:true,status:'interrupted'})
  80. })
  81. })
  82. const conversation=store.list<WorkspaceConversation>(owner,'conversation').find(value=>value.kind==='direct'&&value.memberIds[0]===bot.id)!
  83. return {store,runtime,conversation,prompts,catalogs,browserOpen,getError:()=>integrationError,
  84. async close(){
  85. runtime.close();plugins.close();await new Promise(resolve=>setTimeout(resolve,10))
  86. for(const client of server.clients)client.terminate()
  87. await new Promise<void>(resolve=>server.close(()=>resolve()))
  88. nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
  89. },
  90. }
  91. }
  92. it.each(['initialize','tools/list'] as const)('submits and completes 你好 despite a plugin %s failure, with a visible warning and healthy tool inventory',async failure=>{
  93. const f=await fixture(failure)
  94. try{
  95. const run=f.runtime.send(owner,f.conversation.id,{requestId:randomUUID(),content:'你好'})
  96. await vi.waitFor(()=>expect(['complete','failed']).toContain(f.store.require<WorkspaceRun>(owner,'run',run.id).status))
  97. const settled=f.store.require<WorkspaceRun>(owner,'run',run.id)
  98. expect(settled.status,settled.error).toBe('complete')
  99. expect(f.getError()).toBeUndefined()
  100. expect(f.prompts).toHaveLength(1)
  101. expect(f.prompts[0]).toContain('【本轮用户消息】\n你好')
  102. const messages=f.store.messages(owner,f.conversation.id),answer=messages.find(message=>message.role==='assistant')!
  103. expect(messages.find(message=>message.role==='user')?.content).toBe('你好')
  104. expect(answer).toMatchObject({content:'你好!',status:'complete',visible:true,serviceWarnings:[{
  105. code:'plugin_initialization_failed',service:'离线服务',message:expect.stringContaining('不可用'),
  106. }]})
  107. expect(f.prompts[0]).toContain(JSON.stringify(answer.serviceWarnings))
  108. expect(f.prompts[0]).toContain('不能宣称已经调用或执行成功')
  109. expect(f.prompts[0]).toContain('继续处理不依赖这些服务的普通对话')
  110. expect(JSON.stringify(answer)).not.toContain('upstream token')
  111. expect(f.catalogs).toHaveLength(1)
  112. const pluginTools=f.catalogs[0]!.filter(tool=>tool.name.startsWith('plugin_'))
  113. expect(pluginTools).toHaveLength(1);expect(pluginTools[0].description).toContain('健康服务')
  114. expect(f.catalogs[0]!.some(tool=>tool.name==='managed_browser_open')).toBe(true)
  115. expect(f.browserOpen).not.toHaveBeenCalled()
  116. }finally{await f.close()}
  117. })