app.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. import { chatTranscriptRouter } from './chatTranscriptApi.js'
  2. import {DesktopEnvironments} from './desktopEnvironments.js'
  3. import {DesktopHostHub} from './desktopHosts.js'
  4. import type {GrokAuth} from './grokAuth.js'
  5. import {GrokCloud} from './grokCloud.js'
  6. import {WorkspaceInspector} from './workspaceInspector.js'
  7. import {WorkspaceRoutines} from './workspaceRoutines.js'
  8. import { WorkspacePlugins } from './botPlugins/workspacePlugins.js'
  9. import { nativeMessageFileText } from '../shared/messageFiles.js'
  10. import { hermesBotRelay } from './hermesBotRelay.js'
  11. import { remoteAgentExports } from './workspaceRemoteAgents.js'
  12. import { randomBytes, createHash } from 'node:crypto'
  13. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  14. import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
  15. import { createServer as createHttpsServer } from 'node:https'
  16. import { join } from 'node:path'
  17. import { bodyParser } from '@koa/bodyparser'
  18. import Koa from 'koa'
  19. import { RealtimeAPI } from './realtimeApi.js'
  20. import type { ServerConfig } from './config.js'
  21. import { loadServerConfig } from './config.js'
  22. import { errorMessage, HttpError } from './errors.js'
  23. import { NodePairingStore } from './pairing.js'
  24. import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  25. import { AccountLoginPairingStore } from './accountPairing.js'
  26. import { UpstreamProfileIdentityService } from './profileIdentities.js'
  27. import { createApiRouter } from './routes.js'
  28. import {
  29. applySecurityHeaders,
  30. CsrfProtection,
  31. isAllowedHostHeader,
  32. isExactOrigin,
  33. } from './security.js'
  34. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  35. import { UploadStore } from './uploads.js'
  36. import { WorkspaceStore } from './workspaceStore.js'
  37. import { WorkspaceNodes } from './workspaceGateway.js'
  38. import { WorkspaceRuntime } from './workspaceRuntime.js'
  39. import { WorkspaceMemorySynthesis } from './workspaceMemorySynthesis.js'
  40. import { WorkspaceAssets } from './workspaceAssets.js'
  41. import { workspaceRouter } from './workspaceRoutes.js'
  42. import { SystemUpdateManager } from './updateManager.js'
  43. import { PushCoordinator } from './pushCoordinator.js'
  44. import {
  45. APNsConfigurationManager,
  46. type APNsConfigurationSnapshot,
  47. } from './apnsConfiguration.js'
  48. import {
  49. FCMConfigurationManager,
  50. type FCMConfigurationSnapshot,
  51. } from './fcmConfiguration.js'
  52. import { OpenVikingConfigurationManager } from './openVikingConfiguration.js'
  53. import { OpenVikingService } from './openVikingService.js'
  54. import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
  55. import {
  56. AllowedHostsConfigurationManager,
  57. type AllowedHostsConfigurationSnapshot,
  58. } from './allowedHostsConfiguration.js'
  59. import {
  60. ChatPushJobManager,
  61. HermesChatNotificationResolver,
  62. } from './pushEvents.js'
  63. import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
  64. import {LocalVmService} from './localVm.js'
  65. import {SharedComputers} from './sharedComputers.js'
  66. import {ComputerControlService} from './computerControls.js'
  67. import { RunnerHub } from './runnerHub.js'
  68. import { HermesBridgeManager } from './hermesBridge.js'
  69. export interface ApplicationOptions {
  70. hermesBridge?: HermesBridgeManager
  71. config?: ServerConfig
  72. fetchImpl?: typeof fetch
  73. grokFetch?:typeof fetch
  74. csrfSecret?: Buffer
  75. pairings?: NodePairingStore
  76. uploads?: UploadStore
  77. updates?: SystemUpdateManager
  78. auth?: LocalAuthStore
  79. pluginFetch?: typeof fetch
  80. upstreamSession?: UpstreamServiceSession
  81. accountPairings?: AccountLoginPairingStore
  82. profileIdentities?: UpstreamProfileIdentityService
  83. push?: PushCoordinator
  84. apnsConfiguration?: APNsConfigurationManager
  85. fcmConfiguration?: FCMConfigurationManager
  86. openVikingConfiguration?: OpenVikingConfigurationManager
  87. allowedHostsConfiguration?: AllowedHostsConfigurationManager
  88. chatCache?: ChatCacheCoordinator
  89. }
  90. export interface ApplicationRuntime {
  91. hermesBridge: HermesBridgeManager
  92. runners: RunnerHub
  93. workspace: WorkspaceStore
  94. workspaceRuntime: WorkspaceRuntime
  95. realtime: RealtimeAPI
  96. app: Koa
  97. config: ServerConfig
  98. csrf: CsrfProtection
  99. pairings: NodePairingStore
  100. upstream: UpstreamClient
  101. uploads: UploadStore
  102. updates: SystemUpdateManager
  103. auth: LocalAuthStore
  104. upstreamSession: UpstreamServiceSession
  105. accountPairings: AccountLoginPairingStore
  106. profileIdentities: UpstreamProfileIdentityService
  107. push: PushCoordinator
  108. apnsConfiguration: APNsConfigurationManager
  109. fcmConfiguration: FCMConfigurationManager
  110. openVikingConfiguration: OpenVikingConfigurationManager
  111. openVikingService: OpenVikingService
  112. allowedHostsConfiguration: AllowedHostsConfigurationManager
  113. pushEventCoordinator: PushCoordinatorEventAdapter
  114. chatPushJobs: ChatPushJobManager
  115. chatCache?: ChatCacheCoordinator
  116. desktopEnvironments:DesktopEnvironments
  117. desktopHosts:DesktopHostHub
  118. grokAuth:GrokAuth
  119. workspaceRoutines:WorkspaceRoutines
  120. workspacePlugins:WorkspacePlugins
  121. localVm: LocalVmService
  122. close(): void
  123. }
  124. function isMutation(method: string): boolean {
  125. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  126. }
  127. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  128. if (override) return override
  129. const path = join(home, 'csrf-secret.bin')
  130. try {
  131. const secret = readFileSync(path)
  132. if (secret.byteLength === 32) return secret
  133. throw new Error('CSRF secret has an invalid length')
  134. } catch (error) {
  135. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  136. }
  137. mkdirSync(home, { recursive: true, mode: 0o700 })
  138. const generated = randomBytes(32)
  139. try {
  140. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  141. return generated
  142. } catch (error) {
  143. // Another 15300 process can finish first during launchd hand-off. Never
  144. // rotate its secret; use the one it safely wrote instead.
  145. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  146. throw error
  147. }
  148. }
  149. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  150. const config = options.config ?? loadServerConfig()
  151. const runtimeAllowedHosts = new Set(config.allowedHosts)
  152. config.allowedHosts = runtimeAllowedHosts
  153. const app = new Koa()
  154. app.proxy = false
  155. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  156. const pairings = options.pairings ?? new NodePairingStore(config.home)
  157. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  158. const uploads = options.uploads ?? new UploadStore(config.home)
  159. const updates = options.updates ?? new SystemUpdateManager(config)
  160. const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
  161. const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
  162. ? { username: config.upstreamUsername, password: config.upstreamPassword }
  163. : undefined
  164. const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
  165. upstream,
  166. () => auth.upstreamCredentials(configuredUpstreamCredentials),
  167. )
  168. let chatCache = options.chatCache
  169. if (!chatCache) {
  170. chatCache = new ChatCacheCoordinator(
  171. new ChatCacheStore(
  172. config.home,
  173. auth.soleUserIDForLegacyMigration(),
  174. ),
  175. upstreamSession,
  176. config.chatCacheMode ?? 'prefer-local',
  177. )
  178. }
  179. const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
  180. const profileIdentities = options.profileIdentities
  181. ?? new UpstreamProfileIdentityService(config, upstreamSession)
  182. const initialAPNsSettings: APNsConfigurationSnapshot = config.apnsSettings ?? {
  183. source: config.apns || config.apnsConfigurationError ? 'environment' : 'none',
  184. editable: !(config.apns || config.apnsConfigurationError),
  185. ...(config.apns ? {
  186. input: {
  187. ...config.apns,
  188. environments: [...(config.apns.environments ?? ['development', 'production'])],
  189. },
  190. config: config.apns,
  191. } : {}),
  192. warnings: [],
  193. ...(config.apnsConfigurationError ? { configurationError: config.apnsConfigurationError } : {}),
  194. }
  195. const apnsConfiguration = options.apnsConfiguration
  196. ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
  197. const initialFCMSettings: FCMConfigurationSnapshot = config.fcmSettings ?? {
  198. source: config.fcm || config.fcmConfigurationError ? 'environment' : 'none',
  199. editable: !(config.fcm || config.fcmConfigurationError),
  200. ...(config.fcm ? { input: { ...config.fcm }, config: config.fcm } : {}),
  201. warnings: [],
  202. ...(config.fcmConfigurationError ? { configurationError: config.fcmConfigurationError } : {}),
  203. }
  204. const fcmConfiguration = options.fcmConfiguration
  205. ?? new FCMConfigurationManager(config.home, initialFCMSettings)
  206. const openVikingConfiguration = options.openVikingConfiguration
  207. ?? new OpenVikingConfigurationManager(config.home)
  208. const initialAllowedHostsSettings: AllowedHostsConfigurationSnapshot = config.allowedHostsSettings ?? {
  209. source: runtimeAllowedHosts.size ? 'environment' : 'none',
  210. hosts: [...runtimeAllowedHosts],
  211. editableHosts: [],
  212. environmentHosts: [...runtimeAllowedHosts],
  213. }
  214. const allowedHostsConfiguration = options.allowedHostsConfiguration
  215. ?? new AllowedHostsConfigurationManager(config.home, initialAllowedHostsSettings, runtimeAllowedHosts)
  216. const push = options.push ?? new PushCoordinator({
  217. home: config.home,
  218. apns: config.apns,
  219. apnsConfigurationError: config.apnsConfigurationError,
  220. fcm: config.fcm,
  221. fcmConfigurationError: config.fcmConfigurationError,
  222. isUserActive: userID => auth.isUserActive(userID),
  223. userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
  224. })
  225. const pushEventCoordinator = new PushCoordinatorEventAdapter(push)
  226. const chatPushJobs = new ChatPushJobManager(config, upstreamSession, pushEventCoordinator, {
  227. resolver: new HermesChatNotificationResolver(
  228. upstreamSession,
  229. (localUserID, prompt) => push.promptDigest(localUserID, prompt),
  230. ),
  231. })
  232. // The application owns group events. No Dashboard plugin is queried.
  233. const workspace = new WorkspaceStore(config.home)
  234. const openVikingService = new OpenVikingService(workspace, openVikingConfiguration)
  235. const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
  236. workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
  237. const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
  238. runners.composeDesktops.desktops.push(...(config.composeDesktops??[]))
  239. const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
  240. const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
  241. const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)
  242. runners.localVmAllowed=(id,runnerId)=>localVm.allowed(id,runnerId)
  243. runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
  244. workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
  245. const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0, openVikingService)
  246. const hermesBridge=options.hermesBridge??new HermesBridgeManager(config,upstreamSession,{isIdle:()=>workspaceRuntime.idleForUpdate&&runners.idleForUpdate&&realtime.broker.idleForUpdate})
  247. const workspaceMemory = new WorkspaceMemorySynthesis(workspaceRuntime)
  248. workspaceMemory.start()
  249. const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
  250. const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
  251. const desktopHosts=new DesktopHostHub(workspace,auth)
  252. workspaceRuntime.desktopEnvironments=desktopEnvironments
  253. grokCloud.preferDesktop=(owner,agent)=>!!desktopEnvironments.selected(owner,agent)
  254. grokCloud.beforeSelection=(owner,id)=>desktopEnvironments.assertIdle(owner,id)
  255. workspaceRuntime.cloud=grokCloud
  256. const workspaceInspector=new WorkspaceInspector(workspace,auth)
  257. workspaceRuntime.inspector=workspaceInspector
  258. const workspaceRoutines=new WorkspaceRoutines(workspace,auth,workspaceNodes,workspaceRuntime)
  259. const workspacePlugins=new WorkspacePlugins(workspace,workspaceNodes,auth,workspaceRuntime,config.home,options.pluginFetch)
  260. workspaceRuntime.plugins=workspacePlugins
  261. workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
  262. workspaceRuntime.onTeamCreated = (owner, team) => {
  263. try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
  264. }
  265. const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
  266. workspaceRuntime.publishArtifact=(owner,message,agent,name,bytes)=>workspaceAssets.publish(owner,message,agent,name,bytes)
  267. workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
  268. workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
  269. if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
  270. push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}:${run.conversationTaskId ?? 'direct'}`, data: { conversationId:c.id, ...(run.conversationTaskId ? { conversationTaskId: run.conversationTaskId } : {}), workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
  271. }
  272. const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
  273. coordinator: pushEventCoordinator,
  274. resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
  275. }, (owner, profile, sessionID) =>
  276. chatCache?.store.ownsSession(owner, profile, sessionID) ?? false)
  277. realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
  278. realtime.transcriptsAvailable = chatCache.store.transcripts.enabled
  279. realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
  280. chatCache?.observe(owner, profile, storedId, frame)
  281. if (owner.startsWith('device:')) return
  282. if (!['message.complete', 'attachment.staged'].includes(frame.type)) return
  283. const data = frame.payload ?? {}, text = frame.type === 'attachment.staged' ? JSON.stringify(data) : nativeMessageFileText(data)
  284. const messageId = String(chatCache.store.projectedMessageID(owner,profile,storedId) ?? data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
  285. void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
  286. }
  287. workspace.nativeMessageForFile = (owner, file) => {
  288. const message = chatCache?.store.fileSourceMessage(owner, file.profile ?? 'default', file.conversationId ?? '', file.messageId ?? '')
  289. if (!message) return undefined
  290. return {
  291. id: file.messageId!, conversationId: file.conversationId!, seq: 0,
  292. role: message.role === 'assistant' ? 'assistant' : message.role === 'user' ? 'user' : 'system',
  293. content: nativeMessageFileText(message), reasoning: '', status: 'complete',
  294. attachments: [], tools: [], createdAt: file.createdAt,
  295. }
  296. }
  297. realtime.broker.onNativeGlobalEvent = (owner, type, upstreamInvalidation) => chatCache?.observeGlobal(owner, type, upstreamInvalidation)
  298. chatCache.onSynchronized = (owner, profile, sessionID) => {
  299. realtime.broker.publishOwnerSessionsChanged(owner, 'cache.synced', profile, sessionID)
  300. }
  301. realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
  302. chatCache?.command(owner, profile, storedId, method, params)
  303. }
  304. realtime.broker.nativeSourceCursor = (owner,profile,id,runtime,epoch)=>chatCache.store.transcripts.sourceCursor(owner,profile,id,runtime,epoch)
  305. realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId, running, snapshot) => {
  306. chatCache?.route(owner, profile, storedId, runtimeId, snapshot?undefined:running)
  307. if(snapshot)chatCache.store.recordEvent(owner,profile,storedId,{type:'route.resumed',payload:snapshot})
  308. }
  309. chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
  310. try {
  311. uploads.cleanupUncommitted()
  312. } catch {
  313. // A locked cleanup should not prevent startup; upload operations will
  314. // still surface the underlying storage error when used.
  315. }
  316. app.use(async (ctx, next) => {
  317. try {
  318. await next()
  319. } catch (error) {
  320. if (error instanceof UpstreamHttpError) {
  321. ctx.status = error.response.status
  322. ctx.type = 'application/json; charset=utf-8'
  323. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  324. return
  325. }
  326. const status = error instanceof HttpError
  327. ? error.status
  328. : typeof (error as { status?: unknown })?.status === 'number'
  329. ? Number((error as { status: number }).status)
  330. : 500
  331. const expose = status < 500 || error instanceof HttpError
  332. ctx.status = status
  333. ctx.type = 'application/json; charset=utf-8'
  334. ctx.body = {
  335. error: expose ? errorMessage(error) : 'Internal server error',
  336. code: error instanceof HttpError ? error.code : 'internal_error',
  337. }
  338. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  339. }
  340. })
  341. app.use(async (ctx, next) => {
  342. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  343. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  344. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  345. }
  346. await next()
  347. })
  348. app.use(async (ctx, next) => {
  349. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  350. const secure = ctx.secure || Boolean(config.tlsCert)
  351. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  352. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  353. }
  354. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  355. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  356. }
  357. }
  358. await next()
  359. })
  360. app.use(runners.middleware())
  361. app.use(desktopHosts.middleware())
  362. app.use(async (ctx, next) => {
  363. const user = auth.current(ctx)
  364. if (user && user.role !== 'admin' && (ctx.path.startsWith('/api/') || ctx.path.startsWith('/node/') || ctx.path.startsWith('/ws/') || ctx.path.startsWith('/Users/') || ctx.path.startsWith('/attachments/'))) {
  365. const path = ctx.path
  366. const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
  367. const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
  368. const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files|computers|bot-tools)(?:\/|$)/.test(path)
  369. && path !== '/api/app/agents/remote'
  370. const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
  371. const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
  372. if (!publicPath && !accountPath && !workspacePath && !pushPath && !identityRead)
  373. throw new HttpError(403, '子账号只能使用 Bot 模式', 'bot_mode_required')
  374. }
  375. await next()
  376. })
  377. app.use(hermesBotRelay(workspaceNodes, auth, csrf))
  378. app.use(remoteAgentExports(workspace,workspaceNodes,pairings,auth))
  379. app.use(realtime.middleware())
  380. const parseBody = (limit: string) => bodyParser({
  381. encoding: 'utf-8',
  382. enableTypes: ['json'],
  383. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  384. jsonLimit: limit,
  385. onError: (error, ctx) => {
  386. throw new HttpError(ctx.status === 413 || (error as { status?: number }).status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  387. },
  388. })
  389. const ordinaryBody = parseBody('2mb'), workspaceBody = parseBody('4mb')
  390. app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) || ctx.path === '/api/app/account/avatar' ? workspaceBody : ordinaryBody)(ctx,next))
  391. app.use(async (ctx, next) => {
  392. ctx.state.localAuth = auth
  393. ctx.state.upstreamSession = upstreamSession
  394. const anonymous = ctx.path === '/api/app/bootstrap'
  395. || ctx.path === '/api/app/setup'
  396. || ctx.path === '/api/app/login'
  397. || ctx.path === '/api/status'
  398. || ctx.path === '/api/auth/providers'
  399. || ctx.path === '/auth/password-login'
  400. || ctx.path.startsWith('/api/pair/v1/')
  401. || ctx.path.startsWith('/api/account-pair/v1/')
  402. || ctx.path === '/healthz'
  403. || ctx.path === '/readyz'
  404. if (ctx.path.startsWith('/api/app/') && !anonymous) {
  405. const allowPasswordChange = ctx.path === '/api/app/account/credentials'
  406. || ctx.path === '/api/app/logout'
  407. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  408. const adminOnly = ctx.path.startsWith('/api/app/admin/')
  409. || ctx.path.startsWith('/api/app/system/')
  410. || ctx.path.startsWith('/api/app/plugins/')
  411. || ctx.path.startsWith('/api/app/pairings')
  412. || ctx.path.startsWith('/api/app/paired-devices')
  413. || ctx.path.startsWith('/api/app/groups/nodes')
  414. if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
  415. }
  416. const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
  417. && !ctx.path.startsWith('/api/pair/'))
  418. || ctx.path === '/auth/logout'
  419. if (gatewayCompatibility && !anonymous) {
  420. const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
  421. || ctx.path === '/api/account/credentials'
  422. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  423. if (isMutation(ctx.method)) {
  424. const origin = ctx.get('origin')
  425. if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
  426. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  427. }
  428. }
  429. }
  430. const user = ctx.state.localUser as { id: string } | undefined
  431. if (user) {
  432. await upstream.withReadScope(`user:${user.id}:${auth.pushAuthorizationVersion(user.id)}`, ctx.get('x-yaoyao-cache') === 'bypass', next)
  433. } else await next()
  434. })
  435. for(const router of [desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
  436. const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
  437. const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
  438. const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
  439. const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push, csrf)
  440. app.use(runners.adminRouter().routes())
  441. app.use(desktopHosts.adminRouter().routes())
  442. app.use(applicationRouter.routes())
  443. app.use(async (ctx, next) => {
  444. if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
  445. const id = /\/sessions\/([^/]+)/.exec(ctx.path)?.[1]
  446. if (id && workspace.ownsUpstream(decodeURIComponent(id))) throw new HttpError(404, '会话不存在', 'session_not_found')
  447. await next()
  448. // Native history is a separate surface, including search and profile projections.
  449. if (ctx.path.includes('/sessions') || ctx.path.endsWith('/profiles')) {
  450. let value: any = ctx.body
  451. const buffered = Buffer.isBuffer(value)
  452. try {
  453. if (buffered) value = JSON.parse(value.toString())
  454. const filter = (v: any): any => {
  455. if (Array.isArray(v)) return v.filter(x => !x || typeof x !== 'object' || (x.source !== 'yaoyao_workspace' && !workspace.ownsUpstream(String(x.id ?? x.session_id ?? x.sessionId ?? '')))).map(filter)
  456. if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k,x]) => [k, filter(x)]))
  457. return v
  458. }
  459. ctx.body = buffered ? Buffer.from(JSON.stringify(filter(value))) : filter(value)
  460. } catch { /* Preserve non-JSON error responses. */ }
  461. }
  462. })
  463. app.use(chatTranscriptRouter(chatCache, auth).routes())
  464. const router = createApiRouter({
  465. onChatListChanged: (owner,profile,id) => realtime.broker.publishOwnerSessionsChanged(owner,'local-state',profile,id),
  466. onServerIdentityChanged: identity => realtime.broker.publishServerIdentity(identity, config.upstream.href),
  467. onUserAccessChanged: async owner => {
  468. for (const c of workspace.list<{ id: string }>(owner, 'conversation')) await workspaceRuntime.stopConversation(owner, c.id)
  469. },
  470. workspace,
  471. config,
  472. csrf,
  473. upstream,
  474. pairings,
  475. uploads,
  476. updates,
  477. auth,
  478. upstreamSession,
  479. accountPairings,
  480. profileIdentities,
  481. push,
  482. apnsConfiguration,
  483. fcmConfiguration,
  484. openVikingConfiguration,
  485. openVikingService,
  486. allowedHostsConfiguration,
  487. hermesBridge,
  488. chatCache,
  489. })
  490. app.use(router.routes())
  491. app.use(router.allowedMethods({ throw: false }))
  492. app.use(async (ctx, next) => {
  493. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  494. ctx.status = 404
  495. ctx.type = 'application/json; charset=utf-8'
  496. ctx.body = { error: 'Route not found', code: 'not_found' }
  497. return
  498. }
  499. await next()
  500. })
  501. return {
  502. runners,
  503. hermesBridge,
  504. localVm,
  505. workspace,
  506. workspaceRuntime,
  507. workspaceRoutines,
  508. workspacePlugins,
  509. desktopEnvironments,
  510. desktopHosts,
  511. grokAuth:grokCloud.authorization,
  512. realtime,
  513. app,
  514. config,
  515. csrf,
  516. pairings,
  517. upstream,
  518. uploads,
  519. updates,
  520. auth,
  521. upstreamSession,
  522. accountPairings,
  523. profileIdentities,
  524. push,
  525. apnsConfiguration,
  526. fcmConfiguration,
  527. openVikingConfiguration,
  528. openVikingService,
  529. allowedHostsConfiguration,
  530. pushEventCoordinator,
  531. chatPushJobs,
  532. chatCache,
  533. close: () => {
  534. workspaceMemory.close()
  535. runners.close()
  536. workspaceAssets.close()
  537. desktopEnvironments.close()
  538. desktopHosts.close()
  539. grokCloud.authorization.close()
  540. workspaceRoutines.close()
  541. workspacePlugins.close()
  542. workspaceRuntime.close()
  543. workspaceNodes.close()
  544. upstream.close()
  545. realtime.close()
  546. chatPushJobs.stop()
  547. push.close()
  548. uploads.close()
  549. workspace.close()
  550. chatCache?.close()
  551. chatCache?.store.close()
  552. },
  553. }
  554. }
  555. export interface NodeServerRuntime {
  556. server: HttpServer
  557. close(closeConnections?: boolean): Promise<void>
  558. }
  559. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  560. const { config } = runtime
  561. const server: HttpServer = config.tlsCert && config.tlsKey
  562. ? createHttpsServer({
  563. cert: readFileSync(config.tlsCert),
  564. key: readFileSync(config.tlsKey),
  565. }, runtime.app.callback())
  566. : createHttpServer(runtime.app.callback())
  567. const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
  568. if (enabled) {
  569. runtime.chatPushJobs.start()
  570. } else {
  571. runtime.chatPushJobs.stop()
  572. }
  573. }
  574. server.once('listening',()=>{const address=server.address();if(address&&typeof address!=='string')void runtime.localVm.start(`${config.tlsCert?'https':'http'}://127.0.0.1:${address.port}`).catch(error=>console.error('本地虚拟机服务启动失败',error.message))})
  575. runtime.workspaceRuntime.start()
  576. runtime.workspaceRoutines.start()
  577. runtime.grokAuth.start()
  578. synchronizePushObservers()
  579. const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
  580. const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
  581. return {
  582. server,
  583. close: async (closeConnections = false) => {
  584. await runtime.localVm.close()
  585. removePushConfigurationListener()
  586. removeWebSockets()
  587. runtime.realtime.close()
  588. if (closeConnections) server.closeAllConnections()
  589. await new Promise<void>((resolve, reject) => {
  590. if (!server.listening) {
  591. resolve()
  592. return
  593. }
  594. server.close((error) => error ? reject(error) : resolve())
  595. })
  596. runtime.close()
  597. },
  598. }
  599. }