realtimeApi.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import type { AgentExportContext } from './workspaceRemoteAgents.js'
  2. import { randomBytes } from 'node:crypto'
  3. import type { Server, IncomingMessage } from 'node:http'
  4. import type { Duplex } from 'node:stream'
  5. import type Koa from 'koa'
  6. import { RealtimeBroker, type RealtimePrincipal, type RealtimeChannel } from './realtimeBroker.js'
  7. import type { ServerConfig } from './config.js'
  8. import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  9. import { NodePairingStore } from './pairing.js'
  10. import { CookieJar, UpstreamClient } from './upstream.js'
  11. import { CsrfProtection, isAllowedHostHeader, isExactOrigin } from './security.js'
  12. import { HttpError } from './errors.js'
  13. import { configuredWorkingDirectory } from './sessionWorkingDirectory.js'
  14. import { canonicalEpoch, groupCursor } from './realtimeProtocol.js'
  15. import { CHAT_MAX_PAYLOAD } from './realtimeProtocol.js'
  16. import { ChatPushRelayObserver, type ChatNotificationResolver, type PushEventCoordinator, type ChatPushTransportFactory } from './pushEvents.js'
  17. export class RealtimeAPI {
  18. readonly broker: RealtimeBroker
  19. private observers = new Map<string, ChatPushRelayObserver>()
  20. constructor(readonly config: ServerConfig, readonly auth: LocalAuthStore, readonly csrf: CsrfProtection,
  21. readonly pairings: NodePairingStore, readonly upstream: UpstreamClient, readonly session: UpstreamServiceSession,
  22. readonly push?: { coordinator: PushEventCoordinator; resolver: ChatNotificationResolver },
  23. readonly ownsSession?: (owner: string, profile: string, sessionId: string) => boolean) {
  24. this.broker = new RealtimeBroker(config.home, Date.now, change => {
  25. upstream.observeRealtime(change)
  26. if (change.kind === 'reset') session.invalidateAuthentication()
  27. })
  28. }
  29. private async canResume(profile: string, sessionId: string, _jar?: CookieJar, owner?: string): Promise<boolean> {
  30. return owner ? this.ownsSession?.(owner, profile, sessionId) ?? false : false
  31. }
  32. private principal(ctx: Koa.Context, device?: string): RealtimePrincipal {
  33. const exported=ctx.state.workspaceAgentExport as AgentExportContext | undefined
  34. const upstream=exported?.target.client ?? this.upstream
  35. const endpoint=exported?.target.url ?? this.config.upstream
  36. let key: string
  37. let valid: () => boolean
  38. let authorize: RealtimePrincipal['authorize']
  39. let jar: CookieJar | undefined
  40. let owner: string | undefined
  41. let observer: ChatPushRelayObserver | undefined
  42. if (device) {
  43. const bearer = ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
  44. jar = new CookieJar(this.pairings.authorize(device, bearer))
  45. key = `device:${device}${exported ? ':agent:'+exported.id : ''}`
  46. owner = key
  47. valid = () => this.pairings.hasDevice(device) && (!exported || exported.valid())
  48. authorize = (kind, method) => {
  49. this.pairings.authorize(device, bearer, kind === 'groups' ? 'groups.read'
  50. : method === 'profiles.list' || method === 'profiles.get_asset' ? 'agents.read' : 'sessions.execute')
  51. }
  52. } else {
  53. const user = this.auth.require(ctx)
  54. owner = user.id
  55. const cookie = ctx.get('cookie')
  56. key = `user:${user.id}:${this.auth.pushAuthorizationVersion(user.id)}`
  57. valid = () => { try { return this.auth.currentFromCookieHeader(cookie)?.id === user.id } catch { return false } }
  58. if (this.push) {
  59. observer = this.observers.get(key)
  60. if (!observer) {
  61. observer = new ChatPushRelayObserver(this.push.coordinator, { localUserID: user.id, source: 'web' }, Date.now, this.push.resolver)
  62. this.observers.set(key, observer)
  63. }
  64. }
  65. }
  66. const nativeBot = ctx.state.hermesBotNative === true
  67. if (nativeBot) key = `native:${key}`
  68. return {
  69. key, nativeBot, instanceKey: endpoint.href, upstreamKey: `${endpoint.href}:${device ? key : 'service'}`, paired: Boolean(device), valid, authorize,
  70. canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar, owner),
  71. configuredWorkingDirectory: profile => configuredWorkingDirectory((path, options) => {
  72. if (!valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  73. if (exported && endpoint.href !== this.config.upstream.href) return exported.target.session.request(path, options)
  74. if (jar) return this.upstream.request(path, jar, options)
  75. return this.session.request(path, options)
  76. }, profile),
  77. agent: upstream.directAgent,
  78. observeCommand: f => observer?.observeClientFrame(f),
  79. observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),
  80. url: async (kind, anchor) => {
  81. if (device && !valid()) throw new HttpError(401, 'Device revoked', 'authentication_required')
  82. // Paired devices retain their delegated identity; never upgrade a device
  83. // cookie into the local service's loopback authorization.
  84. let credential: { name: string; value: string }
  85. if (exported && endpoint.href !== this.config.upstream.href) credential = await exported.target.session.webSocketCredential()
  86. else if (jar) {
  87. const response = await this.upstream.request('/api/auth/ws-ticket', jar, { method: 'POST' })
  88. if (response.status !== 200) throw new HttpError(502, 'Upstream authentication failed', 'upstream_auth_failed')
  89. const ticket = JSON.parse(response.body.toString()).ticket
  90. if (typeof ticket !== 'string' || !ticket) throw new HttpError(502, 'Invalid upstream ticket', 'invalid_ticket')
  91. credential = { name: 'ticket', value: ticket }
  92. } else credential = await this.session.webSocketCredential()
  93. const url = new URL(endpoint)
  94. url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
  95. url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
  96. url.search = ''; url.searchParams.set(credential.name, credential.value)
  97. if (anchor) { url.searchParams.set('epoch', anchor.epoch); url.searchParams.set('cursor', String(anchor.cursor)) }
  98. return url
  99. },
  100. }
  101. }
  102. recoveryTransport: ChatPushTransportFactory = async (job, onFrame, onClose) => {
  103. const principal: RealtimePrincipal = {
  104. key: `push:${job.localUserID}:${this.auth.pushAuthorizationVersion(job.localUserID)}`,
  105. upstreamKey: `${this.config.upstream.href}:service`, paired: false,
  106. instanceKey: this.config.upstream.href,
  107. agent: this.upstream.directAgent,
  108. valid: () => this.auth.isUserActive(job.localUserID),
  109. url: async () => {
  110. const credential = await this.session.webSocketCredential()
  111. const url = new URL(this.config.upstream)
  112. url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
  113. url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
  114. url.search = ''; url.searchParams.set(credential.name, credential.value)
  115. return url
  116. },
  117. }
  118. const channel = await this.broker.create(principal, 'chat')
  119. let off = () => {}, closed = false
  120. return {
  121. start: () => { off = this.broker.subscribe(channel, undefined, entry => { if (entry.event === 'reset') onClose(); else onFrame(entry.data) }) },
  122. send: raw => {
  123. const frame = JSON.parse(raw)
  124. void Promise.resolve().then(() => this.broker.command(channel, randomBytes(24).toString('hex'), frame)).then(receipt => {
  125. if (!closed) onFrame(JSON.stringify({ id: frame.id, ...(receipt.response ?? { error: { message: 'Submission uncertain' } }) }))
  126. }).catch(onClose)
  127. },
  128. close: () => { closed = true; off(); this.broker.release(channel) },
  129. }
  130. }
  131. ownsPushJob(jobID: string): boolean { return [...this.observers.values()].some(observer => observer.ownsJob(jobID)) }
  132. private checkMutation(ctx: Koa.Context, paired: boolean): void {
  133. if (paired || ['GET', 'HEAD'].includes(ctx.method)) return
  134. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), ctx.secure || Boolean(this.config.tlsCert), this.config.allowedHosts)
  135. || !this.csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  136. throw new HttpError(403, 'Origin or CSRF token is invalid', 'invalid_csrf')
  137. }
  138. }
  139. private async body(ctx: Koa.Context, limit = 2 * 1024 * 1024): Promise<Record<string, any>> {
  140. if (!ctx.is('application/json')) throw new HttpError(415, 'JSON body required', 'invalid_content_type')
  141. let bytes = 0
  142. const chunks: Buffer[] = []
  143. const timeout = setTimeout(() => ctx.req.destroy(), 30_000)
  144. timeout.unref()
  145. try {
  146. for await (const chunk of ctx.req) {
  147. bytes += Buffer.byteLength(chunk)
  148. if (bytes > limit) throw new HttpError(413, 'Request body too large', 'body_too_large')
  149. chunks.push(Buffer.from(chunk))
  150. }
  151. const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
  152. if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not object')
  153. return value as Record<string, any>
  154. } catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(400, 'Invalid JSON', 'invalid_json_body') }
  155. finally { clearTimeout(timeout) }
  156. }
  157. middleware(): Koa.Middleware {
  158. return async (ctx, next) => {
  159. const match = /^(?:\/node\/([0-9a-f-]{36}))?\/api\/realtime(\/.*)$/.exec(ctx.path)
  160. if (/^(?:\/node\/[^/]+)?\/api\/(?:auth\/ws-ticket|ws|plugins\/yaoyao\/v1\/events)$/.test(ctx.path)
  161. || ctx.path === '/api/app/realtime-leases' || /^\/ws\/(?:chat|groups)$/.test(ctx.path)) {
  162. throw new HttpError(410, 'Legacy realtime endpoints were removed; use HTTP+SSE', 'http_sse_required')
  163. }
  164. if (!match) return next()
  165. const device = match[1]
  166. const principal = this.principal(ctx, device)
  167. ctx.set('Cache-Control', 'no-store')
  168. const path = match![2]!
  169. if (ctx.method === 'GET' && path === '/capabilities') {
  170. ctx.body = { protocolVersion: 1, channels: ['chat'], brokerEpoch: this.broker.epoch,
  171. ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
  172. return
  173. }
  174. this.checkMutation(ctx, Boolean(device))
  175. if (ctx.method === 'POST' && path === '/channels') {
  176. const body = await this.body(ctx)
  177. if (ctx.state.workspaceAgentExport && body.channel !== 'chat') throw new HttpError(403,'机器人仅支持聊天通道','invalid_channel')
  178. if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
  179. const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
  180. ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
  181. ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return
  182. }
  183. const receipt = /^\/commands\/([A-Za-z0-9:_-]{1,200})$/.exec(path)
  184. if (receipt && ctx.method === 'GET') { ctx.body = this.broker.receipt(principal, receipt[1]!); return }
  185. const channel = /^\/channels\/([0-9a-f-]{36})(?:\/(commands|events))?$/.exec(path)
  186. if (!channel) throw new HttpError(404, 'Realtime route not found', 'not_found')
  187. const c = this.broker.get(principal, channel[1]!)
  188. if (ctx.method === 'DELETE' && !channel[2]) { this.broker.detach(principal, c.id); ctx.status = 204; return }
  189. if (ctx.method === 'POST' && channel[2] === 'commands') {
  190. let body = await this.body(ctx, CHAT_MAX_PAYLOAD)
  191. const exported=ctx.state.workspaceAgentExport as AgentExportContext | undefined
  192. if (exported) body=exported.transform(body)
  193. ctx.body = await this.broker.command(c, ctx.get('idempotency-key'), body)
  194. return
  195. }
  196. if (ctx.method === 'GET' && channel[2] === 'events') { this.stream(ctx, c); return }
  197. throw new HttpError(405, 'Method not allowed', 'method_not_allowed')
  198. }
  199. }
  200. private stream(ctx: Koa.Context, c: RealtimeChannel): void {
  201. const res = ctx.res
  202. const initial: string[] = []
  203. let started = false
  204. let closed = false
  205. let congested: ReturnType<typeof setTimeout> | undefined
  206. let heartbeat: ReturnType<typeof setInterval> | undefined
  207. let off = () => {}
  208. const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); clearTimeout(congested); off(); if (!res.writableEnded) res.end() }
  209. const write = (text: string) => {
  210. if (!started) { initial.push(text); return }
  211. if (res.destroyed || res.writableEnded) return close()
  212. if (res.writableLength + Buffer.byteLength(text) > 1024 * 1024) return close()
  213. if (!res.write(text) && !congested) { congested = setTimeout(close, 15_000); congested.unref() }
  214. }
  215. off = this.broker.subscribe(c, ctx.get('last-event-id') || undefined, entry => {
  216. write(`id: ${entry.id}\nevent: ${entry.event}\ndata: ${entry.data}\n\n`)
  217. if (entry.event === 'reset' && started) close()
  218. })
  219. ctx.respond = false
  220. res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' })
  221. res.flushHeaders(); started = true
  222. write(`event: ready\ndata: ${JSON.stringify({ channelId: c.id, brokerEpoch: this.broker.epoch })}\n\n`)
  223. for (const text of initial) write(text)
  224. heartbeat = setInterval(() => { if (!c.principal.valid()) close(); else write(': heartbeat\n\n') }, 15_000)
  225. heartbeat.unref()
  226. res.on('drain', () => { clearTimeout(congested); congested = undefined })
  227. res.once('close', close)
  228. res.once('error', close)
  229. }
  230. rejectLegacyUpgrades(server: Server): () => void {
  231. const upgrade = (request: IncomingMessage, socket: Duplex) => {
  232. const path = (request.url ?? '/').split('?')[0]!
  233. if (!this.config.production && !path.startsWith('/api/') && !path.startsWith('/node/') && !path.startsWith('/ws/')) return
  234. const body = JSON.stringify({ code: 'http_sse_required', error: 'Client WebSockets are no longer supported; use HTTP+SSE' })
  235. socket.end('HTTP/1.1 410 Gone\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ' + Buffer.byteLength(body) + '\r\n\r\n' + body)
  236. }
  237. server.on('upgrade', upgrade)
  238. return () => server.off('upgrade', upgrade)
  239. }
  240. close(): void {
  241. for (const observer of this.observers.values()) observer.disconnected()
  242. this.observers.clear(); this.broker.close()
  243. }
  244. }