pairing.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import { NodePairingStore } from '../../src/server/pairing.js'
  9. const homes: string[] = []
  10. const runtimes: ApplicationRuntime[] = []
  11. afterEach(() => {
  12. for (const runtime of runtimes.splice(0)) runtime.close()
  13. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  14. })
  15. function temporaryHome(): string {
  16. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-pairing-'))
  17. homes.push(home)
  18. return home
  19. }
  20. function config(home: string): ServerConfig {
  21. return {
  22. host: '127.0.0.1',
  23. port: 8800,
  24. upstream: new URL('http://127.0.0.1:9119'),
  25. allowedHosts: new Set(),
  26. home,
  27. mediaRoot: join(home, 'media'),
  28. attachmentsRoot: join(home, 'attachments'),
  29. imagesRoot: join(home, 'images'),
  30. mediaOwner: 'tester',
  31. allowInsecureLan: false,
  32. insecureLan: false,
  33. production: false,
  34. }
  35. }
  36. function cookies(response: request.Response): string {
  37. const values = response.headers['set-cookie'] as unknown as string[] | undefined
  38. return (values ?? []).map((value) => value.split(';', 1)[0]).join('; ')
  39. }
  40. describe('Hermes node pairing', () => {
  41. it('stores only encrypted delegated cookies and rejects replayed pairing secrets', () => {
  42. const home = temporaryHome()
  43. let now = 1_000
  44. const store = new NodePairingStore(home, () => now)
  45. const pairing = store.create('hermes_session_rt=refresh-secret', ['agents.read', 'history.read'])
  46. const claimed = store.claim({
  47. pairingID: pairing.id,
  48. secret: pairing.secret,
  49. deviceName: '测试 iPhone',
  50. })
  51. expect(store.authorize(claimed.device.id, claimed.token, 'history.read'))
  52. .toBe('hermes_session_rt=refresh-secret')
  53. expect(() => store.claim({
  54. pairingID: pairing.id,
  55. secret: pairing.secret,
  56. deviceName: '重放设备',
  57. })).toThrowError(/already used/i)
  58. expect(() => store.authorize(claimed.device.id, claimed.token, 'groups.execute'))
  59. .toThrowError(/lacks groups.execute/i)
  60. const persisted = readFileSync(join(home, 'paired-devices.json'), 'utf8')
  61. expect(persisted).not.toContain('refresh-secret')
  62. expect(persisted).not.toContain(claimed.token)
  63. now += 1
  64. const restored = new NodePairingStore(home, () => now)
  65. expect(restored.nodeID).toBe(store.nodeID)
  66. expect(restored.authorize(claimed.device.id, claimed.token, 'agents.read'))
  67. .toBe('hermes_session_rt=refresh-secret')
  68. })
  69. it('expires unclaimed QR secrets after two minutes', () => {
  70. const home = temporaryHome()
  71. let now = 10_000
  72. const store = new NodePairingStore(home, () => now)
  73. const pairing = store.create('hermes_session_at=access')
  74. now = pairing.expiresAt + 1
  75. expect(store.status(pairing.id)).toEqual({ state: 'expired' })
  76. expect(() => store.claim({
  77. pairingID: pairing.id,
  78. secret: pairing.secret,
  79. deviceName: '迟到的设备',
  80. })).toThrowError(/expired/i)
  81. })
  82. it('claims from the QR and exposes a revocable Hermes-compatible node proxy', async () => {
  83. const home = temporaryHome()
  84. const upstreamRequests: Array<{
  85. path: string
  86. cookie: string
  87. authorization: string
  88. contentType: string
  89. nodeClient: string
  90. body?: Buffer
  91. }> = []
  92. const fetchImpl: typeof fetch = (async (input: string | URL | Request, init?: RequestInit) => {
  93. const url = new URL(input instanceof Request ? input.url : String(input))
  94. const headers = new Headers(init?.headers)
  95. const recordedBody = init?.body === undefined
  96. ? undefined
  97. : Buffer.from(await new Response(init.body).arrayBuffer())
  98. upstreamRequests.push({
  99. path: url.pathname,
  100. cookie: headers.get('cookie') ?? '',
  101. authorization: headers.get('authorization') ?? '',
  102. contentType: headers.get('content-type') ?? '',
  103. nodeClient: headers.get('x-yaoyao-node-client') ?? '',
  104. body: recordedBody,
  105. })
  106. const responseHeaders = new Headers({ 'content-type': 'application/json' })
  107. if (url.pathname === '/api/status') {
  108. responseHeaders.append('set-cookie', 'hermes_session_at=paired-access; Path=/; HttpOnly')
  109. return new Response(JSON.stringify({ auth_required: true }), { headers: responseHeaders })
  110. }
  111. if (url.pathname === '/api/auth/me') {
  112. return new Response(JSON.stringify({ user_id: 'owner', display_name: 'Owner' }), { headers: responseHeaders })
  113. }
  114. if (url.pathname === '/api/profiles') {
  115. return new Response(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }), { headers: responseHeaders })
  116. }
  117. if (url.pathname === '/api/plugins/yaoyao/profiles') {
  118. return new Response(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }), { headers: responseHeaders })
  119. }
  120. if (url.pathname === '/api/auth/providers') {
  121. return new Response(JSON.stringify({
  122. providers: [{ name: 'basic', supports_password: true }],
  123. }), { headers: responseHeaders })
  124. }
  125. if (url.pathname === '/auth/password-login') {
  126. responseHeaders.append('set-cookie', 'hermes_session_rt=independent-refresh; Path=/; HttpOnly')
  127. return new Response(JSON.stringify({ ok: true }), { headers: responseHeaders })
  128. }
  129. return new Response(JSON.stringify({ ok: true }), { headers: responseHeaders })
  130. }) as typeof fetch
  131. const runtime = createApplication({ config: config(home), fetchImpl })
  132. runtimes.push(runtime)
  133. const bootstrap = await request(runtime.app.callback())
  134. .get('/api/app/bootstrap')
  135. .set('Host', '127.0.0.1:8800')
  136. .expect(200)
  137. const cookie = cookies(bootstrap)
  138. await request(runtime.app.callback())
  139. .post('/api/app/pairings')
  140. .set('Host', '127.0.0.1:8800')
  141. .set('Origin', 'http://127.0.0.1:8800')
  142. .set('Cookie', cookie)
  143. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  144. .send({ scopes: ['agents.read'] })
  145. .expect(400)
  146. const pairing = await request(runtime.app.callback())
  147. .post('/api/app/pairings')
  148. .set('Host', '127.0.0.1:8800')
  149. .set('Origin', 'http://127.0.0.1:8800')
  150. .set('Cookie', cookie)
  151. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  152. .send({
  153. scopes: ['agents.read', 'history.read', 'sessions.execute', 'groups.execute'],
  154. username: 'paired-user',
  155. password: 'paired-password',
  156. })
  157. .expect(201)
  158. const deepLink = new URL(pairing.body.qrPayload)
  159. expect(deepLink.protocol).toBe('yaoyao:')
  160. expect(deepLink.searchParams.get('secret')).toBeTruthy()
  161. const claim = await request(runtime.app.callback())
  162. .post('/api/pair/v1/claim')
  163. .set('Host', '127.0.0.1:8800')
  164. .send({
  165. pairingId: pairing.body.pairingId,
  166. secret: deepLink.searchParams.get('secret'),
  167. deviceName: 'YaoYao iPhone',
  168. })
  169. .expect(201)
  170. expect(claim.body.serverUrl).toBe(`http://127.0.0.1:8800/node/${claim.body.deviceId}`)
  171. expect(readFileSync(join(home, 'paired-devices.json'), 'utf8'))
  172. .not.toContain('paired-password')
  173. const profiles = await request(runtime.app.callback())
  174. .get(`/node/${claim.body.deviceId}/api/profiles`)
  175. .set('Host', '127.0.0.1:8800')
  176. .set('Authorization', `Bearer ${claim.body.token}`)
  177. .expect(200)
  178. expect(profiles.body.profiles[0].name).toBe('default')
  179. expect(upstreamRequests.at(-1)).toMatchObject({
  180. path: '/api/profiles',
  181. authorization: '',
  182. })
  183. expect(upstreamRequests.at(-1)?.cookie).toContain('hermes_session_at=paired-access')
  184. expect(upstreamRequests.at(-1)?.cookie).toContain('hermes_session_rt=independent-refresh')
  185. const attachment = Buffer.from('remote attachment')
  186. await request(runtime.app.callback())
  187. .post(`/node/${claim.body.deviceId}/api/plugins/yaoyao/v1/node-worker/sessions/runtime-1/attachments`)
  188. .set('Host', '127.0.0.1:8800')
  189. .set('Authorization', `Bearer ${claim.body.token}`)
  190. .set('Content-Type', 'application/octet-stream')
  191. .set('X-File-Name-B64', Buffer.from('note.txt').toString('base64url'))
  192. .set('X-Mime-Type', 'text/plain')
  193. .send(attachment)
  194. .expect(200)
  195. expect(upstreamRequests.at(-1)).toMatchObject({
  196. path: '/api/plugins/yaoyao/v1/node-worker/sessions/runtime-1/attachments',
  197. contentType: 'application/octet-stream',
  198. nodeClient: claim.body.deviceId,
  199. body: attachment,
  200. })
  201. await request(runtime.app.callback())
  202. .delete(`/api/app/paired-devices/${claim.body.deviceId}`)
  203. .set('Host', '127.0.0.1:8800')
  204. .set('Origin', 'http://127.0.0.1:8800')
  205. .set('Cookie', cookie)
  206. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  207. .expect(200)
  208. await request(runtime.app.callback())
  209. .get(`/node/${claim.body.deviceId}/api/profiles`)
  210. .set('Host', '127.0.0.1:8800')
  211. .set('Authorization', `Bearer ${claim.body.token}`)
  212. .expect(401)
  213. })
  214. })