realtimeMigration.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. // @vitest-environment node
  2. import { createServer, type Server } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import WebSocket, { WebSocketServer } from 'ws'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import { mkdtempSync, rmSync } from 'node:fs'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
  12. const closers: Array<() => Promise<void>> = []
  13. const homes: string[] = []
  14. afterEach(async () => {
  15. for (const close of closers.splice(0).reverse()) await close()
  16. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  17. })
  18. function listen(server: Server): Promise<number> {
  19. return new Promise((resolve, reject) => {
  20. server.once('error', reject)
  21. server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
  22. })
  23. }
  24. function closeServer(server: Server): Promise<void> {
  25. return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
  26. }
  27. function cookies(response: Response): string {
  28. return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
  29. }
  30. function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
  31. return () => runtime.close()
  32. }
  33. describe('HTTP+SSE migration', () => {
  34. it('lets a QR-paired iOS node use authenticated HTTP+SSE and rejects old tickets', async () => {
  35. let pairedCookie = ''
  36. const cwdReads: Array<{ profile: string | null; cookie: string }> = []
  37. const pairedFrames: Array<Record<string, unknown>> = []
  38. const upstream = createServer((request, response) => {
  39. response.setHeader('Content-Type', 'application/json')
  40. if (request.url === '/api/status') {
  41. pairedCookie = request.headers.cookie ?? pairedCookie
  42. response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
  43. response.end(JSON.stringify({ auth_required: true }))
  44. } else if (request.url === '/api/auth/me') {
  45. pairedCookie = request.headers.cookie ?? pairedCookie
  46. response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
  47. } else if (request.url === '/api/profiles') {
  48. pairedCookie = request.headers.cookie ?? pairedCookie
  49. response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
  50. } else if (request.url === '/api/plugins/yaoyao/profiles') {
  51. response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
  52. } else if (request.url?.startsWith('/api/config?')) {
  53. const profile = new URL(request.url, 'http://hermes.test').searchParams.get('profile')
  54. cwdReads.push({ profile, cookie: request.headers.cookie ?? '' })
  55. response.end(JSON.stringify({ terminal: { cwd: '/Hermes/agent-work' } }))
  56. } else if (request.url === '/api/auth/providers') {
  57. response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
  58. } else if (request.url === '/auth/password-login' && request.method === 'POST') {
  59. response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
  60. response.end(JSON.stringify({ ok: true }))
  61. } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
  62. pairedCookie = request.headers.cookie ?? pairedCookie
  63. response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
  64. } else {
  65. response.statusCode = 404
  66. response.end(JSON.stringify({ error: 'not found' }))
  67. }
  68. })
  69. const upstreamSockets = new WebSocketServer({ noServer: true })
  70. upstream.on('upgrade', (request, socket, head) => {
  71. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  72. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
  73. socket.destroy()
  74. return
  75. }
  76. upstreamSockets.handleUpgrade(request, socket, head, client => {
  77. client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { paired: true } } }))
  78. client.on('message', data => {
  79. const frame = JSON.parse(data.toString()) as Record<string, unknown>
  80. pairedFrames.push(frame)
  81. client.send(JSON.stringify({ id: frame.id, result: frame.method === 'session.create'
  82. ? { session_id: 'runtime-test', stored_session_id: 'stored-test', info: { cwd: '/Hermes/agent-work' } } : {} }))
  83. })
  84. })
  85. })
  86. const upstreamPort = await listen(upstream)
  87. closers.push(async () => {
  88. for (const client of upstreamSockets.clients) client.terminate()
  89. upstreamSockets.close()
  90. await closeServer(upstream)
  91. })
  92. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
  93. homes.push(home)
  94. const config: ServerConfig = {
  95. host: '127.0.0.1', port: 15300,
  96. upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
  97. allowedHosts: new Set(), home,
  98. mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
  99. allowInsecureLan: false, insecureLan: false, production: false,
  100. }
  101. const application = createApplication({ config })
  102. const node = createNodeServer(application)
  103. await listen(node.server)
  104. closers.push(closeRuntime(node))
  105. const port = (node.server.address() as AddressInfo).port
  106. config.port = port
  107. const origin = `http://127.0.0.1:${port}`
  108. const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
  109. const bootstrapBody = await bootstrap.json() as { csrfToken: string }
  110. const cookie = cookies(bootstrap)
  111. const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
  112. method: 'POST',
  113. headers: {
  114. 'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
  115. 'X-CSRF-Token': bootstrapBody.csrfToken,
  116. },
  117. body: JSON.stringify({
  118. username: 'paired-user', password: 'paired-password',
  119. }),
  120. })
  121. expect(pairingResponse.status).toBe(201)
  122. const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
  123. const qr = new URL(pairing.qrPayload)
  124. const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
  125. method: 'POST',
  126. headers: { 'Content-Type': 'application/json' },
  127. body: JSON.stringify({
  128. pairingId: pairing.pairingId,
  129. secret: qr.searchParams.get('secret'),
  130. deviceName: 'Test iPhone',
  131. }),
  132. })
  133. expect(claimResponse.status).toBe(201)
  134. const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
  135. const authorization = `Bearer ${claim.token}`
  136. const status = await fetch(`${claim.serverUrl}/api/status`, {
  137. headers: { Authorization: authorization },
  138. })
  139. expect(status.status).toBe(200)
  140. expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
  141. const retired = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
  142. method: 'POST', headers: { Authorization: authorization },
  143. })
  144. expect(retired.status).toBe(410)
  145. const capabilities = await fetch(`${claim.serverUrl}/api/realtime/capabilities`, { headers: { Authorization: authorization } })
  146. expect(await capabilities.json()).toMatchObject({ protocolVersion: 1, channels: ['chat'] })
  147. const opened = await fetch(`${claim.serverUrl}/api/realtime/channels`, {
  148. method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
  149. body: JSON.stringify({ channel: 'chat' }),
  150. })
  151. expect(opened.status).toBe(201)
  152. expect(pairedFrames).toEqual([])
  153. const channel = (await opened.json() as { id: string }).id
  154. const command = (id: string, method: string, params: object) => fetch(`${claim.serverUrl}/api/realtime/channels/${channel}/commands`, {
  155. method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Idempotency-Key': id },
  156. body: JSON.stringify({ method, params }),
  157. })
  158. const created = await command('ios-create', 'session.create', { profile: 'default', source: 'ios', cwd: '/mobile/override' })
  159. expect(await created.json()).toMatchObject({ state: 'confirmed' })
  160. expect(pairedFrames.at(-1)).toMatchObject({ method: 'session.create', params: { cwd: '/Hermes/agent-work' } })
  161. expect(await (await command('ios-send', 'prompt.submit', { session_id: 'runtime-test', text: 'hello' })).json()).toMatchObject({ state: 'confirmed' })
  162. expect(cwdReads).toHaveLength(2)
  163. expect(cwdReads.every(r => r.profile === 'default' && r.cookie.includes('paired'))).toBe(true)
  164. const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
  165. method: 'DELETE', headers: { Authorization: authorization },
  166. })
  167. expect(revoke.status).toBe(200)
  168. expect(await fetch(`${claim.serverUrl}/api/status`, {
  169. headers: { Authorization: authorization },
  170. })).toHaveProperty('status', 401)
  171. })
  172. it('rejects client WebSocket upgrades instead of keeping legacy compatibility', async () => {
  173. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  174. homes.push(home)
  175. const config: ServerConfig = {
  176. host: '127.0.0.1',
  177. port: 15300,
  178. upstream: new URL('http://127.0.0.1:9119'),
  179. allowedHosts: new Set(),
  180. home,
  181. allowInsecureLan: false,
  182. insecureLan: false,
  183. production: true,
  184. }
  185. const application = createApplication({ config })
  186. const node = createNodeServer(application)
  187. await listen(node.server)
  188. closers.push(closeRuntime(node))
  189. const port = (node.server.address() as AddressInfo).port
  190. config.port = port
  191. const status = await new Promise<number>((resolve, reject) => {
  192. const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
  193. origin: `http://127.0.0.1:${port}`,
  194. })
  195. socket.once('unexpected-response', (_request, response) => {
  196. resolve(response.statusCode ?? 0)
  197. response.resume()
  198. })
  199. socket.once('error', reject)
  200. })
  201. expect(status).toBe(410)
  202. })
  203. })