websocket.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { createServer, type Server } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import WebSocket, { WebSocketServer } from 'ws'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
  6. import type { ServerConfig } from '../../src/server/config.js'
  7. import { mkdtempSync, rmSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
  11. const closers: Array<() => Promise<void>> = []
  12. const homes: string[] = []
  13. afterEach(async () => {
  14. for (const close of closers.splice(0).reverse()) await close()
  15. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  16. })
  17. function listen(server: Server): Promise<number> {
  18. return new Promise((resolve, reject) => {
  19. server.once('error', reject)
  20. server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
  21. })
  22. }
  23. function closeServer(server: Server): Promise<void> {
  24. return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
  25. }
  26. function cookies(response: Response): string {
  27. return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
  28. }
  29. function wsOpen(url: string, origin: string, cookie: string): Promise<WebSocket> {
  30. return new Promise((resolve, reject) => {
  31. const socket = new WebSocket(url, { origin, headers: { Cookie: cookie } })
  32. socket.once('open', () => resolve(socket))
  33. socket.once('error', reject)
  34. })
  35. }
  36. function wsMessage(socket: WebSocket): Promise<Record<string, unknown>> {
  37. return new Promise((resolve, reject) => {
  38. socket.once('message', (data) => {
  39. try {
  40. resolve(JSON.parse(data.toString()) as Record<string, unknown>)
  41. } catch (error) {
  42. reject(error)
  43. }
  44. })
  45. socket.once('error', reject)
  46. })
  47. }
  48. function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
  49. return () => runtime.close()
  50. }
  51. describe('WebSocket relay', () => {
  52. it('lets a QR-paired iOS node use Hermes-compatible REST and WebSocket paths', async () => {
  53. let pairedCookie = ''
  54. const pairedFrames: Array<Record<string, unknown>> = []
  55. const upstream = createServer((request, response) => {
  56. response.setHeader('Content-Type', 'application/json')
  57. if (request.url === '/api/status') {
  58. pairedCookie = request.headers.cookie ?? pairedCookie
  59. response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
  60. response.end(JSON.stringify({ auth_required: true }))
  61. } else if (request.url === '/api/auth/me') {
  62. pairedCookie = request.headers.cookie ?? pairedCookie
  63. response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
  64. } else if (request.url === '/api/profiles') {
  65. pairedCookie = request.headers.cookie ?? pairedCookie
  66. response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
  67. } else if (request.url === '/api/plugins/yaoyao/profiles') {
  68. response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
  69. } else if (request.url === '/api/auth/providers') {
  70. response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
  71. } else if (request.url === '/auth/password-login' && request.method === 'POST') {
  72. response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
  73. response.end(JSON.stringify({ ok: true }))
  74. } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
  75. pairedCookie = request.headers.cookie ?? pairedCookie
  76. response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
  77. } else {
  78. response.statusCode = 404
  79. response.end(JSON.stringify({ error: 'not found' }))
  80. }
  81. })
  82. const upstreamSockets = new WebSocketServer({ noServer: true })
  83. upstream.on('upgrade', (request, socket, head) => {
  84. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  85. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
  86. socket.destroy()
  87. return
  88. }
  89. upstreamSockets.handleUpgrade(request, socket, head, client => {
  90. client.send(JSON.stringify({ method: 'gateway.ready', params: { paired: true } }))
  91. client.on('message', data => {
  92. const frame = JSON.parse(data.toString()) as Record<string, unknown>
  93. pairedFrames.push(frame)
  94. client.send(JSON.stringify({ id: frame.id, result: {} }))
  95. })
  96. })
  97. })
  98. const upstreamPort = await listen(upstream)
  99. closers.push(async () => {
  100. for (const client of upstreamSockets.clients) client.terminate()
  101. upstreamSockets.close()
  102. await closeServer(upstream)
  103. })
  104. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
  105. homes.push(home)
  106. const config: ServerConfig = {
  107. host: '127.0.0.1', port: 8800,
  108. upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
  109. allowedHosts: new Set(), home,
  110. mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
  111. allowInsecureLan: false, insecureLan: false, production: false,
  112. }
  113. const application = createApplication({ config })
  114. const node = createNodeServer(application)
  115. await listen(node.server)
  116. closers.push(closeRuntime(node))
  117. const port = (node.server.address() as AddressInfo).port
  118. config.port = port
  119. const origin = `http://127.0.0.1:${port}`
  120. const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
  121. const bootstrapBody = await bootstrap.json() as { csrfToken: string }
  122. const cookie = cookies(bootstrap)
  123. const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
  124. method: 'POST',
  125. headers: {
  126. 'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
  127. 'X-CSRF-Token': bootstrapBody.csrfToken,
  128. },
  129. body: JSON.stringify({
  130. username: 'paired-user', password: 'paired-password',
  131. }),
  132. })
  133. expect(pairingResponse.status).toBe(201)
  134. const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
  135. const qr = new URL(pairing.qrPayload)
  136. const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
  137. method: 'POST',
  138. headers: { 'Content-Type': 'application/json' },
  139. body: JSON.stringify({
  140. pairingId: pairing.pairingId,
  141. secret: qr.searchParams.get('secret'),
  142. deviceName: 'Test iPhone',
  143. }),
  144. })
  145. expect(claimResponse.status).toBe(201)
  146. const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
  147. const authorization = `Bearer ${claim.token}`
  148. const status = await fetch(`${claim.serverUrl}/api/status`, {
  149. headers: { Authorization: authorization },
  150. })
  151. expect(status.status).toBe(200)
  152. expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
  153. const ticketResponse = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
  154. method: 'POST', headers: { Authorization: authorization },
  155. })
  156. expect(ticketResponse.status).toBe(200)
  157. const ticket = (await ticketResponse.json() as { ticket: string }).ticket
  158. expect(pairedCookie).toContain('hermes_session_at=paired-user')
  159. const socket = await wsOpen(
  160. `${claim.serverUrl.replace(/^http/, 'ws')}/api/ws?ticket=${encodeURIComponent(ticket)}`,
  161. origin,
  162. '',
  163. )
  164. expect(await wsMessage(socket)).toMatchObject({
  165. method: 'gateway.ready', params: { paired: true },
  166. })
  167. socket.send(JSON.stringify({
  168. id: 'profiles', method: 'profiles.list',
  169. params: { include_sessions: true },
  170. }))
  171. await wsMessage(socket)
  172. socket.send(JSON.stringify({
  173. id: 'profile-meta', method: 'profiles.configure',
  174. params: {
  175. name: 'default',
  176. ui_meta: { 'hermes-bots': { chat: 'stored-session' } },
  177. ui_meta_expected_revisions: { 'hermes-bots': 2 },
  178. },
  179. }))
  180. await wsMessage(socket)
  181. socket.send(JSON.stringify({
  182. id: 'profile-avatar', method: 'profiles.set_asset',
  183. params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
  184. }))
  185. await wsMessage(socket)
  186. socket.send(JSON.stringify({
  187. id: 'hidden-bot', method: 'session.create',
  188. params: {
  189. profile: 'default', title: 'Bot Chat', source: 'ios_bot_group',
  190. hidden: true, close_on_disconnect: false,
  191. },
  192. }))
  193. await wsMessage(socket)
  194. expect(pairedFrames[0]).toEqual({
  195. id: 'profiles', method: 'profiles.list', params: { include_sessions: true },
  196. })
  197. expect(pairedFrames[1]).toMatchObject({
  198. id: 'profile-meta', method: 'profiles.configure',
  199. params: { name: 'default' },
  200. })
  201. expect(pairedFrames[2]).toEqual({
  202. id: 'profile-avatar', method: 'profiles.set_asset',
  203. params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
  204. })
  205. expect(pairedFrames[3]).toMatchObject({
  206. id: 'hidden-bot', method: 'session.create',
  207. params: { profile: 'default', source: 'ios_bot_group', hidden: true },
  208. })
  209. socket.close()
  210. const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
  211. method: 'DELETE', headers: { Authorization: authorization },
  212. })
  213. expect(revoke.status).toBe(200)
  214. expect(await fetch(`${claim.serverUrl}/api/status`, {
  215. headers: { Authorization: authorization },
  216. })).toHaveProperty('status', 401)
  217. })
  218. it('keeps the upstream ticket server-side and filters chat RPC methods', async () => {
  219. const received: Array<Record<string, unknown>> = []
  220. const upstream = createServer((request, response) => {
  221. response.setHeader('Content-Type', 'application/json')
  222. if (request.url === '/api/status') {
  223. response.setHeader('Set-Cookie', 'hermes_session_at=socket-user; Path=/; HttpOnly')
  224. response.end(JSON.stringify({ auth_required: true }))
  225. } else if (request.url === '/api/auth/me') {
  226. response.end(JSON.stringify({ user_id: 'socket-user', display_name: 'Socket User' }))
  227. } else if (request.url === '/api/profiles') {
  228. response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
  229. } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
  230. response.end(JSON.stringify({ ticket: 'one-time-upstream-secret' }))
  231. } else {
  232. response.statusCode = 404
  233. response.end(JSON.stringify({ error: 'not found' }))
  234. }
  235. })
  236. const upstreamSockets = new WebSocketServer({ noServer: true })
  237. upstream.on('upgrade', (request, socket, head) => {
  238. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  239. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'one-time-upstream-secret') {
  240. socket.destroy()
  241. return
  242. }
  243. upstreamSockets.handleUpgrade(request, socket, head, (client) => {
  244. client.send(JSON.stringify({ method: 'gateway.ready', params: {} }))
  245. client.on('message', (data) => {
  246. received.push(JSON.parse(data.toString()) as Record<string, unknown>)
  247. client.send(JSON.stringify({ id: 'ok', result: {} }))
  248. })
  249. })
  250. })
  251. const upstreamPort = await listen(upstream)
  252. closers.push(async () => {
  253. for (const client of upstreamSockets.clients) client.terminate()
  254. upstreamSockets.close()
  255. await closeServer(upstream)
  256. })
  257. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  258. homes.push(home)
  259. const config: ServerConfig = {
  260. host: '127.0.0.1',
  261. port: 8800,
  262. upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
  263. allowedHosts: new Set(),
  264. home,
  265. allowInsecureLan: false,
  266. insecureLan: false,
  267. production: false,
  268. }
  269. const application = createApplication({ config })
  270. const node = createNodeServer(application)
  271. await listen(node.server)
  272. closers.push(closeRuntime(node))
  273. const port = (node.server.address() as AddressInfo).port
  274. config.port = port
  275. const origin = `http://127.0.0.1:${port}`
  276. const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
  277. expect(bootstrap.status).toBe(200)
  278. const bootstrapBody = await bootstrap.json() as { csrfToken: string }
  279. const cookie = cookies(bootstrap)
  280. const leaseResponse = await fetch(`${origin}/api/app/realtime-leases`, {
  281. method: 'POST',
  282. headers: {
  283. 'Content-Type': 'application/json',
  284. Cookie: cookie,
  285. Origin: origin,
  286. 'X-CSRF-Token': bootstrapBody.csrfToken,
  287. },
  288. body: JSON.stringify({ channel: 'chat' }),
  289. })
  290. expect(leaseResponse.status).toBe(201)
  291. const leaseBody = await leaseResponse.json() as { lease: string }
  292. expect(JSON.stringify(leaseBody)).not.toContain('upstream-secret')
  293. const rotatedCookie = [cookie, cookies(leaseResponse)].filter(Boolean).join('; ')
  294. const socket = await wsOpen(
  295. `ws://127.0.0.1:${port}/ws/chat?lease=${encodeURIComponent(leaseBody.lease)}`,
  296. origin,
  297. rotatedCookie,
  298. )
  299. expect(await wsMessage(socket)).toMatchObject({ method: 'gateway.ready' })
  300. socket.send(JSON.stringify({
  301. id: 'resume-1',
  302. method: 'session.resume',
  303. params: { session_id: 'stored-session', profile: 'default', source: 'ios' },
  304. }))
  305. await wsMessage(socket)
  306. expect(received[0]).toMatchObject({
  307. method: 'session.resume',
  308. params: { session_id: 'stored-session', profile: 'default', source: 'web' },
  309. })
  310. socket.send(JSON.stringify({
  311. id: 'clarify-1',
  312. method: 'clarify.respond',
  313. params: { request_id: 'question-1', answer: '继续', },
  314. }))
  315. await wsMessage(socket)
  316. expect(received[1]).toMatchObject({
  317. method: 'clarify.respond',
  318. params: { request_id: 'question-1', answer: '继续' },
  319. })
  320. socket.send(JSON.stringify({
  321. id: 'queue-1',
  322. method: 'prompt.submit',
  323. params: { session_id: 'stored-session', text: '下一条', queued: true, ignored: 'drop-me' },
  324. }))
  325. await wsMessage(socket)
  326. expect(received[2]).toEqual({
  327. id: 'queue-1',
  328. method: 'prompt.submit',
  329. params: { session_id: 'stored-session', text: '下一条', queued: true },
  330. })
  331. socket.send(JSON.stringify({
  332. id: 'model-with-scoped-provider',
  333. method: 'config.set',
  334. params: { session_id: 'stored-session', key: 'model', value: 'omni --provider Custom:Tingly --session' },
  335. }))
  336. await wsMessage(socket)
  337. expect(received[3]).toEqual({
  338. id: 'model-with-scoped-provider',
  339. method: 'config.set',
  340. params: { session_id: 'stored-session', key: 'model', value: 'omni --provider Custom:Tingly --session' },
  341. })
  342. socket.send(JSON.stringify({
  343. id: 'forbidden',
  344. method: 'config.set',
  345. params: { session_id: 'stored-session', key: 'yolo', value: 'true' },
  346. }))
  347. const closeCode = await new Promise<number>((resolve) => socket.once('close', resolve))
  348. expect(closeCode).toBe(1008)
  349. })
  350. it('rejects unknown production Upgrade requests instead of leaking a hanging socket', async () => {
  351. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  352. homes.push(home)
  353. const config: ServerConfig = {
  354. host: '127.0.0.1',
  355. port: 8800,
  356. upstream: new URL('http://127.0.0.1:9119'),
  357. allowedHosts: new Set(),
  358. home,
  359. allowInsecureLan: false,
  360. insecureLan: false,
  361. production: true,
  362. }
  363. const application = createApplication({ config })
  364. const node = createNodeServer(application)
  365. await listen(node.server)
  366. closers.push(closeRuntime(node))
  367. const port = (node.server.address() as AddressInfo).port
  368. config.port = port
  369. const status = await new Promise<number>((resolve, reject) => {
  370. const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
  371. origin: `http://127.0.0.1:${port}`,
  372. })
  373. socket.once('unexpected-response', (_request, response) => {
  374. resolve(response.statusCode ?? 0)
  375. response.resume()
  376. })
  377. socket.once('error', reject)
  378. })
  379. expect(status).toBe(404)
  380. })
  381. })