workspacePairedNodes.test.ts 7.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  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, 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 refuses execution of a retired paired node', 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. expect(()=>t.nodes.target('parent-user',node.id)).toThrow('远程机器人已停用')
  59. expect(t.seen.some(r=>r.url.pathname.endsWith('/commands'))).toBe(false)
  60. })
  61. it('refuses retired paired-node execution before issuing remote commands', async () => {
  62. const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
  63. const id=t.store.list<WorkspaceNode>('owner','node')[0]!.id
  64. t.seen.length=0
  65. expect(()=>t.nodes.target('owner',id)).toThrow('远程机器人已停用')
  66. expect(t.seen).toEqual([])
  67. })
  68. it('rejects self-pairing before redeeming a code', async () => {
  69. const t=setup(remote)
  70. await expect(t.nodes.pair('owner',{qrPayload:code(),name:'self'})).rejects.toThrow('自己')
  71. expect(t.seen).toEqual([])
  72. })
  73. it('refuses importing retired remote agents without creating local references', async () => {
  74. const t=setup();await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
  75. const node=t.store.list<WorkspaceNode>('owner','node')[0]!, remote='66666666-6666-4666-8666-666666666666'
  76. t.seen.length=0
  77. await expect(t.nodes.importRemoteAgent('owner',node.id,remote)).rejects.toThrow('远程机器人已停用')
  78. expect(t.store.list('owner','agent')).toEqual([])
  79. expect(t.seen).toEqual([])
  80. })
  81. it('checks identity at the new address before sending the saved bearer and leaves state unchanged on mismatch', async () => {
  82. const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'远端'})
  83. const node=t.store.list<WorkspaceNode>('owner','node')[0]!; t.seen.length=0; t.mismatch()
  84. await expect(t.nodes.update('owner',node.id,{name:'错误目标',url:'http://192.168.1.99:15300'})).rejects.toThrow('身份')
  85. expect(t.store.require<WorkspaceNode>('owner','node',node.id).url).toBe(node.url)
  86. expect(t.seen).toHaveLength(1); expect(t.seen[0]!.headers.has('authorization')).toBe(false)
  87. })
  88. })