@memberjunction/server 6.2.0-edge.3 → 6.2.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/agentSessions/BridgeHostInstance.d.ts +25 -0
  3. package/dist/agentSessions/BridgeHostInstance.d.ts.map +1 -0
  4. package/dist/agentSessions/BridgeHostInstance.js +30 -0
  5. package/dist/agentSessions/BridgeHostInstance.js.map +1 -0
  6. package/dist/agentSessions/HostInstance.d.ts +75 -11
  7. package/dist/agentSessions/HostInstance.d.ts.map +1 -1
  8. package/dist/agentSessions/HostInstance.js +99 -28
  9. package/dist/agentSessions/HostInstance.js.map +1 -1
  10. package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -1
  11. package/dist/agentSessions/ReturningVisitorRecap.js +7 -3
  12. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -1
  13. package/dist/agentSessions/SessionJanitor.d.ts +173 -25
  14. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  15. package/dist/agentSessions/SessionJanitor.js +452 -54
  16. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  17. package/dist/agentSessions/SessionManager.d.ts +43 -2
  18. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  19. package/dist/agentSessions/SessionManager.js +99 -8
  20. package/dist/agentSessions/SessionManager.js.map +1 -1
  21. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  22. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  23. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  24. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  25. package/dist/agentSessions/index.d.ts +2 -0
  26. package/dist/agentSessions/index.d.ts.map +1 -1
  27. package/dist/agentSessions/index.js +10 -3
  28. package/dist/agentSessions/index.js.map +1 -1
  29. package/dist/auth/actionAuthorization.d.ts +40 -0
  30. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  31. package/dist/auth/actionAuthorization.js +94 -0
  32. package/dist/auth/actionAuthorization.js.map +1 -0
  33. package/dist/auth/index.d.ts.map +1 -1
  34. package/dist/auth/index.js +3 -10
  35. package/dist/auth/index.js.map +1 -1
  36. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  37. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  38. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  39. package/dist/auth/newUsers.d.ts +10 -0
  40. package/dist/auth/newUsers.d.ts.map +1 -1
  41. package/dist/auth/newUsers.js +80 -11
  42. package/dist/auth/newUsers.js.map +1 -1
  43. package/dist/config.d.ts +2378 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/config.js +123 -0
  46. package/dist/config.js.map +1 -1
  47. package/dist/context.d.ts +8 -0
  48. package/dist/context.d.ts.map +1 -1
  49. package/dist/context.js +14 -11
  50. package/dist/context.js.map +1 -1
  51. package/dist/generated/graphql-schemas/__mj.d.ts +135 -0
  52. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  53. package/dist/generated/graphql-schemas/__mj.js +706 -0
  54. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  55. package/dist/generic/OrphanedConversationDetailReconciler.js +3 -3
  56. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  57. package/dist/generic/ResolverBase.d.ts +20 -4
  58. package/dist/generic/ResolverBase.d.ts.map +1 -1
  59. package/dist/generic/ResolverBase.js +39 -10
  60. package/dist/generic/ResolverBase.js.map +1 -1
  61. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  62. package/dist/generic/RunViewResolver.js +4 -0
  63. package/dist/generic/RunViewResolver.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +120 -131
  67. package/dist/index.js.map +1 -1
  68. package/dist/isolatedProvider.d.ts +20 -0
  69. package/dist/isolatedProvider.d.ts.map +1 -0
  70. package/dist/isolatedProvider.js +22 -0
  71. package/dist/isolatedProvider.js.map +1 -0
  72. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +44 -4
  73. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  74. package/dist/realtimeProxy/RealtimeProxyServer.js +121 -16
  75. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  76. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts +171 -0
  77. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts.map +1 -0
  78. package/dist/realtimeProxy/RealtimeRelayTunnel.js +532 -0
  79. package/dist/realtimeProxy/RealtimeRelayTunnel.js.map +1 -0
  80. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts +148 -0
  81. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts.map +1 -0
  82. package/dist/realtimeSessions/RealtimeSessionEventService.js +191 -0
  83. package/dist/realtimeSessions/RealtimeSessionEventService.js.map +1 -0
  84. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts +66 -0
  85. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -0
  86. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +138 -0
  87. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -0
  88. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts +38 -0
  89. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -0
  90. package/dist/realtimeSessions/RealtimeVerifyRouter.js +98 -0
  91. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -0
  92. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts +61 -0
  93. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -0
  94. package/dist/realtimeSessions/SessionVerificationPortsBase.js +149 -0
  95. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -0
  96. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts +57 -0
  97. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts.map +1 -0
  98. package/dist/realtimeSessions/SlidingWindowRateLimiter.js +69 -0
  99. package/dist/realtimeSessions/SlidingWindowRateLimiter.js.map +1 -0
  100. package/dist/realtimeSessions/index.d.ts +17 -0
  101. package/dist/realtimeSessions/index.d.ts.map +1 -0
  102. package/dist/realtimeSessions/index.js +17 -0
  103. package/dist/realtimeSessions/index.js.map +1 -0
  104. package/dist/realtimeSessions/sessionAccess.d.ts +86 -0
  105. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -0
  106. package/dist/realtimeSessions/sessionAccess.js +90 -0
  107. package/dist/realtimeSessions/sessionAccess.js.map +1 -0
  108. package/dist/realtimeSessions/sessionConfigStamps.d.ts +70 -0
  109. package/dist/realtimeSessions/sessionConfigStamps.d.ts.map +1 -0
  110. package/dist/realtimeSessions/sessionConfigStamps.js +93 -0
  111. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -0
  112. package/dist/realtimeSessions/subscriptionAuthorization.d.ts +65 -0
  113. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -0
  114. package/dist/realtimeSessions/subscriptionAuthorization.js +71 -0
  115. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -0
  116. package/dist/realtimeSessions/verificationCore.d.ts +243 -0
  117. package/dist/realtimeSessions/verificationCore.d.ts.map +1 -0
  118. package/dist/realtimeSessions/verificationCore.js +356 -0
  119. package/dist/realtimeSessions/verificationCore.js.map +1 -0
  120. package/dist/realtimeSessions/verificationPages.d.ts +66 -0
  121. package/dist/realtimeSessions/verificationPages.d.ts.map +1 -0
  122. package/dist/realtimeSessions/verificationPages.js +110 -0
  123. package/dist/realtimeSessions/verificationPages.js.map +1 -0
  124. package/dist/realtimeSessions/verificationPolicy.d.ts +147 -0
  125. package/dist/realtimeSessions/verificationPolicy.d.ts.map +1 -0
  126. package/dist/realtimeSessions/verificationPolicy.js +249 -0
  127. package/dist/realtimeSessions/verificationPolicy.js.map +1 -0
  128. package/dist/realtimeSessions/verificationWorkflow.d.ts +263 -0
  129. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -0
  130. package/dist/realtimeSessions/verificationWorkflow.js +435 -0
  131. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -0
  132. package/dist/realtimeWidget/widgetGuestElevation.d.ts +20 -2
  133. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  134. package/dist/realtimeWidget/widgetGuestElevation.js +31 -2
  135. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  136. package/dist/resolvers/ActionResolver.d.ts +31 -5
  137. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  138. package/dist/resolvers/ActionResolver.js +75 -14
  139. package/dist/resolvers/ActionResolver.js.map +1 -1
  140. package/dist/resolvers/AdhocQueryResolver.d.ts +14 -1
  141. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  142. package/dist/resolvers/AdhocQueryResolver.js +21 -5
  143. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  144. package/dist/resolvers/CacheStatsResolver.d.ts +27 -0
  145. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -1
  146. package/dist/resolvers/CacheStatsResolver.js +113 -2
  147. package/dist/resolvers/CacheStatsResolver.js.map +1 -1
  148. package/dist/resolvers/ClientToolRequestResolver.d.ts +4 -2
  149. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -1
  150. package/dist/resolvers/ClientToolRequestResolver.js +28 -10
  151. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -1
  152. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +12 -1
  153. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  154. package/dist/resolvers/FetchEntityVectorsResolver.js +83 -2
  155. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  156. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -1
  157. package/dist/resolvers/HumanHandoffResolver.js +7 -1
  158. package/dist/resolvers/HumanHandoffResolver.js.map +1 -1
  159. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  160. package/dist/resolvers/MCPResolver.js +9 -4
  161. package/dist/resolvers/MCPResolver.js.map +1 -1
  162. package/dist/resolvers/RealtimeBridgeResolver.d.ts +54 -2
  163. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  164. package/dist/resolvers/RealtimeBridgeResolver.js +155 -10
  165. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  166. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +155 -25
  167. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  168. package/dist/resolvers/RealtimeClientSessionResolver.js +394 -90
  169. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  170. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts +61 -0
  171. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts.map +1 -0
  172. package/dist/resolvers/RealtimeSessionEventsResolver.js +130 -0
  173. package/dist/resolvers/RealtimeSessionEventsResolver.js.map +1 -0
  174. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts +85 -0
  175. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts.map +1 -0
  176. package/dist/resolvers/RealtimeSessionVerificationResolver.js +188 -0
  177. package/dist/resolvers/RealtimeSessionVerificationResolver.js.map +1 -0
  178. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  179. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  180. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  181. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  182. package/dist/resolvers/RunAIAgentResolver.d.ts +10 -0
  183. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  184. package/dist/resolvers/RunAIAgentResolver.js +68 -11
  185. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  186. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  187. package/dist/resolvers/RunTemplateResolver.js +12 -11
  188. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  189. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  190. package/dist/resolvers/SyncDataResolver.js +6 -2
  191. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  192. package/dist/resolvers/SyncRolesUsersResolver.d.ts +2 -0
  193. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  194. package/dist/resolvers/SyncRolesUsersResolver.js +23 -6
  195. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  196. package/dist/resolvers/UserAvatarResolver.d.ts +16 -0
  197. package/dist/resolvers/UserAvatarResolver.d.ts.map +1 -0
  198. package/dist/resolvers/UserAvatarResolver.js +124 -0
  199. package/dist/resolvers/UserAvatarResolver.js.map +1 -0
  200. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  201. package/dist/resolvers/UserResolver.js +2 -17
  202. package/dist/resolvers/UserResolver.js.map +1 -1
  203. package/dist/resolvers/agentVisionConsentAudit.d.ts +37 -0
  204. package/dist/resolvers/agentVisionConsentAudit.d.ts.map +1 -0
  205. package/dist/resolvers/agentVisionConsentAudit.js +63 -0
  206. package/dist/resolvers/agentVisionConsentAudit.js.map +1 -0
  207. package/dist/resolvers/avatarInputValidation.d.ts +40 -0
  208. package/dist/resolvers/avatarInputValidation.d.ts.map +1 -0
  209. package/dist/resolvers/avatarInputValidation.js +97 -0
  210. package/dist/resolvers/avatarInputValidation.js.map +1 -0
  211. package/dist/resolvers/currentUserRoles.d.ts +29 -0
  212. package/dist/resolvers/currentUserRoles.d.ts.map +1 -1
  213. package/dist/resolvers/currentUserRoles.js +30 -0
  214. package/dist/resolvers/currentUserRoles.js.map +1 -1
  215. package/dist/resolvers/meetings/meetingMutations.d.ts +6 -0
  216. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -1
  217. package/dist/resolvers/meetings/meetingMutations.js +21 -0
  218. package/dist/resolvers/meetings/meetingMutations.js.map +1 -1
  219. package/dist/services/TaskGraphContinuationDeliverer.d.ts +4 -3
  220. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  221. package/dist/services/TaskGraphContinuationDeliverer.js +13 -7
  222. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  223. package/dist/sharedCache.d.ts +105 -0
  224. package/dist/sharedCache.d.ts.map +1 -0
  225. package/dist/sharedCache.js +276 -0
  226. package/dist/sharedCache.js.map +1 -0
  227. package/dist/types.d.ts +6 -0
  228. package/dist/types.d.ts.map +1 -1
  229. package/dist/types.js.map +1 -1
  230. package/package.json +97 -95
  231. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  232. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  233. package/src/__tests__/BridgeHostInstance.test.ts +232 -0
  234. package/src/__tests__/ClientToolRequestResolver.test.ts +116 -0
  235. package/src/__tests__/FetchEntityVectorsResolver.test.ts +285 -0
  236. package/src/__tests__/HostInstance.test.ts +84 -0
  237. package/src/__tests__/HumanHandoffResolver.test.ts +17 -0
  238. package/src/__tests__/MeetingResolver.test.ts +75 -1
  239. package/src/__tests__/RealtimeBridgeResolver.test.ts +415 -6
  240. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  241. package/src/__tests__/RealtimeClientSessionResolver.test.ts +682 -58
  242. package/src/__tests__/RealtimeProxyServer.test.ts +16 -1
  243. package/src/__tests__/RealtimeRelay.test.ts +876 -0
  244. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  245. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  246. package/src/__tests__/ResolverBase.clientOldValues.test.ts +6 -3
  247. package/src/__tests__/ReturningVisitorRecap.test.ts +115 -0
  248. package/src/__tests__/RunAIAgentResolver.branch.test.ts +169 -0
  249. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +4 -4
  250. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +166 -0
  251. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  252. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +63 -0
  253. package/src/__tests__/SessionJanitor.test.ts +845 -18
  254. package/src/__tests__/SessionManager.test.ts +213 -17
  255. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  256. package/src/__tests__/SyncRolesUsersResolver.userRoles.test.ts +97 -0
  257. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +50 -3
  258. package/src/__tests__/UserAvatarResolver.test.ts +255 -0
  259. package/src/__tests__/UserResolver.currentUser.test.ts +16 -1
  260. package/src/__tests__/actionAuthorization.test.ts +130 -0
  261. package/src/__tests__/agentVisionConsentAudit.test.ts +118 -0
  262. package/src/__tests__/avatarInputValidation.test.ts +160 -0
  263. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  264. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  265. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  266. package/src/__tests__/newUsers.template.test.ts +151 -0
  267. package/src/__tests__/newUsers.test.ts +74 -5
  268. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +17 -2
  269. package/src/__tests__/realtimeSessions/RealtimeSessionEventService.test.ts +201 -0
  270. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +215 -0
  271. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationResolver.test.ts +102 -0
  272. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationService.test.ts +349 -0
  273. package/src/__tests__/realtimeSessions/RealtimeVerifyRouter.test.ts +131 -0
  274. package/src/__tests__/realtimeSessions/SlidingWindowRateLimiter.test.ts +56 -0
  275. package/src/__tests__/realtimeSessions/helpers.ts +4 -0
  276. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +129 -0
  277. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +124 -0
  278. package/src/__tests__/realtimeSessions/verificationCore.test.ts +355 -0
  279. package/src/__tests__/realtimeSessions/verificationPages.test.ts +84 -0
  280. package/src/__tests__/realtimeSessions/verificationPolicy.test.ts +226 -0
  281. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +633 -0
  282. package/src/__tests__/resolverBase.fls.test.ts +87 -4
  283. package/src/__tests__/serverExtensionReservedRoots.test.ts +2 -0
  284. package/src/__tests__/sharedCache.test.ts +310 -0
  285. package/src/__tests__/widgetGuestElevation.test.ts +44 -0
  286. package/src/agentSessions/BridgeHostInstance.ts +32 -0
  287. package/src/agentSessions/HostInstance.ts +124 -28
  288. package/src/agentSessions/ReturningVisitorRecap.ts +7 -3
  289. package/src/agentSessions/SessionJanitor.ts +543 -57
  290. package/src/agentSessions/SessionManager.ts +125 -25
  291. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  292. package/src/agentSessions/index.ts +10 -3
  293. package/src/auth/actionAuthorization.ts +116 -0
  294. package/src/auth/index.ts +3 -8
  295. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  296. package/src/auth/newUsers.ts +99 -11
  297. package/src/config.ts +125 -0
  298. package/src/context.ts +15 -10
  299. package/src/generated/graphql-schemas/__mj.ts +496 -1
  300. package/src/generic/OrphanedConversationDetailReconciler.ts +3 -3
  301. package/src/generic/ResolverBase.ts +39 -9
  302. package/src/generic/RunViewResolver.ts +4 -0
  303. package/src/index.ts +132 -143
  304. package/src/isolatedProvider.ts +22 -0
  305. package/src/realtimeProxy/RealtimeProxyServer.ts +148 -16
  306. package/src/realtimeProxy/RealtimeRelayTunnel.ts +645 -0
  307. package/src/realtimeSessions/README.md +179 -0
  308. package/src/realtimeSessions/RealtimeSessionEventService.ts +274 -0
  309. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +167 -0
  310. package/src/realtimeSessions/RealtimeVerifyRouter.ts +108 -0
  311. package/src/realtimeSessions/SessionVerificationPortsBase.ts +189 -0
  312. package/src/realtimeSessions/SlidingWindowRateLimiter.ts +92 -0
  313. package/src/realtimeSessions/index.ts +16 -0
  314. package/src/realtimeSessions/sessionAccess.ts +141 -0
  315. package/src/realtimeSessions/sessionConfigStamps.ts +123 -0
  316. package/src/realtimeSessions/subscriptionAuthorization.ts +116 -0
  317. package/src/realtimeSessions/verificationCore.ts +502 -0
  318. package/src/realtimeSessions/verificationPages.ts +149 -0
  319. package/src/realtimeSessions/verificationPolicy.ts +321 -0
  320. package/src/realtimeSessions/verificationWorkflow.ts +702 -0
  321. package/src/realtimeWidget/widgetGuestElevation.ts +34 -2
  322. package/src/resolvers/ActionResolver.ts +84 -21
  323. package/src/resolvers/AdhocQueryResolver.ts +24 -6
  324. package/src/resolvers/CacheStatsResolver.ts +89 -2
  325. package/src/resolvers/ClientToolRequestResolver.ts +25 -7
  326. package/src/resolvers/FetchEntityVectorsResolver.ts +97 -2
  327. package/src/resolvers/HumanHandoffResolver.ts +8 -0
  328. package/src/resolvers/MCPResolver.ts +8 -5
  329. package/src/resolvers/RealtimeBridgeResolver.ts +163 -11
  330. package/src/resolvers/RealtimeClientSessionResolver.ts +501 -96
  331. package/src/resolvers/RealtimeSessionEventsResolver.ts +133 -0
  332. package/src/resolvers/RealtimeSessionVerificationResolver.ts +174 -0
  333. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  334. package/src/resolvers/RunAIAgentResolver.ts +78 -11
  335. package/src/resolvers/RunTemplateResolver.ts +13 -12
  336. package/src/resolvers/SyncDataResolver.ts +6 -2
  337. package/src/resolvers/SyncRolesUsersResolver.ts +24 -6
  338. package/src/resolvers/UserAvatarResolver.ts +111 -0
  339. package/src/resolvers/UserResolver.ts +2 -16
  340. package/src/resolvers/agentVisionConsentAudit.ts +82 -0
  341. package/src/resolvers/avatarInputValidation.ts +123 -0
  342. package/src/resolvers/currentUserRoles.ts +32 -1
  343. package/src/resolvers/meetings/meetingMutations.ts +21 -0
  344. package/src/services/TaskGraphContinuationDeliverer.ts +13 -7
  345. package/src/sharedCache.ts +302 -0
  346. package/src/types.ts +7 -0
@@ -11,9 +11,9 @@
11
11
  * and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
12
12
  *
13
13
  * 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
14
- * session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
15
- * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
16
- * reading the target agent id from the session (NOT the client) so the browser can't swap targets.
14
+ * session (storing the target agent id on it), mint the ephemeral config.
15
+ * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
16
+ * for the target agent stored on the session, re-authorized for the caller on every call.
17
17
  * 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
18
18
  * `Conversation Detail`.
19
19
  *
@@ -21,12 +21,19 @@
21
21
  * - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
22
22
  * orchestration agent, so the meaningful gate is the agent doing the real work).
23
23
  * - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
24
+ * - **Session `Config`**: the owner can edit it through the generic entity API, so nothing in it is
25
+ * trusted. Execute re-checks `CanRun` on its target, direct actions never come from it, and its
26
+ * run ids are used only when {@link SessionRunIDVerifier} finds them to be records of the session.
27
+ * - **Scope-limited callers** (anonymous and resource-scoped magic-link sessions, including public
28
+ * web-widget guests): no direct actions. Their scope exists only as row-level filters, which an
29
+ * action can read around, and a scoped anonymous caller's relay runs as the system user. They
30
+ * reach the target agent only through `invoke-target-agent`.
24
31
  *
25
32
  * @module @memberjunction/server
26
33
  */
27
34
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
35
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
36
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogErrorEx, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
37
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
38
  import {
32
39
  MJAIAgentEntity,
@@ -36,9 +43,10 @@ import {
36
43
  MJArtifactVersionEntity,
37
44
  MJConversationDetailArtifactEntity,
38
45
  MJConversationDetailEntity,
46
+ ConversationEngine,
39
47
  } from '@memberjunction/core-entities';
40
48
  import { AIEngine } from '@memberjunction/aiengine';
41
- import { AIAgentPermissionHelper, AIEngineBase } from '@memberjunction/ai-engine-base';
49
+ import { AIAgentPermissionHelper, AIEngineBase, HasRealtimeUsage, ParseRealtimeUsageRecord, type RealtimeUsageRecord } from '@memberjunction/ai-engine-base';
42
50
  import {
43
51
  RealtimeClientSessionService,
44
52
  DelegatedRunArtifact,
@@ -50,6 +58,7 @@ import {
50
58
  ResolveEffectiveRealtimeConfig,
51
59
  ResolveRealtimeCoAgentID,
52
60
  FilterAllowedAgentsByCanRun,
61
+ INVOKE_TARGET_AGENT_TOOL_NAME,
53
62
  RealtimeAllowedAgent,
54
63
  RealtimeDirectActionsConfig,
55
64
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -58,13 +67,26 @@ import {
58
67
  writeRealtimeRecordingSegment,
59
68
  deleteRealtimeRecordingSegments,
60
69
  } from '@memberjunction/ai-agents';
61
- import { AgentExecutionProgressCallback, AppContextSnapshot } from '@memberjunction/ai-core-plus';
70
+ import {
71
+ AgentExecutionProgressCallback,
72
+ AppContextSnapshot,
73
+ IsValidRealtimeToolDefinition,
74
+ NormalizeChannelKey,
75
+ ParseRealtimeChannelCandidates,
76
+ type RealtimeChannelCandidate,
77
+ type RealtimeSessionClientPolicy
78
+ } from '@memberjunction/ai-core-plus';
62
79
  import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
63
80
  import { ResolverBase } from '../generic/ResolverBase.js';
64
81
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
65
82
  import { GetReadWriteProvider } from '../util.js';
66
83
  import { SessionManager } from '../agentSessions/index.js';
67
- import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
84
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
85
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
86
+ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser, ResolveRecordingStoreUser } from '../realtimeWidget/widgetGuestElevation.js';
87
+ import { RunWithTrustedSessionConfigWrites } from '@memberjunction/core-entities-server';
88
+ import { BuildMintConfigStamps, LoadInheritedVerifiedIdentity } from '../realtimeSessions/sessionConfigStamps.js';
89
+ import { ParseConfigObject } from '../realtimeSessions/verificationCore.js';
68
90
 
69
91
  /**
70
92
  * Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
@@ -73,6 +95,12 @@ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../
73
95
  */
74
96
  const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
75
97
 
98
+ /**
99
+ * The longest `usageDetailsJson` a usage relay may carry. Every field of both detail blocks fits in a few hundred
100
+ * characters, so anything longer is not a relay the browser runtime sends.
101
+ */
102
+ const MAX_USAGE_DETAILS_JSON_LENGTH = 4096;
103
+
76
104
  /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
77
105
  const SESSION_ENTITY = 'MJ: AI Agent Sessions';
78
106
  const CO_AGENT_ENTITY = 'MJ: AI Agent Co Agents';
@@ -100,7 +128,8 @@ const WHITEBOARD_ARTIFACT_TYPE_NAME = 'Whiteboard';
100
128
  */
101
129
  const MAX_CLIENT_TOOLS = 64;
102
130
  /** Maximum accepted size (chars) of the serialized client tool declarations. Raised in step with
103
- * {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large. */
131
+ * {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large.
132
+ * Also the largest serialized app-context snapshot stored on the session config. */
104
133
  const MAX_CLIENT_TOOLS_JSON_CHARS = 256_000;
105
134
  /** Maximum accepted size (chars) of a persisted channel state blob. */
106
135
  const MAX_CHANNEL_STATE_CHARS = 2_000_000;
@@ -118,9 +147,9 @@ const MAX_CONVERSATION_HISTORY_TURNS = 30;
118
147
  const MAX_CONVERSATION_HISTORY_CHARS = 8_000;
119
148
 
120
149
  /**
121
- * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
122
- * The target agent id is stored here at start and read back on every relay — the browser never
123
- * re-supplies it, so it cannot swap targets mid-session.
150
+ * Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
151
+ * it, but the session owner can also edit it through the generic entity API, so relays re-check
152
+ * what they read from it (see the authorization model above).
124
153
  */
125
154
  interface RealtimeSessionConfig {
126
155
  /** The top-level target agent the co-agent voices on behalf of. */
@@ -157,6 +186,12 @@ interface RealtimeSessionConfig {
157
186
  * for observability / continuity; absent when the session carries no app context.
158
187
  */
159
188
  applicationID?: string;
189
+ /**
190
+ * The app-context snapshot the browser sent at session start. Passed into each delegated
191
+ * target-agent run so its prompt sees the surface context and tools. Absent when the start carried
192
+ * none, or when its JSON is longer than {@link MAX_CLIENT_TOOLS_JSON_CHARS}.
193
+ */
194
+ appContext?: AppContextSnapshot;
160
195
  /**
161
196
  * The session's effective allowed delegation targets (union from the config cascade, incl. the app's
162
197
  * `RelevantAgents`). Persisted at start so each relayed `invoke-target-agent` call can validate a
@@ -175,6 +210,24 @@ interface RealtimeSessionConfig {
175
210
  * agent default kit is used (the common case).
176
211
  */
177
212
  mediaCollectionID?: string;
213
+ /**
214
+ * The fork (`MJ: Conversation Branches.ID`) the session was started on, fixed at start; absent means
215
+ * Main. Every transcript, direct-action and anchor row the session writes carries it.
216
+ */
217
+ conversationBranchID?: string;
218
+ /**
219
+ * Server-authoritative identity-verification state (policy snapshot taken at mint, pending/verified
220
+ * state written by `RealtimeSessionVerificationService`). Protected from owner writes by
221
+ * `MJAIAgentSessionEntityServer`; opaque to this resolver beyond preserving it across config rewrites.
222
+ */
223
+ identityVerification?: Record<string, unknown>;
224
+ /**
225
+ * The keys of the interactive channels the session's resolved scope put IN the session (registry
226
+ * kill switch, agent and app configuration applied). Persisted at start so a later server-side
227
+ * call naming a channel — a state or artifact save — can check it is actually in scope. Absent
228
+ * (an unscoped session: a client that reported no channel candidates) ⇒ no channel check.
229
+ */
230
+ channels?: string[];
178
231
  }
179
232
 
180
233
  /**
@@ -199,14 +252,32 @@ export class StartRealtimeClientSessionResult {
199
252
  @Field(() => String)
200
253
  Model: string;
201
254
 
202
- /** The short-lived client secret the browser presents to the provider to authenticate. */
255
+ /**
256
+ * The short-lived client secret the browser presents to the provider to authenticate. Empty on a relay session
257
+ * ({@link StartRealtimeClientSessionResult.Transport} `relay`), whose {@link StartRealtimeClientSessionResult.RelayUrl}
258
+ * carries the relay's ticket instead.
259
+ */
203
260
  @Field(() => String)
204
261
  EphemeralToken: string;
205
262
 
206
- /** ISO-8601 timestamp at which {@link StartRealtimeClientSessionResult.EphemeralToken} expires. */
263
+ /** ISO-8601 timestamp at which {@link StartRealtimeClientSessionResult.EphemeralToken} (or a relay session's ticket) expires. */
207
264
  @Field(() => String)
208
265
  ExpiresAt: string;
209
266
 
267
+ /**
268
+ * How the browser reaches the provider: `direct` (with the ephemeral token) or `relay` (through MJAPI's realtime
269
+ * relay at {@link StartRealtimeClientSessionResult.RelayUrl}). Null means direct.
270
+ */
271
+ @Field(() => String, { nullable: true })
272
+ Transport?: string;
273
+
274
+ /**
275
+ * Where a relay session's browser connects (MJAPI's realtime relay). Null on a direct session. The URL carries the
276
+ * session's ticket, so it is a credential and is never logged.
277
+ */
278
+ @Field(() => String, { nullable: true })
279
+ RelayUrl?: string;
280
+
210
281
  /** JSON string of the provider-native session config the browser applies verbatim. */
211
282
  @Field(() => String)
212
283
  SessionConfigJson: string;
@@ -263,6 +334,25 @@ export class StartRealtimeClientSessionResult {
263
334
  */
264
335
  @Field(() => String, { nullable: true })
265
336
  EffectiveConfigJson?: string;
337
+
338
+ /**
339
+ * JSON of the server's resolved channel policy for this session (`RealtimeSessionClientPolicy`):
340
+ * which of the channel candidates the browser reported are in the session (registry kill switch,
341
+ * agent and app `channels` configuration applied), with each one's resolved display and config,
342
+ * plus the app/static client-tool tiers. Null when the caller reported no channel candidates (a
343
+ * client that predates channel scoping) — the browser then resolves the scope locally.
344
+ */
345
+ @Field(() => String, { nullable: true })
346
+ ClientPolicyJson?: string;
347
+
348
+ /**
349
+ * JSON of the session's live-avatar status (`RealtimeAvatarStatus` from `@memberjunction/ai`): whether the voiced
350
+ * agent asked for an avatar, whether the model renders it, and why not. The browser reads it after connecting and
351
+ * tells the user once when the call is audio only. Null when the agent asked for no avatar (its video setting is
352
+ * off).
353
+ */
354
+ @Field(() => String, { nullable: true })
355
+ AvatarStatusJson?: string;
266
356
  }
267
357
 
268
358
  /**
@@ -356,6 +446,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
356
446
  // state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
357
447
  private readonly clientSessionService = new RealtimeClientSessionService();
358
448
  private readonly sessionManager = new SessionManager(this.clientSessionService);
449
+ private readonly runIDVerifier = new SessionRunIDVerifier();
359
450
 
360
451
  /**
361
452
  * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
@@ -392,7 +483,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
392
483
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
393
484
  * see {@link ResolveRealtimeCoAgentID} for the full contract.
394
485
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
395
- * config server-side — this is the authoritative target for all later relays.
486
+ * config — the target later relays use, re-authorized for the caller on each relay.
396
487
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
397
488
  * On failure the just-created session is closed so no half-open session leaks.
398
489
  *
@@ -418,6 +509,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
418
509
  * `TypeConfiguration` ← this). **Authorization-gated**: requires the
419
510
  * `Realtime: Advanced Session Controls` authorization — unauthorized callers receive a
420
511
  * structured rejection (never a silent ignore). Must be a JSON object.
512
+ * @param showsAgentVideo `false` from an app that shows no agent video (no channel of it shows it: the embeddable
513
+ * widgets, the mobile app): the session asks the model for no avatar, and `AvatarStatusJson` says `host`. Absent or
514
+ * `true`: the app may show it. It only ever turns video off, so it needs no authorization.
515
+ * @param conversationBranchId Optional fork (`MJ: Conversation Branches.ID`) the session starts on;
516
+ * omitted is Main. It must be a fork of `conversationId`, else the start fails and no session is
517
+ * created. The session's history is read from that fork's path and its transcript is written there.
421
518
  *
422
519
  * @returns The ephemeral config + session linkage the browser needs to open its socket.
423
520
  */
@@ -436,6 +533,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
436
533
  @Arg('mediaCollectionId', () => String, { nullable: true }) mediaCollectionId?: string,
437
534
  @Arg('applicationId', () => String, { nullable: true }) applicationId?: string,
438
535
  @Arg('appContextJson', () => String, { nullable: true }) appContextJson?: string,
536
+ @Arg('channelCandidatesJson', () => String, { nullable: true }) channelCandidatesJson?: string,
537
+ @Arg('showsAgentVideo', () => Boolean, { nullable: true }) showsAgentVideo?: boolean,
538
+ @Arg('conversationBranchId', () => String, { nullable: true }) conversationBranchId?: string,
439
539
  ): Promise<StartRealtimeClientSessionResult> {
440
540
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
441
541
 
@@ -473,22 +573,32 @@ export class RealtimeClientSessionResolver extends ResolverBase {
473
573
  );
474
574
  }
475
575
  }
476
- const session = await this.sessionManager.CreateSession(
477
- {
478
- agentID: coAgentID,
479
- userID: contextUser.ID,
480
- conversationID: conversationId,
481
- lastSessionID: lastSessionId,
482
- config: JSON.stringify(config),
483
- },
484
- contextUser,
485
- provider,
576
+ const sessionBranchId = await this.resolveSessionBranch(conversationId, conversationBranchId, contextUser, provider);
577
+ if (sessionBranchId) {
578
+ config.conversationBranchID = sessionBranchId;
579
+ }
580
+ // The initial Config carries `maxSessionDeadlineIso` for capped sessions — a server-authoritative key
581
+ // that `MJAIAgentSessionEntityServer` refuses to let the session OWNER write, so this write (and only
582
+ // this one, built entirely from server-decided values) runs in the trusted scope.
583
+ const session = await RunWithTrustedSessionConfigWrites(() =>
584
+ this.sessionManager.CreateSession(
585
+ {
586
+ agentID: coAgentID,
587
+ userID: contextUser.ID,
588
+ conversationID: conversationId,
589
+ lastSessionID: lastSessionId,
590
+ config: JSON.stringify(config),
591
+ },
592
+ contextUser,
593
+ provider,
594
+ ),
486
595
  );
487
596
 
488
597
  // Best-effort: stamp recording-start metadata when the browser captured (with consent) at start.
489
598
  await this.stampRecordingStart(session, recordingConsent, recordingStartedAt);
490
599
 
491
600
  const clientTools = this.parseClientTools(clientToolsJson);
601
+ const channelCandidates = this.parseChannelCandidates(channelCandidatesJson);
492
602
  // Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
493
603
  // checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
494
604
  // Strictly tolerant — any problem yields no hydration, never a failed start.
@@ -498,24 +608,103 @@ export class RealtimeClientSessionResolver extends ResolverBase {
498
608
  // framed as the resumed transcript, and injecting the same turns twice puts two accounts of
499
609
  // the same exchange in one prompt.
500
610
  const conversationMessages = await this.loadConversationHistory(
501
- conversationId, prior?.LegIDs ?? [], contextUser, provider,
611
+ conversationId, prior?.LegIDs ?? [], contextUser, provider, sessionBranchId,
502
612
  );
503
613
  const result = await this.prepareClientSessionOrClose(
504
614
  session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, prior?.Text,
505
615
  configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson), conversationMessages,
616
+ channelCandidates, showsAgentVideo,
506
617
  );
618
+ await this.stampVerificationPolicy(session, result.EffectiveConfigJson, lastSessionId, contextUser, provider);
507
619
  // Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
508
620
  // board). Strictly tolerant — any problem yields a null field, never a failed start.
509
621
  result.PriorChannelStatesJson = (await this.loadPriorChannelStatesJson(lastSessionId, contextUser, provider)) ?? undefined;
510
622
  return result;
511
623
  }
512
624
 
625
+ /**
626
+ * The fork a session starts on: null (Main) when none is named; else the named fork, which must
627
+ * belong to the session's conversation. Returns the fork's stored id.
628
+ * @throws when a fork is named without a conversation, or is not a fork of it, or the fork rows cannot be read
629
+ */
630
+ private async resolveSessionBranch(
631
+ conversationId: string | undefined,
632
+ branchId: string | undefined,
633
+ contextUser: UserInfo,
634
+ provider: IMetadataProvider,
635
+ ): Promise<string | null> {
636
+ const named = branchId?.trim();
637
+ if (!named) {
638
+ return null;
639
+ }
640
+ if (!conversationId) {
641
+ throw new Error(`StartRealtimeClientSession: fork ${named} needs a conversation`);
642
+ }
643
+ try {
644
+ const scope = await ConversationEngine.LoadScope(conversationId, named, contextUser, provider);
645
+ return scope.BranchID;
646
+ } catch (error) {
647
+ throw new Error(`StartRealtimeClientSession: fork ${named} is not a readable fork of conversation ${conversationId}: ${(error as Error).message}`);
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Stamps the identity-verification policy snapshot, the configured unverified duration cap and any
653
+ * verified identity inherited from the resumed session onto the new session's `Config`.
654
+ *
655
+ * The effective config (config cascade) is only known after prepare, so this is a second, narrowly
656
+ * scoped write: it runs only when there is something to stamp. It **fails closed** — a cap that could
657
+ * not be applied would leave the session uncapped, so on a save failure the session is closed and the
658
+ * start fails rather than proceeding without its limits.
659
+ */
660
+ private async stampVerificationPolicy(
661
+ session: MJAIAgentSessionEntity,
662
+ effectiveConfigJson: string | undefined,
663
+ lastSessionId: string | undefined,
664
+ contextUser: UserInfo,
665
+ provider: IMetadataProvider,
666
+ ): Promise<void> {
667
+ const plan = BuildMintConfigStamps({
668
+ EffectiveRealtime: this.readEffectiveRealtime(effectiveConfigJson),
669
+ ExistingConfigRaw: session.Config_,
670
+ InheritedVerified: await LoadInheritedVerifiedIdentity(lastSessionId, contextUser, provider),
671
+ NowMs: Date.now(),
672
+ });
673
+ if (!plan) {
674
+ return;
675
+ }
676
+ session.Config_ = plan.NextConfigRaw;
677
+ const saved = await RunWithTrustedSessionConfigWrites(() => session.Save());
678
+ if (!saved) {
679
+ LogError(
680
+ `RealtimeClientSessionResolver.stampVerificationPolicy save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
681
+ );
682
+ await this.sessionManager.CloseSession(session.ID, contextUser, provider, 'Error');
683
+ throw new Error('Failed to apply the session limits; the realtime session was not started.');
684
+ }
685
+ }
686
+
687
+ /** The `realtime` section of the resolved effective config JSON, or undefined when absent/unparseable. */
688
+ private readEffectiveRealtime(effectiveConfigJson: string | undefined): unknown {
689
+ if (!effectiveConfigJson) {
690
+ return undefined;
691
+ }
692
+ try {
693
+ return (JSON.parse(effectiveConfigJson) as { realtime?: unknown }).realtime;
694
+ } catch (error) {
695
+ LogError(`RealtimeClientSessionResolver: effective config JSON was unparseable while stamping verification policy: ${(error as Error).message}`);
696
+ return undefined;
697
+ }
698
+ }
699
+
513
700
  /**
514
701
  * Execute a single tool call the browser relayed from its provider socket and return the
515
702
  * serialized result for the browser to relay back to the model.
516
703
  *
517
- * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
518
- * from the client. Heartbeats the session on success.
704
+ * Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
705
+ * checked for the caller on every call, because the owner can edit the config. A direct-action
706
+ * call from a scope-limited caller is refused before any elevation. Heartbeats the session on
707
+ * success.
519
708
  *
520
709
  * @returns The serialized tool result JSON.
521
710
  */
@@ -531,13 +720,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
531
720
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
532
721
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
533
722
  const config = this.readSessionConfig(session);
723
+ await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
724
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME && IsScopeLimitedPrincipal(contextUser)) {
725
+ return this.refuseDirectAction(agentSessionId, toolName);
726
+ }
534
727
 
535
728
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
536
729
  // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
537
730
  // (the caller's role deliberately holds no grants on the run entities). The lead
538
- // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
539
- // union is gated just below, against the CALLER, so elevation never widens agent authority.
731
+ // targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
732
+ // just below, so elevation never widens agent authority.
540
733
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
734
+ const runIDs = await this.runIDVerifier.Verify(
735
+ session.ID,
736
+ { CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
737
+ runUser,
738
+ provider,
739
+ );
541
740
  if (runUser !== contextUser) {
542
741
  LogStatus(
543
742
  `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
@@ -555,16 +754,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
555
754
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
556
755
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
557
756
  AllowedAgents: await FilterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
558
- DirectActions: config.directActions,
757
+ // No DirectActions: the service takes them from the session's start-time projection
758
+ // or the target agent's metadata, never from the owner-editable config.
559
759
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
560
760
  // row and its context-memory scope must stay the person's, not the system user's.
561
761
  AttributionUserID: contextUser.ID,
562
762
  // Nest the delegated target-agent run under the co-agent observability run (when present).
563
- ParentRunID: config.coAgentRunID,
763
+ ParentRunID: runIDs.CoAgentRunID,
764
+ // The client-tool channel is keyed on the browser session id, so the delegated run can call
765
+ // the surface's tools; the session-start app context gives its prompt the surface context.
766
+ BrowserSessionID: userPayload.sessionId,
767
+ // The app context is a snapshot the browser sent, so a run elevated to the system user
768
+ // does not get it in its prompt.
769
+ AppContext: runUser === contextUser ? config.appContext : undefined,
564
770
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
565
771
  OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
566
772
  // Resume a previously-paused delegated run (if any) with the user's answer.
567
- ResumeRunID: config.pendingFeedbackRunID,
773
+ ResumeRunID: runIDs.PendingFeedbackRunID,
568
774
  },
569
775
  runUser,
570
776
  provider,
@@ -574,7 +780,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
574
780
  `ExecuteRealtimeSessionTool: completed relayed tool '${toolName}' (callId: ${callId}) in ${durationMs}ms`,
575
781
  );
576
782
 
577
- if (toolName !== 'invoke-target-agent') {
783
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME) {
578
784
  await this.persistDirectActionTurn(
579
785
  session,
580
786
  callId,
@@ -631,6 +837,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
631
837
  };
632
838
  }
633
839
 
840
+ /**
841
+ * The tool result for a direct-action call from a scope-limited caller (see the authorization
842
+ * model above). It has the broker's result shape, so the model hears that the tool is not
843
+ * available and can ask the target agent through `invoke-target-agent` instead.
844
+ */
845
+ private refuseDirectAction(agentSessionId: string, toolName: string): string {
846
+ LogStatus(`ExecuteRealtimeSessionTool: refused direct action '${toolName}' for a scope-limited caller on session ${agentSessionId}.`);
847
+ return JSON.stringify({
848
+ success: false,
849
+ output: `The tool '${toolName}' is not available in this session. Ask the agent to do it with ${INVOKE_TARGET_AGENT_TOOL_NAME}.`,
850
+ });
851
+ }
852
+
634
853
  /**
635
854
  * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
636
855
  * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
@@ -646,17 +865,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
646
865
  if (!previous && !pausedRunID) {
647
866
  return;
648
867
  }
649
- const next: RealtimeSessionConfig = {
650
- targetAgentID: config.targetAgentID,
651
- coAgentRunID: config.coAgentRunID,
652
- promptRunID: config.promptRunID,
653
- coAgentRunStepID: config.coAgentRunStepID,
654
- pendingFeedbackRunID: pausedRunID,
655
- // Preserve the server-authoritative voice deadline across config rewrites.
656
- maxSessionDeadlineIso: config.maxSessionDeadlineIso,
657
- applicationID: config.applicationID,
658
- allowedAgents: config.allowedAgents,
659
- };
868
+ // Start from the RAW stored config so every key survives the rewrite — including the
869
+ // server-authoritative `maxSessionDeadlineIso` and `identityVerification` (dropping either would be
870
+ // refused by `MJAIAgentSessionEntityServer`, and silently losing a verification would be worse).
871
+ const next = { ...ParseConfigObject(session.Config_), targetAgentID: config.targetAgentID, pendingFeedbackRunID: pausedRunID };
660
872
  session.Config_ = JSON.stringify(next);
661
873
  if (!(await session.Save())) {
662
874
  LogError(
@@ -701,14 +913,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
701
913
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
702
914
  // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
703
915
  // Conversation Detail above deliberately stays on the caller.
704
- const promptRunID = this.readPromptRunID(session);
916
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
917
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
705
918
  if (promptRunID) {
706
919
  await this.clientSessionService.AppendPromptRunMessage(
707
920
  promptRunID,
708
921
  this.mapTranscriptRoleToChatRole(role),
709
922
  text,
710
923
  replacesPrevious ?? false,
711
- ResolveScopedAnonymousRunUser(contextUser),
924
+ runUser,
712
925
  provider,
713
926
  );
714
927
  }
@@ -767,11 +980,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
767
980
  return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
768
981
  }
769
982
 
770
- // SCOPED-ANONYMOUS ELEVATION (issue #3371): past the ownership + consent gates, the store is
771
- // server-side plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link)
772
- // the caller's narrow relay role deliberately does not hold. Attribution flows through the
773
- // session link, so nothing here depends on the caller's identity.
774
- const runUser = ResolveScopedAnonymousRunUser(contextUser);
983
+ // ELEVATION (issue #3371, #5195): past the ownership + consent gates, the store is server-side
984
+ // plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link, the session's
985
+ // recording stamp) that neither a scoped-anonymous relay role nor the stock UI role holds.
986
+ // Attribution flows through the session link, so nothing here depends on the caller's identity.
987
+ const runUser = ResolveRecordingStoreUser(contextUser);
775
988
  try {
776
989
  const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
777
990
  if (!(await agent.Load(session.AgentID))) {
@@ -822,11 +1035,14 @@ export class RealtimeClientSessionResolver extends ResolverBase {
822
1035
 
823
1036
  /**
824
1037
  * Uploads ONE crash-recovery audio shard (~15s) for an IN-PROGRESS recording into the session's
825
- * folder (`realtime-recordings/<sessionId>/seg-NNNN.<ext>`) as a raw storage object. Durability
826
- * insurance during a live call so a browser/tab death loses at most the last window; the shards are
827
- * deleted once the canonical consolidated file lands via {@link UploadRealtimeRecording}. Ownership-
828
- * gated; consent was already established at session start. Best-effort — returns `false` (never
829
- * throws) on any problem so a failed shard never disrupts the live call.
1038
+ * folder (`realtime-recordings/<sessionId>/seg-NNNN.r<rate>.pcm` for PCM with a rate in its MIME type,
1039
+ * else the legacy `seg-NNNN.<ext>`) as a raw storage object. Durability insurance during a live call
1040
+ * so a browser/tab death loses at most the last window; the shards are deleted once the canonical
1041
+ * consolidated file lands via {@link UploadRealtimeRecording}, or by the session janitor once it has
1042
+ * recovered a recording from them when that upload never arrives. Ownership-
1043
+ * gated; consent was already established at session start. Refused (`false`) once the session has
1044
+ * its consolidated recording, since nothing would delete a shard written after that. Best-effort —
1045
+ * returns `false` (never throws) on any problem so a failed shard never disrupts the live call.
830
1046
  *
831
1047
  * @param agentSessionId The in-progress session (ownership-gated).
832
1048
  * @param segmentIndex 0-based shard index within the session.
@@ -845,6 +1061,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
845
1061
  try {
846
1062
  const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
847
1063
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1064
+ // The consolidated recording already landed and its upload deleted the shards; a shard
1065
+ // arriving now (one still on the wire at hang-up) would be an orphan nothing deletes.
1066
+ if (session.RecordingFileID) {
1067
+ LogStatus(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment: shard ${segmentIndex} for session ${agentSessionId} refused — the session already has its consolidated recording`);
1068
+ return false;
1069
+ }
848
1070
  // Scoped-anonymous elevation (issue #3371) — same rationale as UploadRealtimeRecording.
849
1071
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
850
1072
  const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
@@ -906,7 +1128,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
906
1128
  provider,
907
1129
  );
908
1130
 
909
- const promptRunID = this.readPromptRunID(session);
1131
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1132
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
910
1133
  if (!promptRunID) {
911
1134
  return false;
912
1135
  }
@@ -915,7 +1138,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
915
1138
  'assistant',
916
1139
  this.formatToolTurn(toolName, argsJson, resultJson),
917
1140
  false,
918
- ResolveScopedAnonymousRunUser(contextUser),
1141
+ runUser,
919
1142
  provider,
920
1143
  );
921
1144
  }
@@ -987,8 +1210,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
987
1210
  * (observability creation was skipped), a failed load, or a failed save all log and return
988
1211
  * `false` — usage relay must never break a live call.
989
1212
  *
1213
+ * The optional details are the per-modality usage since the last relay: input and output
1214
+ * blocks shaped like Core's `RealtimeUsageModalityDetail`, avatar video seconds included. They
1215
+ * add into the usage record on the run's `ModelSpecificResponseDetails`, which prices the avatar
1216
+ * video at finalize. Only the fields a block defines, with values that are finite numbers of at
1217
+ * least 0, are kept; a relay that carries only details (avatar seconds, no tokens) is stored.
1218
+ *
990
1219
  * @param inputTokens Input-token DELTA to add (negative/non-finite values are clamped to 0).
991
1220
  * @param outputTokens Output-token DELTA to add (negative/non-finite values are clamped to 0).
1221
+ * @param usageDetailsJson The per-modality usage since the last relay, as JSON (`{ Input, Output }`).
992
1222
  * @returns `true` when the accumulated usage was persisted; `false` on any tolerated failure.
993
1223
  */
994
1224
  @Mutation(() => Boolean)
@@ -997,25 +1227,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
997
1227
  @Arg('inputTokens', () => Int) inputTokens: number,
998
1228
  @Arg('outputTokens', () => Int) outputTokens: number,
999
1229
  @Ctx() { userPayload, providers }: AppContext,
1230
+ @Arg('usageDetailsJson', () => String, { nullable: true }) usageDetailsJson?: string,
1000
1231
  ): Promise<boolean> {
1001
1232
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1002
1233
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1003
1234
 
1004
1235
  const inputDelta = this.clampTokenDelta(inputTokens);
1005
1236
  const outputDelta = this.clampTokenDelta(outputTokens);
1006
- if (inputDelta === 0 && outputDelta === 0) {
1237
+ const details = this.readUsageDetails(usageDetailsJson);
1238
+ if (inputDelta === 0 && outputDelta === 0 && !details) {
1007
1239
  return true; // nothing to add — a no-op flush is a success, not a failure
1008
1240
  }
1009
1241
 
1010
- const promptRunID = this.readPromptRunID(session);
1242
+ // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1243
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1244
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
1011
1245
  if (!promptRunID) {
1012
1246
  return false; // no observability prompt run for this session — logged in the helper
1013
1247
  }
1014
1248
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
1015
1249
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
1016
- // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1017
1250
  return this.clientSessionService.AccumulatePromptRunUsage(
1018
- promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
1251
+ promptRunID, inputDelta, outputDelta, runUser, provider, details ?? undefined,
1019
1252
  );
1020
1253
  }
1021
1254
 
@@ -1024,6 +1257,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1024
1257
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1025
1258
  }
1026
1259
 
1260
+ /**
1261
+ * Reads the relayed per-modality usage, or `null` when there is none worth storing: absent,
1262
+ * malformed, longer than a relay ever needs (a client cannot make the server parse an
1263
+ * arbitrarily large string), or holding no amount above 0.
1264
+ */
1265
+ private readUsageDetails(usageDetailsJson: string | undefined): RealtimeUsageRecord | null {
1266
+ if (typeof usageDetailsJson !== 'string') {
1267
+ return null;
1268
+ }
1269
+ if (usageDetailsJson.length > MAX_USAGE_DETAILS_JSON_LENGTH) {
1270
+ LogError(`RelayRealtimeUsage: usage details of ${usageDetailsJson.length} characters exceed ${MAX_USAGE_DETAILS_JSON_LENGTH} — details dropped.`);
1271
+ return null;
1272
+ }
1273
+ const details = ParseRealtimeUsageRecord(usageDetailsJson);
1274
+ return HasRealtimeUsage(details) ? details : null;
1275
+ }
1276
+
1027
1277
  /**
1028
1278
  * Sanitizes client-supplied waveform peaks before they're persisted: drops anything that isn't a
1029
1279
  * finite number, clamps each to `[0, 1]`, and caps the array length so a hostile client can't bloat
@@ -1043,24 +1293,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1043
1293
 
1044
1294
  /**
1045
1295
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
1046
- * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
1047
- * config or absent id just logs and returns `null`.
1296
+ * throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
1297
+ * session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
1298
+ * missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
1299
+ *
1300
+ * @param runUser The user the prompt-run write runs as; the check runs as the same user.
1048
1301
  */
1049
- private readPromptRunID(session: MJAIAgentSessionEntity): string | null {
1050
- try {
1051
- const config = this.readSessionConfig(session);
1052
- if (config.promptRunID) {
1053
- return config.promptRunID;
1054
- }
1302
+ private async readPromptRunID(
1303
+ session: MJAIAgentSessionEntity,
1304
+ runUser: UserInfo,
1305
+ provider: IMetadataProvider,
1306
+ ): Promise<string | null> {
1307
+ const config = this.tryReadSessionConfig(session);
1308
+ if (!config) {
1309
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1310
+ return null;
1311
+ }
1312
+ if (!config.promptRunID) {
1055
1313
  LogStatus(
1056
1314
  `RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
1057
1315
  'creation was skipped or failed) — usage delta dropped.',
1058
1316
  );
1059
1317
  return null;
1060
- } catch {
1061
- LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1318
+ }
1319
+ const verified = await this.runIDVerifier.Verify(
1320
+ session.ID,
1321
+ { CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
1322
+ runUser,
1323
+ provider,
1324
+ );
1325
+ if (!verified.PromptRunID) {
1326
+ LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
1062
1327
  return null;
1063
1328
  }
1329
+ return verified.PromptRunID;
1064
1330
  }
1065
1331
 
1066
1332
  /**
@@ -1094,6 +1360,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1094
1360
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1095
1361
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1096
1362
 
1363
+ if (!this.isChannelInSessionScope(session, channelName)) {
1364
+ LogError(`SaveSessionChannelState: channel '${channelName}' is not in session ${agentSessionId}'s resolved scope — state not saved.`);
1365
+ return false;
1366
+ }
1097
1367
  if (stateJson.length > MAX_CHANNEL_STATE_CHARS) {
1098
1368
  LogError(
1099
1369
  `SaveSessionChannelState: rejected oversized state for session ${agentSessionId} / channel '${channelName}' ` +
@@ -1173,6 +1443,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1173
1443
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1174
1444
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1175
1445
 
1446
+ if (!this.isChannelInSessionScope(session, channelName)) {
1447
+ const message = `SaveSessionChannelArtifact: channel '${channelName}' is not in session ${agentSessionId}'s resolved scope — nothing saved.`;
1448
+ LogError(message);
1449
+ return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
1450
+ }
1176
1451
  if (contentJson.length > MAX_CHANNEL_STATE_CHARS) {
1177
1452
  const message =
1178
1453
  `SaveSessionChannelArtifact: rejected oversized content for session ${agentSessionId} / channel '${channelName}' ` +
@@ -1210,8 +1485,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1210
1485
  }
1211
1486
 
1212
1487
  /**
1213
- * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
1214
- * caller must be able to run the **target** agent. Denial throws (no session is created).
1488
+ * Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
1489
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
1490
+ * created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
1491
+ * relayed call, because the owner can edit the target in the session config.
1215
1492
  */
1216
1493
  private async assertCanRunTarget(
1217
1494
  targetAgentId: string,
@@ -1479,6 +1756,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1479
1756
  * resumed session remembers the previous leg(s).
1480
1757
  * @param conversationMessages Optional capped history of the TEXT conversation this session is
1481
1758
  * starting from (from {@link loadConversationHistory}) — framed as "Conversation so far".
1759
+ * @param showsAgentVideo `false` when the app shows no agent video: the session asks for no avatar (`host`).
1482
1760
  */
1483
1761
  private async prepareClientSessionOrClose(
1484
1762
  session: MJAIAgentSessionEntity,
@@ -1494,6 +1772,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1494
1772
  applicationId?: string,
1495
1773
  appContext?: AppContextSnapshot,
1496
1774
  conversationMessages?: ChatMessage[],
1775
+ channelCandidates?: RealtimeChannelCandidate[],
1776
+ showsAgentVideo?: boolean,
1497
1777
  ): Promise<StartRealtimeClientSessionResult> {
1498
1778
  const prep = await this.clientSessionService.PrepareClientSession(
1499
1779
  {
@@ -1521,6 +1801,14 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1521
1801
  // (allowed-agent union); the snapshot is injected into the system prompt at mint.
1522
1802
  ApplicationID: applicationId,
1523
1803
  AppContext: appContext,
1804
+ // Scope-limited callers get no direct actions (see the authorization model above), so
1805
+ // the model is not given tools that the relay refuses.
1806
+ ExcludeDirectActions: IsScopeLimitedPrincipal(contextUser),
1807
+ // The channels the browser could mount: scoped here (registry + cascade) and handed back
1808
+ // as the resolved policy; the declared tools are narrowed to match.
1809
+ ChannelCandidates: channelCandidates,
1810
+ // An app with no channel that shows the agent's video: no avatar is asked for (reason `host`).
1811
+ ShowsAgentVideo: showsAgentVideo ?? undefined,
1524
1812
  },
1525
1813
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1526
1814
  // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
@@ -1541,7 +1829,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1541
1829
  session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID,
1542
1830
  applicationId, prep.EffectiveConfig?.realtime?.allowedAgents,
1543
1831
  prep.EffectiveConfig?.realtime?.directActions,
1832
+ prep.ClientPolicy?.Channels.map((c) => c.Key),
1833
+ appContext,
1544
1834
  );
1835
+ this.pruneExcludedServerChannels(session.ID, prep.ClientPolicy);
1545
1836
 
1546
1837
  const cfg = prep.ClientConfig;
1547
1838
  return {
@@ -1551,20 +1842,43 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1551
1842
  Model: cfg.Model,
1552
1843
  EphemeralToken: cfg.EphemeralToken,
1553
1844
  ExpiresAt: cfg.ExpiresAt,
1845
+ Transport: cfg.Transport,
1846
+ RelayUrl: cfg.RelayUrl,
1554
1847
  SessionConfigJson: JSON.stringify(cfg.SessionConfig),
1555
1848
  ModelName: prep.ModelName,
1556
1849
  DriverClass: prep.DriverClass,
1557
1850
  NarrationInstructionsTemplate: prep.NarrationInstructionsTemplate,
1558
1851
  NarrationPaceMs: prep.NarrationPaceMs,
1559
1852
  EffectiveConfigJson: prep.EffectiveConfig ? JSON.stringify(prep.EffectiveConfig) : undefined,
1853
+ ClientPolicyJson: prep.ClientPolicy ? JSON.stringify(prep.ClientPolicy) : undefined,
1854
+ AvatarStatusJson: prep.AvatarStatus ? JSON.stringify(prep.AvatarStatus) : undefined,
1560
1855
  };
1561
1856
  }
1562
1857
 
1858
+ /**
1859
+ * Drops the server-side channel plugins of channels the resolved scope left out. Server plugins
1860
+ * start when the session row is created — before the scope is known — so an excluded channel's
1861
+ * server half would otherwise keep running. A no-op when the caller reported no candidates.
1862
+ */
1863
+ private pruneExcludedServerChannels(agentSessionID: string, policy: RealtimeSessionClientPolicy | undefined): void {
1864
+ const excluded = (policy?.ExcludedChannels ?? []).map((e) => e.Key);
1865
+ if (excluded.length === 0) {
1866
+ return;
1867
+ }
1868
+ const dropped = RealtimeChannelServerHost.Instance.PruneSessionChannels(agentSessionID, excluded);
1869
+ if (dropped.length > 0) {
1870
+ LogStatus(`StartRealtimeClientSession: dropped the server plugins of out-of-scope channel(s) [${dropped.join(', ')}] for session ${agentSessionID}.`);
1871
+ }
1872
+ }
1873
+
1563
1874
  /**
1564
1875
  * Writes the co-agent observability run ids into the session's `Config_` alongside the
1565
- * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1566
- * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1567
- * not thrown — the voice session still proceeds, it just won't carry the run ids.
1876
+ * `targetAgentID`, then saves the session. These ids are read back on close to
1877
+ * finalize the runs (and on relay to nest delegated runs). The app awareness (application id,
1878
+ * allowed agents, direct actions, app-context snapshot) is written with them; relays read the
1879
+ * allowed agents and the app context, never the direct actions.
1880
+ * Best-effort: a save failure is logged, not thrown — the voice session still proceeds, it just
1881
+ * won't carry the run ids.
1568
1882
  */
1569
1883
  private async persistObservabilityRunIDs(
1570
1884
  session: MJAIAgentSessionEntity,
@@ -1575,22 +1889,29 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1575
1889
  applicationID?: string,
1576
1890
  allowedAgents?: RealtimeAllowedAgent[],
1577
1891
  directActions?: RealtimeDirectActionsConfig,
1892
+ channels?: string[],
1893
+ appContext?: AppContextSnapshot,
1578
1894
  ): Promise<void> {
1579
- // Preserve any server-authoritative voice deadline stamped at session start (this rebuilds the
1580
- // full config, so read the existing value forward rather than dropping it).
1581
- const existing = this.tryReadSessionConfig(session);
1582
- const config: RealtimeSessionConfig = {
1895
+ // Start from the RAW stored config so every key set at session start survives — the
1896
+ // server-authoritative voice deadline (`maxSessionDeadlineIso`) and the per-session media-kit
1897
+ // override among them — rather than rebuilding from a whitelist that silently drops the rest.
1898
+ const config = {
1899
+ ...ParseConfigObject(session.Config_),
1583
1900
  targetAgentID,
1584
1901
  coAgentRunID,
1585
1902
  promptRunID,
1586
1903
  coAgentRunStepID,
1587
- maxSessionDeadlineIso: existing?.maxSessionDeadlineIso,
1588
1904
  applicationID,
1905
+ appContext: this.appContextToStore(appContext, session.ID),
1589
1906
  allowedAgents: allowedAgents && allowedAgents.length > 0 ? allowedAgents : undefined,
1590
1907
  directActions,
1908
+ channels,
1591
1909
  };
1592
1910
  session.Config_ = JSON.stringify(config);
1593
- const saved = await session.Save();
1911
+ // Every value written here is server-decided (run ids, the resolved target, the in-scope channel
1912
+ // set), and `channels` is a server-authoritative key the owner may not write — so this save runs in
1913
+ // the trusted scope. Protected keys already in the stored config are carried forward unchanged.
1914
+ const saved = await RunWithTrustedSessionConfigWrites(() => session.Save());
1594
1915
  if (!saved) {
1595
1916
  LogError(
1596
1917
  `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
@@ -1598,6 +1919,32 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1598
1919
  }
1599
1920
  }
1600
1921
 
1922
+ /**
1923
+ * Returns the app-context snapshot to store on the session config. A snapshot whose JSON is longer
1924
+ * than {@link MAX_CLIENT_TOOLS_JSON_CHARS} is not stored: a warning is logged and `undefined` is
1925
+ * returned, so the session still starts and its delegated runs get no app context.
1926
+ *
1927
+ * @param appContext The snapshot the browser sent at session start, or undefined.
1928
+ * @param sessionID The session the snapshot belongs to (for the warning).
1929
+ * @returns The snapshot to store, or `undefined`.
1930
+ */
1931
+ private appContextToStore(appContext: AppContextSnapshot | undefined, sessionID: string): AppContextSnapshot | undefined {
1932
+ if (!appContext) {
1933
+ return undefined;
1934
+ }
1935
+ const chars = JSON.stringify(appContext).length;
1936
+ if (chars > MAX_CLIENT_TOOLS_JSON_CHARS) {
1937
+ LogErrorEx({
1938
+ message:
1939
+ `RealtimeClientSessionResolver: app context for session ${sessionID} not stored — ${chars} chars ` +
1940
+ `exceeds the ${MAX_CLIENT_TOOLS_JSON_CHARS} cap; delegated runs get no app context.`,
1941
+ severity: 'warning',
1942
+ });
1943
+ return undefined;
1944
+ }
1945
+ return appContext;
1946
+ }
1947
+
1601
1948
  /**
1602
1949
  * Loads a session, enforcing inbound ownership (`UserID === contextUser.ID`) and that it is not
1603
1950
  * `Closed`. Throws on any violation. Returns the loaded session for the caller to read from.
@@ -1843,6 +2190,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1843
2190
  * as the resumed transcript are removed, so the prompt never carries two accounts of one
1844
2191
  * exchange.
1845
2192
  *
2193
+ * Only the rows on the session's path that no rerun replaced are read: the fork it started on,
2194
+ * or Main, in `Sequence` order. When that path cannot be built — its branch rows are missing or invalid — the session
2195
+ * starts with no history, never with every branch's rows.
2196
+ *
1846
2197
  * Strictly tolerant — no conversation, a failed read, or any throw yields `[]`, never a failed
1847
2198
  * session start. The call still works; it just starts cold.
1848
2199
  *
@@ -1850,6 +2201,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1850
2201
  * @param coveredSessionIDs Session ids already framed via `PriorTranscript`.
1851
2202
  * @param contextUser The calling user (RunView scope — the read is row-level-secured).
1852
2203
  * @param provider The request-scoped metadata provider.
2204
+ * @param branchId The fork the session started on; null is Main.
1853
2205
  * @returns The capped history, oldest first.
1854
2206
  */
1855
2207
  private async loadConversationHistory(
@@ -1857,12 +2209,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1857
2209
  coveredSessionIDs: string[],
1858
2210
  contextUser: UserInfo,
1859
2211
  provider: IMetadataProvider,
2212
+ branchId: string | null,
1860
2213
  ): Promise<ChatMessage[]> {
1861
2214
  if (!conversationId) {
1862
2215
  return [];
1863
2216
  }
2217
+ const rv = RunView.FromMetadataProvider(provider);
2218
+ let pathFilter: string;
2219
+ try {
2220
+ pathFilter = await ConversationEngine.BranchPathFilterFresh(conversationId, branchId, contextUser, provider);
2221
+ } catch (error) {
2222
+ LogError(
2223
+ `StartRealtimeClientSession: could not build the path of the session's view for conversation ${conversationId}: ${(error as Error).message}`,
2224
+ );
2225
+ return [];
2226
+ }
1864
2227
  try {
1865
- const rv = RunView.FromMetadataProvider(provider);
1866
2228
  const result = await rv.RunView<{
1867
2229
  Role: string;
1868
2230
  Message: string | null;
@@ -1871,9 +2233,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1871
2233
  }>(
1872
2234
  {
1873
2235
  EntityName: CONVERSATION_DETAIL_ENTITY,
1874
- ExtraFilter: `ConversationID='${conversationId.replace(/'/g, "''")}'`,
2236
+ ExtraFilter: ConversationEngine.LiveRowsFilter(pathFilter),
1875
2237
  Fields: ['ID', 'Role', 'Message', 'HiddenToUser', 'AgentSessionID', '__mj_CreatedAt'],
1876
- OrderBy: '__mj_CreatedAt ASC',
2238
+ OrderBy: 'Sequence ASC',
1877
2239
  ResultType: 'simple',
1878
2240
  },
1879
2241
  contextUser,
@@ -2240,6 +2602,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2240
2602
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2241
2603
  detail.NewRecord();
2242
2604
  detail.ConversationID = session.ConversationID;
2605
+ detail.BranchID = this.sessionBranchIdOf(session);
2243
2606
  detail.Role = 'AI';
2244
2607
  detail.HiddenToUser = true;
2245
2608
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
@@ -2392,21 +2755,54 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2392
2755
 
2393
2756
  /** Shape check for one client tool declaration: Name/Description non-empty strings, ParametersSchema a plain object. */
2394
2757
  private isValidClientTool(candidate: unknown): boolean {
2395
- if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
2758
+ return IsValidRealtimeToolDefinition(candidate);
2759
+ }
2760
+
2761
+ /**
2762
+ * Tolerantly parses the channel candidates the browser reported at mint (the same caps and
2763
+ * tolerance as {@link parseClientTools}: capped, never throws, anything wrong is dropped and
2764
+ * logged). A caller that sent none yields `undefined` — an unscoped session, exactly as before.
2765
+ */
2766
+ private parseChannelCandidates(channelCandidatesJson?: string): RealtimeChannelCandidate[] | undefined {
2767
+ const parsed = ParseRealtimeChannelCandidates(channelCandidatesJson);
2768
+ for (const reason of parsed.Rejected) {
2769
+ LogError(`StartRealtimeClientSession: ${reason}.`);
2770
+ }
2771
+ return parsed.Candidates.length > 0 ? parsed.Candidates : undefined;
2772
+ }
2773
+
2774
+ /**
2775
+ * Whether a session's persisted scope allows a server-side call to name `channelName`. A session
2776
+ * with no persisted scope (it reported no candidates) is unscoped and allows everything; a scoped
2777
+ * one allows only the channels its policy put in the session.
2778
+ */
2779
+ private isChannelInSessionScope(session: MJAIAgentSessionEntity, channelName: string): boolean {
2780
+ // Read the RAW config: an absent `channels` key means an unscoped session, but anything present
2781
+ // and malformed fails CLOSED — a scope that cannot be read must not widen to "everything".
2782
+ // (`channels` is a server-authoritative key, so the owner cannot delete it to unscope a session.)
2783
+ const channels = ParseConfigObject(session.Config_)['channels'];
2784
+ if (channels === undefined) {
2785
+ return true;
2786
+ }
2787
+ if (!Array.isArray(channels)) {
2788
+ LogError(`RealtimeClientSessionResolver: session ${session.ID} has a malformed channel scope; refusing channel '${channelName}'.`);
2396
2789
  return false;
2397
2790
  }
2398
- const tool = candidate as Partial<RealtimeToolDefinition>;
2399
- return (
2400
- typeof tool.Name === 'string' && tool.Name.trim().length > 0 && tool.Name.length <= 128 &&
2401
- typeof tool.Description === 'string' && tool.Description.trim().length > 0 &&
2402
- tool.ParametersSchema !== null && typeof tool.ParametersSchema === 'object' && !Array.isArray(tool.ParametersSchema)
2403
- );
2791
+ const wanted = NormalizeChannelKey(channelName);
2792
+ return channels.some((key) => typeof key === 'string' && NormalizeChannelKey(key) === wanted);
2793
+ }
2794
+
2795
+ /** The fork the session was started on (its config's `conversationBranchID`); null is Main. */
2796
+ private sessionBranchIdOf(session: MJAIAgentSessionEntity): string | null {
2797
+ const branchId = ParseConfigObject(session.Config_)['conversationBranchID'];
2798
+ return typeof branchId === 'string' && branchId.length > 0 ? branchId : null;
2404
2799
  }
2405
2800
 
2406
2801
  /**
2407
- * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
2408
- * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
2409
- * or carries no target — a relay cannot proceed without a target.
2802
+ * Parses the session's persisted config, returning its `targetAgentID` plus any observability run
2803
+ * ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
2804
+ * target — a relay cannot proceed without a target. The owner can edit the config, so callers
2805
+ * re-check what they act on.
2410
2806
  */
2411
2807
  private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
2412
2808
  const raw = session.Config_;
@@ -2424,11 +2820,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2424
2820
  maxSessionDeadlineIso:
2425
2821
  typeof parsed.maxSessionDeadlineIso === 'string' ? parsed.maxSessionDeadlineIso : undefined,
2426
2822
  applicationID: typeof parsed.applicationID === 'string' ? parsed.applicationID : undefined,
2823
+ appContext:
2824
+ parsed.appContext && typeof parsed.appContext === 'object' && !Array.isArray(parsed.appContext)
2825
+ ? parsed.appContext
2826
+ : undefined,
2427
2827
  allowedAgents: Array.isArray(parsed.allowedAgents) ? parsed.allowedAgents : undefined,
2428
2828
  directActions:
2429
2829
  parsed.directActions && typeof parsed.directActions === 'object' && typeof parsed.directActions.enabled === 'boolean'
2430
2830
  ? (parsed.directActions as RealtimeDirectActionsConfig)
2431
2831
  : undefined,
2832
+ conversationBranchID: typeof parsed.conversationBranchID === 'string' ? parsed.conversationBranchID : undefined,
2833
+ mediaCollectionID: typeof parsed.mediaCollectionID === 'string' ? parsed.mediaCollectionID : undefined,
2834
+ channels: Array.isArray(parsed.channels) ? parsed.channels.filter((k): k is string => typeof k === 'string') : undefined,
2432
2835
  };
2433
2836
  }
2434
2837
  } catch {
@@ -2516,6 +2919,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2516
2919
  );
2517
2920
  detail.NewRecord();
2518
2921
  detail.ConversationID = session.ConversationID;
2922
+ detail.BranchID = this.sessionBranchIdOf(session);
2519
2923
  detail.Role = this.mapTranscriptRole(role);
2520
2924
  detail.Message = text;
2521
2925
  detail.AgentSessionID = session.ID;
@@ -2689,6 +3093,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2689
3093
  );
2690
3094
  detail.NewRecord();
2691
3095
  detail.ConversationID = session.ConversationID;
3096
+ detail.BranchID = this.sessionBranchIdOf(session);
2692
3097
  detail.Role = 'AI';
2693
3098
  detail.HiddenToUser = true;
2694
3099
  detail.AgentSessionID = session.ID;