workspacePairedNodes.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. // @vitest-environment node
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { WorkspaceNodes, WorkspaceGateway, type WorkspaceNode } from '../../src/server/workspaceGateway'
  7. import { parseWorkspacePairCode } from '../../src/server/workspacePairedNode'
  8. import { WorkspaceStore } from '../../src/server/workspaceStore'
  9. import { loadServerConfig } from '../../src/server/config'
  10. import { UpstreamClient } from '../../src/server/upstream'
  11. import { UpstreamServiceSession } from '../../src/server/localAuth'
  12. const remote = '11111111-1111-4111-8111-111111111111', device = '22222222-2222-4222-8222-222222222222',
  13. pairing = '33333333-3333-4333-8333-333333333333', channel = '44444444-4444-4444-8444-444444444444', fingerprint = 'f'.repeat(64)
  14. const homes: string[] = [], stores: WorkspaceStore[] = [], clients: UpstreamClient[] = [], nodeStores: WorkspaceNodes[] = []
  15. afterEach(() => { nodeStores.forEach(n => n.close()); clients.forEach(c => c.close()); stores.forEach(s => s.close()); homes.forEach(h => rmSync(h, { recursive: true, force: true })); homes.length = stores.length = clients.length = nodeStores.length = 0 })
  16. function code() { const url = new URL('yaoyao://pair'); for (const [k,v] of Object.entries({v:'1', url:'http://192.168.1.10:15300/', node:remote, id:pairing, fingerprint, secret:'s'.repeat(64)})) url.searchParams.set(k,v); return url.href }
  17. function setup(localID?: string) {
  18. const home = mkdtempSync(join(tmpdir(), 'workspace-child-')); homes.push(home)
  19. const config = loadServerConfig({ HERMES_YAOYAO_HOME: home }), store = new WorkspaceStore(home); stores.push(store)
  20. const seen: Array<{url: URL; headers: Headers; body: any}> = []
  21. let mismatch = false, lostCommandResponse = false, remoteRules = 'remote rules v1'
  22. const fetchImpl = (async (input: any, init: RequestInit = {}) => {
  23. const url = new URL(String(input)), headers = new Headers(init.headers), body = init.body ? JSON.parse(String(init.body)) : undefined
  24. seen.push({url,headers,body})
  25. if (url.pathname === '/api/pair/v1/capabilities') return Response.json({protocolVersion:1,serviceType:'yaoyao-web',nodeId:remote,fingerprint:mismatch ? 'x'.repeat(64) : fingerprint})
  26. if (url.pathname === '/api/pair/v1/claim') return Response.json({protocolVersion:1,serviceType:'yaoyao-web',nodeId:remote,fingerprint,deviceId:device,token:'delegated-token',scopes:['agents.read','history.read','sessions.execute'],serverUrl:`http://192.168.1.10:15300/node/${device}`})
  27. expect(headers.get('authorization')).toBe('Bearer delegated-token')
  28. expect(url.pathname).toContain(`/node/${device}/api/`)
  29. if (url.pathname.endsWith('/api/workspace-agents')) return Response.json({agents:[{id:'66666666-6666-4666-8666-666666666666',name:'远端现成 Agent',avatar:'',instructions:remoteRules,nodeId:'local',profile:'remote-profile',archived:false,revision:1,createdAt:1,updatedAt:1}]})
  30. if (url.pathname.endsWith('/profiles')) return Response.json({profiles:[{name:'remote-profile',display_name:'远端基础 Agent'}]})
  31. if (url.pathname.endsWith('/channels')) return Response.json({id:channel})
  32. if (url.pathname.endsWith('/events')) return new Response(new ReadableStream({start(controller) {
  33. controller.enqueue(new TextEncoder().encode('id: epoch:1\nevent: frame\ndata: '+JSON.stringify({method:'event',params:{type:'gateway.ready'}})+'\n\n'))
  34. init.signal?.addEventListener('abort', () => { try { controller.close() } catch {} })
  35. }}), {headers:{'Content-Type':'text/event-stream'}})
  36. if (url.pathname.endsWith('/commands') && lostCommandResponse) throw new Error('response lost after admission')
  37. if (url.pathname.endsWith('/commands') || url.pathname.includes('/api/realtime/commands/')) return Response.json({state:'confirmed',response:{result:{session_id:'runtime-child',stored_session_id:'stored-child'}}})
  38. return new Response(null,{status:204})
  39. }) as typeof fetch
  40. const client = new UpstreamClient(config.upstream, fetchImpl); clients.push(client)
  41. const nodes = new WorkspaceNodes(store,config,{url:config.upstream,client,session:new UpstreamServiceSession(client,()=>undefined)},localID); nodeStores.push(nodes)
  42. return {home,store,nodes,seen,mismatch:()=>{mismatch=true},loseResponse:()=>{lostCommandResponse=true},changeRemoteRules:()=>{remoteRules='remote rules v2'}}
  43. }
  44. describe('workspace 15300 child nodes', () => {
  45. it('rejects login codes and duplicate identity fields', () => {
  46. expect(()=>parseWorkspacePairCode(code().replace('://pair','://login'))).toThrow()
  47. expect(()=>parseWorkspacePairCode(code()+'&node='+remote)).toThrow()
  48. })
  49. it('pairs only under the owner, encrypts credentials, preserves agent binding when changing IP, and executes via HTTP/SSE', async () => {
  50. const t=setup(); await t.nodes.pair('parent-user',{qrPayload:code(),name:'远端'})
  51. const node=t.store.list<WorkspaceNode>('parent-user','node')[0]!
  52. expect(node.transport).toBe('paired-web'); expect(node.url).toContain(':15300')
  53. expect(node.secret).not.toContain('delegated-token'); expect(t.store.list('other-user','node')).toEqual([])
  54. expect(()=>t.nodes.target('other-user',node.id)).toThrow()
  55. const agent=t.store.createAgent('parent-user',{name:'策划',profile:'remote-profile',nodeId:node.id,instructions:''})
  56. await t.nodes.update('parent-user',node.id,{name:'新名字',url:'http://192.168.1.20:15300'})
  57. expect(t.store.require<any>('parent-user','agent',agent.id).nodeId).toBe(node.id)
  58. const target=t.nodes.target('parent-user',node.id)
  59. expect(target.url.hostname).toBe('192.168.1.20')
  60. const profiles=await target.session.request('/api/profiles'); expect(profiles.status).toBe(200)
  61. const gateway=new WorkspaceGateway(target)
  62. await gateway.connect()
  63. expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
  64. gateway.close()
  65. const command=t.seen.find(r=>r.url.pathname.endsWith('/commands'))!
  66. expect(command.headers.get('idempotency-key')).toBeTruthy()
  67. expect(command.body.method).toBe('session.create')
  68. expect(t.seen.every(r=>r.url.port==='15300')).toBe(true)
  69. expect(t.seen.some(r=>r.url.pathname.includes('/auth/password-login'))).toBe(false)
  70. })
  71. it('recovers a lost command response by receipt without resubmitting the command', async () => {
  72. const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
  73. const id=t.store.list<WorkspaceNode>('owner','node')[0]!.id
  74. const gateway=new WorkspaceGateway(t.nodes.target('owner',id)); await gateway.connect(); t.loseResponse()
  75. expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
  76. gateway.close()
  77. expect(t.seen.filter(r=>r.url.pathname.endsWith('/commands'))).toHaveLength(1)
  78. expect(t.seen.filter(r=>r.url.pathname.includes('/api/realtime/commands/'))).toHaveLength(1)
  79. })
  80. it('rejects self-pairing before redeeming a code', async () => {
  81. const t=setup(remote)
  82. await expect(t.nodes.pair('owner',{qrPayload:code(),name:'self'})).rejects.toThrow('自己')
  83. expect(t.seen).toEqual([])
  84. })
  85. it('imports a read-only reference without duplicates and refreshes remote configuration', async () => {
  86. const t=setup();await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
  87. const node=t.store.list<WorkspaceNode>('owner','node')[0]!, remote='66666666-6666-4666-8666-666666666666'
  88. const agent=await t.nodes.importRemoteAgent('owner',node.id,remote)
  89. expect(agent.remoteAgentId).toBe(remote);expect(agent.nodeId).toBe(node.id)
  90. expect(()=>t.store.updateAgent('owner',agent.id,{instructions:'local edit'})).toThrow('远端')
  91. expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).id).toBe(agent.id)
  92. t.changeRemoteRules();await t.nodes.remoteAgents('owner',node.id,true)
  93. expect((await t.nodes.refreshRemoteAgent('owner',agent)).instructions).toBe('remote rules v2')
  94. t.store.updateAgent('owner',agent.id,{archived:true})
  95. expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).archived).toBe(false)
  96. expect(t.store.list('owner','agent')).toHaveLength(1)
  97. expect(t.nodes.targetForAgent('owner',agent).url.pathname).toContain(`/api/workspace-agents/${remote}/gateway`)
  98. })
  99. it('checks identity at the new address before sending the saved bearer and leaves state unchanged on mismatch', async () => {
  100. const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'远端'})
  101. const node=t.store.list<WorkspaceNode>('owner','node')[0]!; t.seen.length=0; t.mismatch()
  102. await expect(t.nodes.update('owner',node.id,{name:'错误目标',url:'http://192.168.1.99:15300'})).rejects.toThrow('身份')
  103. expect(t.store.require<WorkspaceNode>('owner','node',node.id).url).toBe(node.url)
  104. expect(t.seen).toHaveLength(1); expect(t.seen[0]!.headers.has('authorization')).toBe(false)
  105. })
  106. })