realtimeBroker.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { mkdtempSync, readFileSync } from 'node:fs'
  2. import { join } from 'node:path'
  3. import { tmpdir } from 'node:os'
  4. import { once } from 'node:events'
  5. import { WebSocketServer, type WebSocket } from 'ws'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { RealtimeBroker, type RealtimeActivity, type RealtimePrincipal, type StreamEntry } from '../../src/server/realtimeBroker.js'
  8. import { RealtimeReceipts } from '../../src/server/realtimeReceipts.js'
  9. const cleanup: Array<() => void> = []
  10. afterEach(() => cleanup.splice(0).reverse().forEach(f => f()))
  11. async function fixture() {
  12. const home = mkdtempSync(join(tmpdir(), 'yaoyao-realtime-test-'))
  13. const ws = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  14. await once(ws, 'listening')
  15. cleanup.push(() => { for (const c of ws.clients) c.terminate(); ws.close() })
  16. const commands: any[] = []
  17. let count = 0
  18. let seq = 0, truncate = false, dropPrompt = false
  19. const history: any[] = []
  20. let peer: WebSocket
  21. ws.on('connection', socket => {
  22. peer = socket; count++
  23. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'test' } } }))
  24. socket.on('message', raw => {
  25. const f = JSON.parse(raw.toString()); commands.push(f)
  26. if (f.method === 'prompt.submit' && dropPrompt) { socket.terminate(); return }
  27. if (f.method === 'profiles.list') {
  28. socket.send(JSON.stringify({id:f.id,result:{profiles:[{name:'default',ui_meta:{'hermes-bots':{chat:'stored-1'}}}]}}))
  29. } else if (f.method === 'session.events.since') {
  30. socket.send(JSON.stringify({ id: f.id, result: { epoch: 'test', truncated: truncate,
  31. events: history.filter(e => e.seq > f.params.last_seen) } }))
  32. } else if (f.method === 'session.branch') {
  33. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-branch', stored_session_id: 'stored-branch' } }))
  34. } else if (f.method === 'session.create' || f.method === 'session.resume') {
  35. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: false } }))
  36. } else {
  37. setTimeout(() => socket.send(JSON.stringify({ id: f.id, result: { status: 'submitted' } })), 20)
  38. }
  39. })
  40. })
  41. let now = Date.now()
  42. const activities: RealtimeActivity[] = []
  43. const broker = new RealtimeBroker(home, () => now, change => activities.push(change))
  44. cleanup.push(() => broker.close())
  45. const principal = (key: string): RealtimePrincipal => ({ key, upstreamKey: 'one-service', paired: false,
  46. valid: () => true, url: async () => new URL(`ws://127.0.0.1:${(ws.address() as any).port}`) })
  47. return { home, broker, principal, commands, activities, count: () => count, advance: (n: number) => { now += n },
  48. disconnect: () => peer!.terminate(), truncate: () => { truncate = true }, dropPrompt: () => { dropPrompt = true },
  49. emit: (type: string, payload: unknown) => {
  50. const params = { type, session_id: 'runtime-1', seq: ++seq, payload }; history.push(params)
  51. if (peer!.readyState === 1) peer!.send(JSON.stringify({ method: 'event', params }))
  52. } }
  53. }
  54. const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
  55. describe('realtime broker', () => {
  56. it('rejects resuming a history-only session before opening an upstream route', async () => {
  57. const f = await fixture()
  58. const routes: string[] = []
  59. f.broker.onNativeRoute = (_owner, _profile, stored) => routes.push(stored)
  60. const principal = { ...f.principal('alice'), canResume: async () => false }
  61. const channel = await f.broker.create(principal, 'chat')
  62. const receipt = await f.broker.command(channel, 'history-resume', resume)
  63. expect(receipt).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  64. expect(f.commands).toEqual([])
  65. expect(routes).toEqual([])
  66. })
  67. it('allows only a native Bots configured chat without claiming arbitrary history', async () => {
  68. const f = await fixture()
  69. const principal = {...f.principal('native:user:alice:1'), nativeBot:true, canResume:async()=>false}
  70. const channel = await f.broker.create(principal,'chat')
  71. expect(await f.broker.command(channel,'native-canonical',resume)).toMatchObject({state:'confirmed'})
  72. expect(await f.broker.command(channel,'wrong-profile',{...resume,params:{...resume.params,profile:'other'}}))
  73. .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
  74. expect(await f.broker.command(channel,'arbitrary-history',{...resume,params:{...resume.params,session_id:'history-2'}}))
  75. .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
  76. expect(f.commands.filter(c=>c.method==='session.resume')).toHaveLength(1)
  77. })
  78. it('registers and resumes sessions only within one stable device owner', async () => {
  79. const f = await fixture()
  80. const owned = new Set<string>()
  81. const commands: Array<{ owner: string; stored: string; method: string }> = []
  82. const events: Array<{ owner: string; stored: string; type: string }> = []
  83. const ownershipKey = (owner: string, profile: string, stored: string) =>
  84. `${owner}\u0000${profile}\u0000${stored}`
  85. f.broker.onNativeRoute = (owner, profile, stored) => {
  86. owned.add(ownershipKey(owner, profile, stored))
  87. }
  88. f.broker.onNativeCommand = (owner, _profile, stored, method) => {
  89. commands.push({ owner, stored, method })
  90. }
  91. f.broker.onNativeEvent = (owner, _profile, stored, frame) => {
  92. events.push({ owner, stored, type: String(frame.type) })
  93. }
  94. const device = (id: string): RealtimePrincipal => ({
  95. ...f.principal(`device:${id}`),
  96. upstreamKey: `device-service:${id}`,
  97. paired: true,
  98. canResume: async (profile, stored) =>
  99. owned.has(ownershipKey(`device:${id}`, profile, stored)),
  100. })
  101. const deviceA = device('device-a')
  102. const first = await f.broker.create(deviceA, 'chat')
  103. const created = await f.broker.command(first, 'device-create', {
  104. method: 'session.create', params: { profile: 'default', source: 'web' },
  105. })
  106. expect(created.state).toBe('confirmed')
  107. expect(owned).toContain(ownershipKey('device:device-a', 'default', 'stored-1'))
  108. expect(commands).toContainEqual({ owner: 'device:device-a', stored: 'stored-1', method: 'session.create' })
  109. f.emit('message.delta', { text: 'device event' })
  110. await vi.waitFor(() => expect(events).toContainEqual({
  111. owner: 'device:device-a', stored: 'stored-1', type: 'message.delta',
  112. }))
  113. const sameDevice = await f.broker.create(device('device-a'), 'chat')
  114. expect((await f.broker.command(sameDevice, 'same-device-resume', resume)).state).toBe('confirmed')
  115. const unowned = await f.broker.command(sameDevice, 'source-web-unowned', {
  116. method: 'session.resume', params: { session_id: 'unowned-source-web', profile: 'default' },
  117. })
  118. expect(unowned).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  119. const anotherDevice = await f.broker.create(device('device-b'), 'chat')
  120. expect(await f.broker.command(anotherDevice, 'other-device-resume', resume))
  121. .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  122. const user = {
  123. ...f.principal('user:alice:1'),
  124. upstreamKey: 'user-service',
  125. canResume: async (profile: string, stored: string) =>
  126. owned.has(ownershipKey('alice', profile, stored)),
  127. }
  128. const userChannel = await f.broker.create(user, 'chat')
  129. expect(await f.broker.command(userChannel, 'user-resume-device-session', resume))
  130. .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  131. })
  132. it('broadcasts a route title as an owner-scoped session list change', async () => {
  133. const f = await fixture()
  134. const globalOwners: string[] = []
  135. f.broker.onNativeGlobalEvent = (owner, type) => {
  136. if (type === 'sessions.changed') globalOwners.push(owner)
  137. }
  138. const routedPrincipal = { ...f.principal('user:alice:1'), canResume: async () => true }
  139. const routed = await f.broker.create(routedPrincipal, 'chat')
  140. const listOnly = await f.broker.create(f.principal('user:alice:1'), 'chat')
  141. const anotherUser = await f.broker.create(f.principal('user:bob:1'), 'chat')
  142. await f.broker.command(routed, 'owner-title-route', resume)
  143. const listFrames: StreamEntry[] = []
  144. const otherFrames: StreamEntry[] = []
  145. f.broker.subscribe(listOnly, undefined, entry => listFrames.push(entry))
  146. f.broker.subscribe(anotherUser, undefined, entry => otherFrames.push(entry))
  147. f.emit('session.title', { session_id: 'stored-1', title: '跨端标题' })
  148. await vi.waitFor(() => expect(listFrames.some(entry =>
  149. entry.data.includes('sessions.changed'))).toBe(true))
  150. expect(otherFrames.some(entry => entry.data.includes('sessions.changed'))).toBe(false)
  151. expect(globalOwners).toEqual(['alice'])
  152. })
  153. it('preserves restricted create provenance and never forwards source on resume', async () => {
  154. const f = await fixture()
  155. const channel = await f.broker.create(f.principal('alice'), 'chat')
  156. await f.broker.command(channel, 'create-ios', {
  157. method: 'session.create', params: { profile: 'default', source: 'ios' },
  158. })
  159. await f.broker.command(channel, 'create-web-default', {
  160. method: 'session.create', params: { profile: 'default' },
  161. })
  162. await f.broker.command(channel, 'resume-with-client-source', {
  163. method: 'session.resume', params: { session_id: 'stored-1', profile: 'default', source: 'ios' },
  164. })
  165. const openFrames = f.commands.filter(frame => frame.method === 'session.create' || frame.method === 'session.resume')
  166. expect(openFrames[0]?.params.source).toBe('ios')
  167. expect(openFrames[1]?.params.source).toBe('web')
  168. expect(openFrames[2]?.params).not.toHaveProperty('source')
  169. expect(() => f.broker.command(channel, 'create-invalid-source', {
  170. method: 'session.create', params: { profile: 'default', source: 'telegram' },
  171. })).toThrow(/source must be web or ios/)
  172. })
  173. it('shares upstream across subscribers and isolates unregistered routes', async () => {
  174. const f = await fixture()
  175. const a = await f.broker.create(f.principal('alice'), 'chat')
  176. const b = await f.broker.create(f.principal('bob'), 'chat')
  177. const c = await f.broker.create(f.principal('alice'), 'chat')
  178. await f.broker.command(a, 'resume-a', resume)
  179. await f.broker.command(c, 'resume-c', resume)
  180. const seenA: StreamEntry[] = [], seenB: StreamEntry[] = [], seenC: StreamEntry[] = []
  181. f.broker.subscribe(a, undefined, x => seenA.push(x)); f.broker.subscribe(b, undefined, x => seenB.push(x)); f.broker.subscribe(c, undefined, x => seenC.push(x))
  182. f.emit('message.delta', { text: '你好' })
  183. await new Promise(r => setTimeout(r, 30))
  184. expect(f.count()).toBe(1)
  185. expect(seenA.some(x => x.data.includes('你好'))).toBe(true)
  186. expect(seenC.some(x => x.data.includes('你好'))).toBe(true)
  187. expect(seenB.some(x => x.data.includes('你好'))).toBe(false)
  188. expect(() => f.broker.get(f.principal('bob'), a.id)).toThrow('Channel not found')
  189. })
  190. it('deduplicates simultaneous commands and rejects a changed payload', async () => {
  191. const f = await fixture(), p = f.principal('alice')
  192. const c = await f.broker.create(p, 'chat')
  193. await f.broker.command(c, 'open', resume)
  194. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'secret prompt' } }
  195. const [one, two] = await Promise.all([f.broker.command(c, 'outbox-1', prompt), f.broker.command(c, 'outbox-1', prompt)])
  196. expect(one).toEqual(two); expect(one.state).toBe('confirmed')
  197. expect(f.commands.filter(x => x.method === 'prompt.submit')).toHaveLength(1)
  198. expect(() => f.broker.command(c, 'outbox-1', { ...prompt, params: { ...prompt.params, text: 'different' } })).toThrow('conflict')
  199. expect(() => f.broker.receipt(f.principal('bob'), 'outbox-1')).toThrow('Receipt not found')
  200. for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) expect(readFileSync(join(f.home, file)).includes(Buffer.from('secret prompt'))).toBe(false)
  201. })
  202. it('keeps upstream alive after downstream detach and replays exact cursor', async () => {
  203. const f = await fixture(), p = f.principal('alice')
  204. const c = await f.broker.create(p, 'chat')
  205. await f.broker.command(c, 'open', resume)
  206. const entries: StreamEntry[] = []
  207. const off = f.broker.subscribe(c, undefined, x => entries.push(x))
  208. const cursor = entries.at(-1)!.id
  209. off(); f.emit('message.start', {}); f.emit('message.delta', { text: 'background' })
  210. await new Promise(r => setTimeout(r, 30))
  211. const replay: StreamEntry[] = []
  212. f.broker.subscribe(c, cursor, x => replay.push(x))
  213. expect(replay).toHaveLength(2); expect(f.count()).toBe(1)
  214. f.advance(600_001)
  215. expect(() => f.broker.subscribe(c, cursor, () => {})).toThrow('Replay window expired')
  216. })
  217. it('does not execute runtime commands without a route subscription', async () => {
  218. const f = await fixture()
  219. const c = await f.broker.create(f.principal('alice'), 'chat')
  220. const result = await f.broker.command(c, 'forbidden', { method: 'session.interrupt', params: { session_id: 'guessed' } })
  221. expect(result.state).toBe('rejected'); expect(f.commands).toHaveLength(0)
  222. })
  223. it('marks write-ahead admissions unknown after a process restart', () => {
  224. const home = mkdtempSync(join(tmpdir(), 'yaoyao-receipts-test-'))
  225. const a = new RealtimeReceipts(home)
  226. a.reserve('alice', 'submitted', '{"text":"private"}'); a.close()
  227. const b = new RealtimeReceipts(home)
  228. expect(b.reserve('alice', 'submitted', '{"text":"private"}')).toMatchObject({ state: 'unknown' })
  229. b.close()
  230. })
  231. it('registers branch identities without losing the original subscription', async () => {
  232. const f = await fixture()
  233. const ownershipCommands: Array<{ owner: string; stored: string; method: string }> = []
  234. f.broker.onNativeCommand = (owner, _profile, stored, method) => {
  235. ownershipCommands.push({ owner, stored, method })
  236. }
  237. const c = await f.broker.create(f.principal('user:alice:1'), 'chat')
  238. await f.broker.command(c, 'open', resume)
  239. const branch = await f.broker.command(c, 'branch', { method: 'session.branch', params: { session_id: 'runtime-1' } })
  240. expect(branch.state).toBe('confirmed')
  241. const sent = await f.broker.command(c, 'branched-prompt', { method: 'prompt.submit', params: { session_id: 'runtime-branch', text: 'branch' } })
  242. expect(sent.state).toBe('confirmed')
  243. expect(c.routes.size).toBe(2)
  244. expect(ownershipCommands).toContainEqual({ owner: 'alice', stored: 'stored-branch', method: 'session.branch' })
  245. })
  246. it('does not let guessed approval IDs escape a subscribed session', async () => {
  247. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  248. await f.broker.command(c, 'open', resume)
  249. const result = await f.broker.command(c, 'approval', { method: 'approval.respond', params: { session_id: 'runtime-1', request_id: 'another-users-approval', approved: true } })
  250. expect(result).toMatchObject({ state: 'rejected', response: { error: { code: 'interaction_forbidden' } } })
  251. expect(f.commands.filter(x => x.method === 'approval.respond')).toHaveLength(0)
  252. })
  253. it('page reloads cannot exhaust live-stream capacity with detached handles', async () => {
  254. const f = await fixture(), p = f.principal('alice')
  255. for (let i = 0; i < 40; i++) await f.broker.create(p, 'chat')
  256. expect(f.broker.channels.size).toBe(32)
  257. expect(f.count()).toBe(1)
  258. })
  259. it('recovers the upstream replay ring without losing offline deltas', async () => {
  260. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  261. await f.broker.command(c, 'open', resume)
  262. const received: StreamEntry[] = []
  263. f.broker.subscribe(c, undefined, e => received.push(e))
  264. f.emit('message.delta', { text: 'before' })
  265. await vi.waitFor(() => expect(received.some(e => e.data.includes('before'))).toBe(true))
  266. f.disconnect(); f.emit('message.delta', { text: 'offline' })
  267. await vi.waitFor(() => expect(received.filter(e => e.data.includes('offline'))).toHaveLength(1), { timeout: 2500 })
  268. expect(f.count()).toBe(2)
  269. expect(received.filter(e => e.event === 'reset')).toHaveLength(0)
  270. })
  271. it('reports reset when upstream history was truncated', async () => {
  272. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  273. await f.broker.command(c, 'open', resume)
  274. const received: StreamEntry[] = []
  275. f.broker.subscribe(c, undefined, e => received.push(e))
  276. f.truncate(); f.disconnect()
  277. await vi.waitFor(() => expect(received.some(e => e.event === 'reset')).toBe(true), { timeout: 2500 })
  278. })
  279. it('never repeats a prompt when the upstream acceptance reply is lost', async () => {
  280. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  281. await f.broker.command(c, 'open', resume)
  282. f.dropPrompt()
  283. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'do once' } }
  284. const result = await f.broker.command(c, 'lost-ack', prompt)
  285. expect(result.state).toBe('unknown')
  286. expect((await f.broker.command(c, 'lost-ack', prompt)).state).toBe('unknown')
  287. expect(f.commands.filter(f => f.method === 'prompt.submit')).toHaveLength(1)
  288. })
  289. it('invalidates read snapshots before/after mutation and before delivering live events', async () => {
  290. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  291. await f.broker.command(c, 'open', resume)
  292. await f.broker.command(c, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'once' } })
  293. expect(f.activities.filter(c => c.kind === 'command' && c.name === 'prompt.submit')).toEqual([
  294. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  295. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  296. ])
  297. const seen: StreamEntry[] = []
  298. f.broker.subscribe(c, undefined, entry => {
  299. if (entry.data.includes('message.delta')) expect(f.activities.at(-1)).toMatchObject({ kind: 'event', name: 'message.delta', sessionId: 'stored-1' })
  300. seen.push(entry)
  301. })
  302. f.emit('message.delta', { text: 'fresh' })
  303. await vi.waitFor(() => expect(seen.some(e => e.data.includes('fresh'))).toBe(true))
  304. })
  305. })