workspaceRemoteAgents.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { randomUUID } from 'node:crypto'
  6. import { WebSocketServer } from 'ws'
  7. import request from 'supertest'
  8. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  9. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  10. import { loadServerConfig } from '../../src/server/config'
  11. import { LocalAuthStore } from '../../src/server/localAuth'
  12. let runtime: ApplicationRuntime, home:string, ws:WebSocketServer, frames:any[], forwarded:any[]
  13. class Auth extends LocalAuthStore { override isUserActive(id:string) { return ['owner','other'].includes(id) } }
  14. beforeEach(async()=>{
  15. home=mkdtempSync(join(tmpdir(),'remote-agent-export-'));frames=[];forwarded=[]
  16. ws=new WebSocketServer({port:0,host:'127.0.0.1'});await new Promise<void>(resolve=>ws.once('listening',resolve))
  17. ws.on('connection',socket=>{
  18. socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
  19. socket.on('message',raw=>{
  20. const frame=JSON.parse(String(raw));frames.push(frame)
  21. const result=['session.create','session.resume'].includes(frame.method) ? {session_id:randomUUID(),stored_session_id:frame.params.session_id ?? randomUUID(),info:{profile_name:frame.params.profile}} : {ok:true}
  22. socket.send(JSON.stringify({id:frame.id,result}))
  23. })
  24. })
  25. const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${(ws.address() as any).port}`})
  26. runtime=createApplication({config,auth:new Auth(home),fetchImpl:(async(input,init={})=>{
  27. const url=new URL(String(input))
  28. if(url.hostname==='nested.test') {
  29. forwarded.push({path:url.pathname,headers:new Headers(init.headers),body:init.body?JSON.parse(String(init.body)):undefined})
  30. return Response.json(url.pathname.endsWith('/channels') ? {id:'55555555-5555-4555-8555-555555555555'} : {state:'confirmed',response:{result:{session_id:'runtime',stored_session_id:'stored'}}},{status:url.pathname.endsWith('/channels')?201:200})
  31. }
  32. if(url.pathname==='/api/auth/ws-ticket')return Response.json({ticket:'test-ticket'})
  33. return Response.json({messages:[],profiles:[{name:'remote-profile'}]})
  34. }) as typeof fetch})
  35. })
  36. afterEach(async()=>{
  37. runtime.close();for(const client of ws.clients)client.terminate()
  38. await new Promise<void>(resolve=>ws.close(()=>resolve()));rmSync(home,{recursive:true,force:true})
  39. })
  40. function pair(owner?:string) {
  41. const code=runtime.pairings.create('delegated-native-cookie=test',undefined,owner)
  42. return runtime.pairings.claim({pairingID:code.id,secret:code.secret,deviceName:'parent'})
  43. }
  44. function call(grant:ReturnType<typeof pair>,method:'get'|'post'|'patch',path:string) {
  45. return request(runtime.app.callback())[method](`/node/${grant.device.id}/api/workspace-agents${path}`)
  46. .set('Host','127.0.0.1:15300').set('Authorization',`Bearer ${grant.token}`)
  47. }
  48. describe('referenced remote Bot Agents',()=>{
  49. it('lists only the QR issuing account and requires renewed authorization for legacy grants',async()=>{
  50. const agent=runtime.workspace.createAgent('owner',{name:'我的 Agent',profile:'remote-profile'})
  51. runtime.workspace.createAgent('other',{name:'其他账号私有 Agent',profile:'remote-profile'})
  52. const grant=pair('owner')
  53. expect((await call(grant,'get','').expect(200)).body.agents.map((a:any)=>a.id)).toEqual([agent.id])
  54. await call(pair(),'get','').expect(403)
  55. await call(grant,'patch',`/${agent.id}`).send({instructions:'overwrite'}).expect(405)
  56. runtime.pairings.revoke(grant.device.id)
  57. await call(grant,'get','').expect(401)
  58. })
  59. it('keeps the same delegated identity when renewing the QR grant',()=>{
  60. const old=pair()
  61. const qr=runtime.pairings.create('new-cookie=test',undefined,'owner')
  62. const renewed=runtime.pairings.claim({pairingID:qr.id,secret:qr.secret,deviceName:'parent',existingDeviceID:old.device.id,existingToken:old.token})
  63. expect(renewed.device.id).toBe(old.device.id);expect(renewed.token).toBe(old.token)
  64. expect(runtime.pairings.workspaceOwner(old.device.id,old.token,'agents.read')).toBe('owner')
  65. })
  66. it('executes using the remote profile and live remote rules, and isolates other Agent channels/history',async()=>{
  67. const agent=runtime.workspace.createAgent('owner',{name:'远端策划',profile:'remote-profile',instructions:'REMOTE RULE V1'})
  68. const other=runtime.workspace.createAgent('owner',{name:'另一 Agent',profile:'other-profile'})
  69. const grant=pair('owner'), base=`/${agent.id}/gateway/api/realtime`
  70. const channel=(await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(201)).body.id
  71. async function rpc(method:string,params:any) {
  72. return call(grant,'post',`${base}/channels/${channel}/commands`).set('Idempotency-Key',randomUUID()).send({jsonrpc:'2.0',method,params})
  73. }
  74. const opened=await rpc('session.create',{profile:'wrong-profile',model:'overwrite',cwd:'/different',fast:true,reasoning_effort:'high',messages:[{role:'system',content:'override'}]})
  75. expect(opened.status).toBe(200)
  76. const result=opened.body.response.result
  77. expect(frames.at(-1).params.profile).toBe('remote-profile');expect(frames.at(-1).params.model).toBeUndefined();expect(frames.at(-1).params.cwd).toBe('');expect(frames.at(-1).params.fast).toBeUndefined();expect(frames.at(-1).params.messages).toBeUndefined()
  78. expect((await rpc('prompt.submit',{session_id:result.session_id,text:'question'})).status).toBe(200)
  79. expect(frames.at(-1).params.text).toContain('REMOTE RULE V1')
  80. runtime.workspace.updateAgent('owner',agent.id,{instructions:'REMOTE RULE V2'})
  81. expect((await rpc('prompt.submit',{session_id:result.session_id,text:'next question'})).status).toBe(200)
  82. expect(frames.at(-1).params.text).toContain('REMOTE RULE V2');expect(frames.at(-1).params.text).not.toContain('REMOTE RULE V1')
  83. expect((await rpc('profiles.configure',{profile:'remote-profile'})).status).toBe(403)
  84. await call(grant,'get',`/${other.id}/gateway/api/realtime/channels/${channel}/events`).expect(403)
  85. await call(grant,'get',`/${agent.id}/gateway/api/sessions/foreign/messages`).expect(403)
  86. await call(grant,'get',`/${agent.id}/gateway/api/sessions/${result.stored_session_id}/messages`).expect(200)
  87. const next=(await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(201)).body.id
  88. await call(grant,'post',`${base}/channels/${next}/commands`).set('Idempotency-Key',randomUUID())
  89. .send({jsonrpc:'2.0',method:'session.resume',params:{session_id:result.stored_session_id,profile:'wrong'}}).expect(200)
  90. runtime.workspace.updateAgent('owner',agent.id,{archived:true})
  91. await call(grant,'get',`/${agent.id}`).expect(410)
  92. })
  93. it('can reference an Agent backed by another paired child without exposing parent credentials',async()=>{
  94. const nodes=runtime.workspaceRuntime.nodes, nodeId=randomUUID(), childDevice=randomUUID()
  95. runtime.workspace.put('owner','node',nodeId,{id:nodeId,name:'nested',url:'http://nested.test:15300/',transport:'paired-web',deviceId:childDevice,secret:nodes.seal({token:'nested-secret'})})
  96. const agent=runtime.workspace.createAgent('owner',{name:'二级远端 Agent',profile:'remote-profile',nodeId,instructions:'nested rules'})
  97. const grant=pair('owner'), base=`/${agent.id}/gateway/api/realtime`
  98. const channel=(await call(grant,'post',`${base}/channels`).send({channel:'chat'}).expect(201)).body.id
  99. await call(grant,'post',`${base}/channels/${channel}/commands`).set('Idempotency-Key',randomUUID())
  100. .send({jsonrpc:'2.0',method:'session.create',params:{profile:'wrong'}}).expect(200)
  101. expect(forwarded.at(-1).headers.get('authorization')).toBe('Bearer nested-secret')
  102. expect(forwarded.at(-1).body.params.profile).toBe('remote-profile')
  103. expect(forwarded.at(-1).headers.get('x-yaoyao-agent-hops')).toBe('1')
  104. })
  105. })