realtimeProtocol.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import type { RawData } from 'ws'
  2. import { HttpError } from './errors.js'
  3. export const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
  4. const CHAT_METHODS = new Set([
  5. 'approval.respond',
  6. 'clarify.respond',
  7. 'config.set',
  8. 'file.attach',
  9. 'image.attach_bytes',
  10. 'pdf.attach',
  11. 'profiles.configure',
  12. 'profiles.get_asset',
  13. 'profiles.list',
  14. 'profiles.set_asset',
  15. 'prompt.submit',
  16. 'session.branch',
  17. 'session.close',
  18. 'session.context_breakdown',
  19. 'session.create',
  20. 'session.interrupt',
  21. 'session.resume',
  22. 'session.steer',
  23. 'session.usage',
  24. ])
  25. const SESSION_ID_REQUIRED_METHODS = new Set([
  26. 'config.set',
  27. 'file.attach',
  28. 'image.attach_bytes',
  29. 'pdf.attach',
  30. 'prompt.submit',
  31. 'session.branch',
  32. 'session.close',
  33. 'session.context_breakdown',
  34. 'session.interrupt',
  35. 'session.resume',
  36. 'session.steer',
  37. 'session.usage',
  38. ])
  39. function safeString(value: unknown, label: string, required = false): string | undefined {
  40. if (value === undefined && !required) return undefined
  41. if (typeof value !== 'string') throw new HttpError(400, `${label} must be a string`)
  42. const normalized = value.trim()
  43. if ((!normalized && required) || normalized.length > 256 || /[\u0000-\u001f\u007f]/.test(normalized)) {
  44. throw new HttpError(400, `${label} is invalid`)
  45. }
  46. return normalized
  47. }
  48. function normalizedCreateSource(value: unknown, fallback = 'web'): 'web' | 'ios' {
  49. const source = safeString(value, 'source') ?? fallback
  50. if (source !== 'web' && source !== 'ios') {
  51. throw new HttpError(400, 'source must be web or ios', 'invalid_source')
  52. }
  53. return source
  54. }
  55. function normalizedConfigParams(params: Record<string, unknown>): Record<string, unknown> {
  56. const sessionID = safeString(params.session_id, 'session_id', true)!
  57. const key = safeString(params.key, 'config key', true)
  58. if (key === 'model') {
  59. const value = typeof params.value === 'string' ? params.value.trim() : ''
  60. if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511} --provider [A-Za-z0-9][A-Za-z0-9._:-]{0,127}( --session)?$/.test(value)) {
  61. throw new HttpError(400, 'Model selection is invalid')
  62. }
  63. return {
  64. session_id: sessionID,
  65. key,
  66. value,
  67. ...(params.confirm_expensive_model === true ? { confirm_expensive_model: true } : {}),
  68. }
  69. }
  70. if (key === 'reasoning') {
  71. const value = safeString(params.value, 'reasoning value', true)!
  72. if (!new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).has(value)) {
  73. throw new HttpError(400, 'Reasoning value is invalid')
  74. }
  75. return { session_id: sessionID, key, value, scope: 'session' }
  76. }
  77. if (key === 'fast') {
  78. const value = safeString(params.value, 'fast value', true)!
  79. if (value !== 'fast' && value !== 'normal') throw new HttpError(400, 'Fast value is invalid')
  80. return { session_id: sessionID, key, value, scope: 'session' }
  81. }
  82. throw new HttpError(403, 'Config key is not allowed')
  83. }
  84. function normalizedSessionOpenParams(
  85. method: 'session.create' | 'session.resume',
  86. params: Record<string, unknown>,
  87. ): Record<string, unknown> {
  88. const profile = safeString(params.profile, 'profile', true)!
  89. const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
  90. ? Math.min(500, Math.max(20, params.cols))
  91. : 80
  92. if (method === 'session.resume') {
  93. return {
  94. session_id: safeString(params.session_id, 'session_id', true)!,
  95. profile,
  96. close_on_disconnect: false,
  97. omit_messages: params.omit_messages === true,
  98. cols: columns,
  99. }
  100. }
  101. const source = normalizedCreateSource(params.source)
  102. const title = safeString(params.title, 'title')
  103. const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
  104. const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
  105. if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
  106. return {
  107. profile,
  108. source,
  109. close_on_disconnect: false,
  110. cols: columns,
  111. ...(title ? { title } : {}),
  112. ...(reasoning ? { reasoning_effort: reasoning } : {}),
  113. ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
  114. }
  115. }
  116. function normalizedPairedSessionOpenParams(
  117. method: 'session.create' | 'session.resume',
  118. params: Record<string, unknown>,
  119. ): Record<string, unknown> {
  120. const profile = safeString(params.profile, 'profile', true)!
  121. const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
  122. ? Math.min(500, Math.max(20, params.cols))
  123. : 80
  124. if (method === 'session.resume') {
  125. return {
  126. session_id: safeString(params.session_id, 'session_id', true)!,
  127. profile,
  128. close_on_disconnect: false,
  129. omit_messages: params.omit_messages === true,
  130. cols: columns,
  131. }
  132. }
  133. const source = safeString(params.source, 'source') ?? 'mobile'
  134. const title = safeString(params.title, 'title')
  135. const cwd = typeof params.cwd === 'string' && params.cwd.length <= 4_096 ? params.cwd : ''
  136. const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
  137. const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
  138. if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
  139. const model = safeString(params.model, 'model')
  140. const provider = safeString(params.provider, 'provider')
  141. if ((model === undefined) !== (provider === undefined)) {
  142. throw new HttpError(400, 'Model and provider must be set together')
  143. }
  144. const messages = params.messages
  145. if (messages !== undefined && (!Array.isArray(messages) || messages.length > 256)) {
  146. throw new HttpError(400, 'Seed messages are invalid')
  147. }
  148. return {
  149. profile,
  150. source,
  151. close_on_disconnect: false,
  152. cols: columns,
  153. cwd,
  154. hidden: params.hidden === true,
  155. ...(title ? { title } : {}),
  156. ...(reasoning ? { reasoning_effort: reasoning } : {}),
  157. ...(model && provider ? { model, provider } : {}),
  158. ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
  159. ...(messages !== undefined ? { messages } : {}),
  160. }
  161. }
  162. export function checkedChatFrame(data: RawData, isBinary: boolean, paired = false): string {
  163. if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
  164. const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
  165. if (bytes.byteLength > CHAT_MAX_PAYLOAD) throw new HttpError(413, 'Chat frame is too large')
  166. let value: unknown
  167. try {
  168. value = JSON.parse(bytes.toString('utf8'))
  169. } catch {
  170. throw new HttpError(400, 'Chat frame must be JSON')
  171. }
  172. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  173. throw new HttpError(400, 'Chat frame must be a JSON-RPC object')
  174. }
  175. const request = value as Record<string, unknown>
  176. if (typeof request.method !== 'string' || !CHAT_METHODS.has(request.method)) {
  177. throw new HttpError(403, 'Chat RPC method is not allowed')
  178. }
  179. if (request.id !== undefined && typeof request.id !== 'string' && typeof request.id !== 'number') {
  180. throw new HttpError(400, 'Chat RPC id is invalid')
  181. }
  182. if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
  183. throw new HttpError(400, 'Chat RPC params must be an object')
  184. }
  185. let params = { ...(request.params as Record<string, unknown>) }
  186. safeString(params.session_id, 'session_id', SESSION_ID_REQUIRED_METHODS.has(request.method))
  187. const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
  188. safeString(params.profile, 'profile', profileRequired)
  189. if (request.method === 'session.create' || request.method === 'session.resume') {
  190. params = paired
  191. ? normalizedPairedSessionOpenParams(request.method, params)
  192. : normalizedSessionOpenParams(request.method, params)
  193. }
  194. if (request.method === 'profiles.list') {
  195. const preferred = params.preferred_session_ids
  196. if (preferred !== undefined && (!preferred || typeof preferred !== 'object' || Array.isArray(preferred))) {
  197. throw new HttpError(400, 'preferred_session_ids must be an object')
  198. }
  199. params = {
  200. ...(typeof params.include_sessions === 'boolean' ? { include_sessions: params.include_sessions } : {}),
  201. ...(preferred !== undefined ? { preferred_session_ids: preferred } : {}),
  202. }
  203. }
  204. if (request.method === 'profiles.get_asset') {
  205. params = {
  206. name: safeString(params.name, 'profile name', true)!,
  207. asset: safeString(params.asset, 'profile asset', true)!,
  208. }
  209. }
  210. if (request.method === 'profiles.set_asset') {
  211. const clear = params.clear === true
  212. const data = typeof params.data === 'string' ? params.data.trim() : ''
  213. if (!clear && !data) throw new HttpError(400, 'profile asset data is required')
  214. if (data.length > 2_800_000) throw new HttpError(413, 'profile asset exceeds 2 MiB')
  215. params = {
  216. name: safeString(params.name, 'profile name', true)!,
  217. asset: safeString(params.asset, 'profile asset', true)!,
  218. ...(clear ? { clear: true } : { data }),
  219. }
  220. }
  221. if (request.method === 'profiles.configure') {
  222. if (!params.ui_meta || typeof params.ui_meta !== 'object' || Array.isArray(params.ui_meta)) {
  223. throw new HttpError(400, 'ui_meta must be an object')
  224. }
  225. if (params.ui_meta_expected_revisions !== undefined
  226. && (!params.ui_meta_expected_revisions
  227. || typeof params.ui_meta_expected_revisions !== 'object'
  228. || Array.isArray(params.ui_meta_expected_revisions))) {
  229. throw new HttpError(400, 'ui_meta_expected_revisions must be an object')
  230. }
  231. params = {
  232. name: safeString(params.name, 'profile name', true)!,
  233. ui_meta: params.ui_meta,
  234. ...(params.ui_meta_expected_revisions !== undefined
  235. ? { ui_meta_expected_revisions: params.ui_meta_expected_revisions }
  236. : {}),
  237. }
  238. }
  239. if (request.method === 'config.set') params = normalizedConfigParams(params)
  240. if (request.method === 'prompt.submit' || request.method === 'session.steer') {
  241. const text = typeof params.text === 'string' ? params.text.trim() : ''
  242. if (!text || text.length > 200_000) throw new HttpError(400, 'Prompt text is invalid')
  243. params = {
  244. session_id: safeString(params.session_id, 'session_id', true)!,
  245. text,
  246. ...(request.method === 'prompt.submit' && params.queued === true ? { queued: true } : {}),
  247. }
  248. }
  249. if (request.method === 'image.attach_bytes' || request.method === 'pdf.attach') {
  250. const encoded = typeof params.content_base64 === 'string' ? params.content_base64 : ''
  251. const estimated = Math.floor(encoded.length * 0.75)
  252. if (!encoded || estimated > 25 * 1_024 * 1_024) {
  253. throw new HttpError(413, 'Attachment exceeds 25 MiB')
  254. }
  255. }
  256. if (request.method === 'file.attach') {
  257. const dataURL = typeof params.data_url === 'string' ? params.data_url : ''
  258. const marker = dataURL.indexOf(',')
  259. if (marker < 0 || Math.floor((dataURL.length - marker - 1) * 0.75) > 25 * 1_024 * 1_024) {
  260. throw new HttpError(413, 'Attachment exceeds 25 MiB')
  261. }
  262. }
  263. return JSON.stringify({ ...request, params })
  264. }
  265. export class GroupFrameValidator {
  266. #ready = false
  267. #cursor: number
  268. constructor(readonly epoch: string, cursor: number) {
  269. this.#cursor = cursor
  270. }
  271. accept(data: RawData, isBinary: boolean): boolean {
  272. if (isBinary) throw new HttpError(400, 'Binary group frames are not accepted')
  273. let envelope: Record<string, unknown>
  274. try {
  275. const value = JSON.parse(Buffer.from(data as Uint8Array).toString('utf8')) as unknown
  276. if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
  277. envelope = value as Record<string, unknown>
  278. } catch {
  279. throw new HttpError(400, 'Malformed group event frame')
  280. }
  281. const type = envelope.type
  282. const cursor = Number(envelope.cursor)
  283. if (type === 'group.ready') {
  284. const heartbeat = Number(envelope.heartbeatSeconds)
  285. if (this.#ready || envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor)
  286. || cursor !== this.#cursor || !Number.isFinite(heartbeat) || heartbeat <= 0) {
  287. throw new HttpError(409, 'Invalid group.ready frame')
  288. }
  289. this.#ready = true
  290. return true
  291. }
  292. if (type === 'group.reset_required') {
  293. if (typeof envelope.epoch !== 'string' || !Number.isSafeInteger(cursor)
  294. || typeof envelope.reason !== 'string') {
  295. throw new HttpError(400, 'Invalid group.reset_required frame')
  296. }
  297. return true
  298. }
  299. if (!this.#ready) throw new HttpError(409, 'Group event arrived before group.ready')
  300. if (type === 'group.event') {
  301. if (envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor) || cursor < 0) {
  302. throw new HttpError(409, 'Group event epoch or cursor is invalid')
  303. }
  304. if (cursor <= this.#cursor) return false
  305. if (cursor !== this.#cursor + 1) throw new HttpError(409, 'Group event cursor gap')
  306. this.#cursor = cursor
  307. return true
  308. }
  309. if (type === 'group.heartbeat') {
  310. if ((envelope.epoch !== undefined && envelope.epoch !== this.epoch)
  311. || !Number.isSafeInteger(cursor) || cursor !== this.#cursor) {
  312. throw new HttpError(409, 'Group heartbeat cursor mismatch')
  313. }
  314. return true
  315. }
  316. throw new HttpError(400, 'Unknown group event frame')
  317. }
  318. resetFrame(reason: string): string {
  319. return JSON.stringify({
  320. type: 'group.reset_required',
  321. epoch: this.epoch,
  322. cursor: this.#cursor,
  323. reason,
  324. })
  325. }
  326. }
  327. export function canonicalEpoch(value: unknown): string {
  328. if (typeof value !== 'string'
  329. || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
  330. throw new HttpError(400, 'epoch must be a canonical lowercase UUID', 'invalid_epoch')
  331. }
  332. return value
  333. }
  334. export function groupCursor(value: unknown): number {
  335. const cursor = typeof value === 'number' ? value : Number(value ?? 0)
  336. if (!Number.isSafeInteger(cursor) || cursor < 0) {
  337. throw new HttpError(400, 'cursor must be a non-negative integer', 'invalid_cursor')
  338. }
  339. return cursor
  340. }