@xenon-device-management/xenon 2.0.0 → 2.2.0

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 (289) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +3 -4
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  5. package/lib/public/assets/BugReportButton-C7OmS61B.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-fPxujqqE.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-5bCutWrs.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-owsBiDT1.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-DFhqzPSt.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-Bw-a-E-e.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-WtByf5bC.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-FpWWFNgT.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-BR7nOhpT.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-B_Va3V05.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-DxRFWvkN.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-CQf-9_V6.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-SoE1Kd7N.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-CgGXTlgn.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-uQ6WIwiC.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-C9RyfzXS.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-DFHi_8yG.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-Bii1t4qc.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-Bnv8Z9hi.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-_Y78wXiP.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-D4MqSOc2.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-uGHCXe-L.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-Dy1OqpzB.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-BUsldEli.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-D3Qq0tKp.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-B4IOD1DE.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-BfYfzvSg.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-DZwYE5Va.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CRG4Utt5.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-CCDYo_TN.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-DuLCmVFb.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-CtKNPqzb.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-3uxl3o7K.js} +43 -43
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-Zy1Op5C1.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-CzTWvUXZ.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CI-nYDFM.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-YZZGFyTX.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-L8tzWgDo.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-DBbX-fu8.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-AJwkOIsE.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-Di_66F2m.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-DiSumY-1.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-CwE4gXl5.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CfR4Sm9V.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-DOS8P9CQ.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-BYpHwQIH.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BRVJU_qV.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Bi3JvAFZ.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-Cqx7pEou.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C0DsncKj.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CAhpdilF.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CrCVeW2m.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-F_hwYqEt.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-C-s5pypL.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-DY5uzv6A.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-Dli7gwyg.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-ui4IhHNC.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV4hSmXJ.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-DkGy_J-L.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  69. package/lib/public/assets/{users-Drd7ifJx.js → users-BU0nXIVe.js} +1 -1
  70. package/lib/public/assets/{users-CEJ3fC78.js → users-Nb_rzRmF.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-CUNzD2kH.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-Ca3UFXZe.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-B2bH2vKX.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +11 -104
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  84. package/lib/src/app/ws/h264StreamWs.js +43 -12
  85. package/lib/src/app/ws/logcatWs.js +48 -14
  86. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  87. package/lib/src/app/ws/upgradeRouter.js +215 -0
  88. package/lib/src/dashboard/event-manager.js +7 -6
  89. package/lib/src/data-service/device-service.js +152 -49
  90. package/lib/src/data-service/device-store.js +92 -10
  91. package/lib/src/data-service/deviceClaims.js +116 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  93. package/lib/src/data-service/prisma-store.js +102 -31
  94. package/lib/src/device-managers/AndroidDeviceManager.js +40 -42
  95. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  96. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  97. package/lib/src/device-managers/NodeDevices.js +7 -2
  98. package/lib/src/device-managers/android/AndroidH264StreamService.js +13 -5
  99. package/lib/src/device-managers/android/AndroidStreamService.js +9 -6
  100. package/lib/src/device-managers/android/previewHold.js +2 -2
  101. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  102. package/lib/src/device-managers/ios/IOSStreamService.js +162 -40
  103. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  104. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  105. package/lib/src/device-utils.js +78 -24
  106. package/lib/src/gateway/createHandoff.js +73 -0
  107. package/lib/src/gateway/defaultGateway.js +80 -0
  108. package/lib/src/gateway/forwardToNode.js +147 -0
  109. package/lib/src/gateway/hubSessionToken.js +356 -0
  110. package/lib/src/gateway/internalCall.js +90 -0
  111. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  112. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  113. package/lib/src/gateway/sessionCreate.js +136 -0
  114. package/lib/src/gateway/sessionGateway.js +201 -0
  115. package/lib/src/gateway/sessionLocator.js +100 -0
  116. package/lib/src/generated/client/edge.js +7 -4
  117. package/lib/src/generated/client/index-browser.js +4 -1
  118. package/lib/src/generated/client/index.d.ts +112 -1
  119. package/lib/src/generated/client/index.js +7 -4
  120. package/lib/src/generated/client/package.json +1 -1
  121. package/lib/src/generated/client/schema.prisma +9 -0
  122. package/lib/src/generated/client/wasm.js +4 -1
  123. package/lib/src/helpers/index.js +5 -1
  124. package/lib/src/logger.js +5 -0
  125. package/lib/src/middleware/authMiddleware.js +80 -0
  126. package/lib/src/middleware/commandAuth.js +31 -17
  127. package/lib/src/middleware/csrfMiddleware.js +3 -2
  128. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  129. package/lib/src/middleware/sessionListingFilter.js +153 -0
  130. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  131. package/lib/src/plugin.js +27 -1
  132. package/lib/src/prisma.js +2 -1
  133. package/lib/src/services/DeviceReconciler.js +5 -2
  134. package/lib/src/services/OrphanSweeper.js +12 -19
  135. package/lib/src/services/PortAllocator.js +75 -16
  136. package/lib/src/services/ServerManager.js +63 -30
  137. package/lib/src/services/SessionLifecycleService.js +335 -76
  138. package/lib/src/services/ShutdownCoordinator.js +7 -1
  139. package/lib/src/services/SocketServer.js +11 -2
  140. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  141. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  142. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  143. package/lib/src/services/lease/LeaseOrphanSweeper.js +34 -6
  144. package/lib/src/services/lease/LeaseService.js +19 -5
  145. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  146. package/lib/src/services/session/sessionCredentials.js +7 -59
  147. package/lib/src/sessions/CloudSession.js +12 -0
  148. package/lib/src/sessions/LocalSession.js +52 -26
  149. package/lib/src/sessions/RemoteSession.js +116 -9
  150. package/lib/src/sessions/SessionManager.js +49 -6
  151. package/lib/src/sessions/appiumUmbrella.js +32 -0
  152. package/lib/test/helpers/appium-umbrella.js +84 -0
  153. package/lib/test/helpers/fake-device-table.js +97 -0
  154. package/lib/test/helpers/loki-stores.js +41 -0
  155. package/lib/test/helpers/loopbackRequest.js +77 -0
  156. package/lib/test/helpers/own-node-id.js +29 -0
  157. package/lib/test/helpers/scratch-database.js +100 -0
  158. package/lib/test/helpers/scratch-port-leases.js +119 -0
  159. package/lib/test/helpers/test-container.js +35 -27
  160. package/lib/test/integration/androidDevices.spec.js +5 -4
  161. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  162. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  163. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  164. package/lib/test/setup/scratch-default-database.js +32 -0
  165. package/lib/test/unit/AndroidDeviceManager.spec.js +56 -1
  166. package/lib/test/unit/DeviceModel.spec.js +9 -9
  167. package/lib/test/unit/HealingCascade.test.js +5 -1
  168. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  169. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  170. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  171. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  172. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  173. package/lib/test/unit/android-h264-service.spec.js +24 -0
  174. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  175. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  176. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  177. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  178. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  179. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  180. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  181. package/lib/test/unit/authMiddleware.test.js +3 -0
  182. package/lib/test/unit/bug-report/route.spec.js +9 -9
  183. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  184. package/lib/test/unit/clipboard-route.spec.js +22 -8
  185. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  186. package/lib/test/unit/command-auth.spec.js +31 -31
  187. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  188. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  189. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  190. package/lib/test/unit/create-error-message.spec.js +43 -0
  191. package/lib/test/unit/create-handoff.spec.js +86 -0
  192. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  193. package/lib/test/unit/device-access-guard.spec.js +66 -28
  194. package/lib/test/unit/device-service.spec.js +4 -0
  195. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  196. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  197. package/lib/test/unit/device-team-guard.spec.js +20 -20
  198. package/lib/test/unit/device-utils.spec.js +8 -0
  199. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  200. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  201. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  202. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  203. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  204. package/lib/test/unit/helpers.spec.js +27 -0
  205. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  206. package/lib/test/unit/hub-create-once.spec.js +190 -0
  207. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  208. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  209. package/lib/test/unit/hub-node-control-forward.spec.js +608 -0
  210. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  211. package/lib/test/unit/hub-node-race.spec.js +426 -0
  212. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  213. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  214. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  215. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  216. package/lib/test/unit/hub-session-token.spec.js +265 -0
  217. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  218. package/lib/test/unit/install-repository-app-team.spec.js +6 -2
  219. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  220. package/lib/test/unit/internal-call.spec.js +181 -0
  221. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  222. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  223. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  224. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  225. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  226. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  227. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  228. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  229. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +107 -0
  230. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  231. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  232. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  233. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  234. package/lib/test/unit/logcat-ws.spec.js +12 -1
  235. package/lib/test/unit/loopback-request.spec.js +81 -0
  236. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  237. package/lib/test/unit/node-create-caps.spec.js +54 -0
  238. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  239. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  240. package/lib/test/unit/node-phone-control.spec.js +131 -0
  241. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  242. package/lib/test/unit/omni-vision.spec.js +7 -3
  243. package/lib/test/unit/plugin.spec.js +45 -4
  244. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  245. package/lib/test/unit/profile-router.test.js +11 -11
  246. package/lib/test/unit/queue-requester.spec.js +5 -0
  247. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  248. package/lib/test/unit/queue-visibility.spec.js +1 -1
  249. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  250. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  251. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  252. package/lib/test/unit/recording-store.spec.js +9 -3
  253. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  254. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  255. package/lib/test/unit/remote-session-create.spec.js +130 -0
  256. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  257. package/lib/test/unit/reset-link.test.js +14 -14
  258. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  259. package/lib/test/unit/session-assets.spec.js +9 -9
  260. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  261. package/lib/test/unit/session-create-layer.spec.js +255 -0
  262. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  263. package/lib/test/unit/session-credentials.spec.js +0 -47
  264. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  265. package/lib/test/unit/session-locator.spec.js +250 -0
  266. package/lib/test/unit/session-node-calls.spec.js +174 -0
  267. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  268. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  269. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  270. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  271. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  272. package/lib/test/unit/stream-leave-route.spec.js +19 -7
  273. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  274. package/lib/test/unit/stream-stop-ownership.spec.js +17 -5
  275. package/lib/test/unit/stream-stop-recording.spec.js +17 -5
  276. package/lib/test/unit/stream-ticket-identity.spec.js +26 -6
  277. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  278. package/lib/test/unit/upgrade-router.spec.js +446 -0
  279. package/lib/test/unit/users-router.test.js +11 -8
  280. package/lib/test/unit/verify-credential.spec.js +4 -1
  281. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  282. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  283. package/lib/tsconfig.tsbuildinfo +1 -1
  284. package/package.json +3 -4
  285. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  286. package/prisma/schema.prisma +9 -0
  287. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  288. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  289. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -51,7 +51,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
51
51
  return (mod && mod.__esModule) ? mod : { "default": mod };
52
52
  };
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
- exports.SessionLifecycleService = void 0;
54
+ exports.SessionLifecycleService = exports.REMOTE_CREATE_TIMEOUT_MS = void 0;
55
55
  const typedi_1 = require("typedi");
56
56
  const uuid_1 = require("uuid");
57
57
  const lodash_1 = __importDefault(require("lodash"));
@@ -73,24 +73,40 @@ const JwtKeyService_1 = require("./token/JwtKeyService");
73
73
  const sessionIdentity_1 = require("./session/sessionIdentity");
74
74
  const leaseSessionCaps_1 = require("./lease/leaseSessionCaps");
75
75
  const sessionCredentials_1 = require("./session/sessionCredentials");
76
+ const nodeCreateCaps_1 = require("./session/nodeCreateCaps");
77
+ const xenonOptions_1 = require("./session/xenonOptions");
76
78
  const leaseOverride_1 = require("./device-access/leaseOverride");
77
79
  const callerTeamIds_1 = require("./device-access/callerTeamIds");
78
80
  const queueVisibility_1 = require("./device-access/queueVisibility");
79
81
  const appVisibility_1 = require("./device-access/appVisibility");
82
+ const LiveSessionOwners_1 = require("./device-access/LiveSessionOwners");
80
83
  const appCapability_1 = require("./session/appCapability");
81
84
  const CircuitBreaker_1 = require("../data-service/CircuitBreaker");
82
- const wd_command_proxy_1 = require("../proxy/wd-command-proxy");
85
+ const nodeWebDriverUrl_1 = require("../gateway/nodeWebDriverUrl");
86
+ const hubSessionToken_1 = require("../gateway/hubSessionToken");
83
87
  const device_store_1 = require("../data-service/device-store");
88
+ const deviceClaims_1 = require("../data-service/deviceClaims");
84
89
  const LocalSession_1 = require("../sessions/LocalSession");
85
90
  const CloudSession_1 = require("../sessions/CloudSession");
86
91
  const RemoteSession_1 = require("../sessions/RemoteSession");
87
92
  const SessionManager_1 = require("../sessions/SessionManager");
88
93
  const event_manager_1 = require("../dashboard/event-manager");
94
+ const prisma_1 = require("../prisma");
89
95
  const session_service_1 = require("../dashboard/services/session-service");
90
96
  const SessionStatus_1 = require("../types/SessionStatus");
91
97
  const SessionType_1 = __importDefault(require("../enums/SessionType"));
92
98
  const async_lock_1 = __importDefault(require("async-lock"));
93
99
  const base_driver_1 = require("@appium/base-driver");
100
+ /**
101
+ * How long the hub waits for a node (or cloud provider) to create a session.
102
+ * A create is sent once (InternalRequestConfig `retry: false`): the node may
103
+ * still be creating when an ordinary request would give up, and a second
104
+ * create starts a second session holding the phone. A first UiAutomator2 or
105
+ * WebDriverAgent install takes minutes. It ends before the phone's pending
106
+ * claim can expire (PENDING_CLAIM_TIMEOUT_MS), so the idle sweeper never frees
107
+ * a phone whose create the hub is still waiting for.
108
+ */
109
+ exports.REMOTE_CREATE_TIMEOUT_MS = deviceClaims_1.PENDING_CLAIM_TIMEOUT_MS - 2 * 60000;
94
110
  const commandsQueueGuard = new async_lock_1.default();
95
111
  // Serializes concurrent deleteSession cleanup for the same sessionId so
96
112
  // that onSessionStopped events don't fire twice if two clients race.
@@ -99,9 +115,52 @@ let SessionLifecycleService = class SessionLifecycleService {
99
115
  constructor() {
100
116
  this.logger = logger_1.default.scope('SessionLifecycleService');
101
117
  }
102
- createSession(next, driver, caps) {
103
- return __awaiter(this, void 0, void 0, function* () {
104
- var _a, _b;
118
+ /**
119
+ * Create a session in one call: prepare it (allocate a phone), then
120
+ * complete it here through `next` (a phone this server drives) or on the
121
+ * phone's node (one another server drives).
122
+ *
123
+ * The session gateway runs the same steps with Appium's route between them
124
+ * (gateway/sessionCreate.ts): it prepares, answers a remote phone itself,
125
+ * and hands a local phone's allocation to XenonPlugin.createSession, which
126
+ * completes it. The plugin calls this only for a create that did not come
127
+ * through the gateway, with `localOnly`: a phone on another server is then
128
+ * given back and refused, since answering for Appium without its driver is
129
+ * what made Appium's umbrella fail (a 500 while the node kept the session).
130
+ */
131
+ createSession(next_1, driver_1, caps_1) {
132
+ return __awaiter(this, arguments, void 0, function* (next, driver, caps, opts = {}) {
133
+ const allocation = yield this.prepareSession(caps);
134
+ if (!allocation.remote)
135
+ return this.completeLocalSession(allocation, next, driver, caps);
136
+ if (opts.localOnly) {
137
+ yield this.releaseAllocation(allocation);
138
+ throw new base_driver_1.errors.SessionNotCreatedError(`Device ${allocation.device.udid} is driven by another server ` +
139
+ `(${allocation.device.host}). Xenon creates sessions there in its session gateway, ` +
140
+ 'which did not handle this request; the device was released.');
141
+ }
142
+ return this.completeRemoteSession(allocation);
143
+ });
144
+ }
145
+ /**
146
+ * The first half of a create: everything up to and including allocating a
147
+ * phone. It takes the credentials out of `caps` (in place) before anything
148
+ * reads them, checks them (or the hub's grant), validates the capabilities,
149
+ * resolves an uploaded app, writes the pending-session row and allocates.
150
+ * Throws to refuse, leaving no pending row and no busy phone.
151
+ *
152
+ * `hubGrant` is the verified grant of a create the hub forwarded (a node
153
+ * only; gateway/sessionCreate.ts). Each instance has its own database, so a
154
+ * node can't check the client's key: the hub checked it, applied the team
155
+ * rule and the session-token gate, and resolved any lease. The grant is
156
+ * then the session's credential: it names the owner, and the one phone on
157
+ * this node the create may have.
158
+ *
159
+ * What it returns must be completed (completeLocalSession /
160
+ * completeRemoteSession) or released (releaseAllocation).
161
+ */
162
+ prepareSession(caps_1) {
163
+ return __awaiter(this, arguments, void 0, function* (caps, opts = {}) {
105
164
  // Fail fast during graceful shutdown so clients get a clear error instead
106
165
  // of their request hanging until the process dies. Lazy import to avoid
107
166
  // a cycle (ShutdownCoordinator -> SessionLifecycleService -> this file).
@@ -116,7 +175,13 @@ let SessionLifecycleService = class SessionLifecycleService {
116
175
  // driver, the Session row, the dashboard, the logs — and hand them only to
117
176
  // the checks that need them.
118
177
  const credentials = (0, sessionCredentials_1.takeSessionCredentials)(caps);
119
- const authResult = yield this.authorizeSessionRequest(caps, credentials);
178
+ const { hubGrant } = opts;
179
+ yield this.assertCreateCameFromHub(hubGrant);
180
+ if (hubGrant)
181
+ this.assertGrantNamesCapsPhone(caps, hubGrant);
182
+ const authResult = hubGrant
183
+ ? this.authorizeHubGrant(hubGrant)
184
+ : yield this.authorizeSessionRequest(caps, credentials);
120
185
  const context = typedi_1.Container.get(PluginContext_1.PluginContext);
121
186
  const pluginArgs = context.pluginArgs;
122
187
  this.logger.debug(`📱 pluginArgs: ${JSON.stringify(pluginArgs)}`);
@@ -174,76 +239,207 @@ let SessionLifecycleService = class SessionLifecycleService {
174
239
  }
175
240
  }));
176
241
  yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
177
- let session;
178
- const isRemoteOrCloudSession = !device.nodeId || device.nodeId !== context.nodeId;
179
242
  this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
243
+ const allocation = {
244
+ caps,
245
+ device,
246
+ remote: !device.nodeId || device.nodeId !== context.nodeId,
247
+ pendingSessionId,
248
+ apiKeyId: authResult.apiKeyId,
249
+ userId: authResult.userId,
250
+ appDownload,
251
+ };
252
+ // The grant opens one phone, on this node. A token taken to another node
253
+ // (an emulator's udid repeats across machines), or naming a phone this
254
+ // node does not drive, is refused, and the phone given back.
255
+ if (hubGrant &&
256
+ (allocation.remote || device.udid !== hubGrant.udid || device.host !== hubGrant.host)) {
257
+ yield this.releaseAllocation(allocation);
258
+ this.logger.error(`❌ Rejecting session: the hub's token is for ${hubGrant.udid} at ${hubGrant.host}, ` +
259
+ `and this node allocated ${device.udid} at ${device.host}`);
260
+ throw new base_driver_1.errors.InvalidArgumentError("session rejected: the hub's token for this session names another device");
261
+ }
262
+ return allocation;
263
+ });
264
+ }
265
+ /**
266
+ * Complete a create on the phone's node (or cloud provider), and record the
267
+ * session here. On failure the phone is released and the error thrown.
268
+ */
269
+ completeRemoteSession(allocation) {
270
+ return __awaiter(this, void 0, void 0, function* () {
271
+ const { device, caps, appDownload } = allocation;
272
+ let session;
180
273
  try {
181
- if (isRemoteOrCloudSession) {
182
- this.logger.debug(`📱 Forwarding session request to ${device.host}`);
183
- yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Forwarding to remote node...');
184
- // A peer Xenon node re-runs createSession — authentication,
185
- // attribution, the session-token gate and the lease check — so it
186
- // needs the credentials this hub read; it strips them itself. A cloud
187
- // provider is not a Xenon node and never sees them.
188
- const isPeerNode = !device.cloud && !!device.nodeId;
189
- const forwardCaps = isPeerNode ? (0, sessionCredentials_1.capsWithCredentials)(caps, credentials) : caps;
190
- // The node's driver downloads the app from this hub, so the copy it
191
- // is sent carries the ticket; `caps` keeps the plain URL.
192
- session = yield this.forwardSessionRequest(device, appDownload
193
- ? (0, appCapability_1.withAppCapability)(forwardCaps, yield this.driverAppUrl(appDownload))
194
- : forwardCaps);
195
- }
196
- else {
197
- this.logger.debug('📱 Creating session on the same node');
198
- yield this.handleLocalWDAProvisioning(device, caps);
199
- yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Finalizing session bootstrap...');
200
- // The driver reads these same caps and downloads the app while it
201
- // starts, so the ticket is in them for this call only.
202
- if (appDownload)
203
- (0, appCapability_1.setAppCapability)(caps, yield this.driverAppUrl(appDownload));
204
- try {
205
- session = yield next();
206
- }
207
- finally {
208
- if (appDownload)
209
- (0, appCapability_1.setAppCapability)(caps, appDownload.url);
210
- }
211
- }
274
+ this.logger.debug(`📱 Forwarding session request to ${device.host}`);
275
+ yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Forwarding to remote node...');
276
+ // A peer Xenon node gets a create that stands on its own (capsForNode:
277
+ // no credentials, no lease id, the phone pinned), with the hub's token
278
+ // naming the owner this hub verified, since it can't check the client's
279
+ // key in its own database. A cloud provider is not a Xenon node: it
280
+ // gets the caps without the credentials, and no token.
281
+ const isPeerNode = !device.cloud && !!device.nodeId;
282
+ const forwardCaps = isPeerNode ? (0, nodeCreateCaps_1.capsForNode)(caps, device.udid) : caps;
283
+ const hubToken = isPeerNode
284
+ ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).createTokenFor({
285
+ userId: allocation.userId,
286
+ udid: device.udid,
287
+ host: device.host,
288
+ })
289
+ : null;
290
+ // The node's driver downloads the app from this hub, so the copy it
291
+ // is sent carries the ticket; `caps` keeps the plain URL.
292
+ session = yield this.forwardSessionRequest(device, appDownload
293
+ ? (0, appCapability_1.withAppCapability)(forwardCaps, yield this.driverAppUrl(appDownload))
294
+ : forwardCaps, hubToken);
212
295
  }
213
296
  catch (err) {
214
- // A THROWN error from the driver's createSession (next()) or the remote
215
- // forward leaves the device locked: allocateDeviceForSession already set
216
- // busy=true, and on the throw path neither finalizeSession nor
217
- // handleSessionFailure runs — so without this the device gets stuck busy
218
- // with no session. Release it, then rethrow the original error.
219
- // (handleSessionFailure below covers the separate case where next()
220
- // RETURNS a W3C error object rather than throwing.)
221
- this.logger.error(`❌ Session creation failed for device ${device.udid}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}. Unblocking device.`);
297
+ yield this.failedCreate(allocation, err);
298
+ throw err;
299
+ }
300
+ return this.finishCreate(allocation, session, undefined);
301
+ });
302
+ }
303
+ /**
304
+ * Complete a create on a phone this server drives: `next` is Appium's own
305
+ * createSession, and `caps` the capabilities its driver reads. On failure
306
+ * the phone is released and the error thrown.
307
+ */
308
+ completeLocalSession(allocation, next, driver, caps) {
309
+ return __awaiter(this, void 0, void 0, function* () {
310
+ const { device, appDownload } = allocation;
311
+ let session;
312
+ try {
313
+ this.logger.debug('📱 Creating session on the same node');
314
+ yield this.handleLocalWDAProvisioning(device, caps);
315
+ yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Finalizing session bootstrap...');
316
+ // The driver reads these same caps and downloads the app while it
317
+ // starts, so the ticket is in them for this call only.
318
+ if (appDownload)
319
+ (0, appCapability_1.setAppCapability)(caps, yield this.driverAppUrl(appDownload));
222
320
  try {
223
- yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
224
- yield (0, device_service_1.unblockDevice)(device.udid, device.host);
225
- yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, '');
226
- if (isRemoteOrCloudSession) {
227
- typedi_1.Container.get(CircuitBreaker_1.CircuitBreaker).recordFailure(device.host);
228
- }
321
+ session = yield next();
229
322
  }
230
- catch (cleanupErr) {
231
- this.logger.warn(`Cleanup after failed session creation had issues: ${(_b = cleanupErr === null || cleanupErr === void 0 ? void 0 : cleanupErr.message) !== null && _b !== void 0 ? _b : cleanupErr}`);
323
+ finally {
324
+ if (appDownload)
325
+ (0, appCapability_1.setAppCapability)(caps, appDownload.url);
232
326
  }
327
+ }
328
+ catch (err) {
329
+ yield this.failedCreate(allocation, err);
233
330
  throw err;
234
331
  }
332
+ return this.finishCreate(Object.assign(Object.assign({}, allocation), { caps }), session, driver);
333
+ });
334
+ }
335
+ /**
336
+ * Give back a phone allocated for a session that was never created: its
337
+ * pending-session row goes and the phone is free again. Only the claim this
338
+ * allocation took is released: if it timed out and the phone went on to
339
+ * another session, that session keeps it. Never throws.
340
+ */
341
+ releaseAllocation(allocation) {
342
+ return __awaiter(this, void 0, void 0, function* () {
343
+ var _a;
344
+ const { device } = allocation;
345
+ try {
346
+ yield (0, pending_sessions_service_1.removePendingSession)(allocation.pendingSessionId);
347
+ yield (0, device_service_1.releasePendingClaim)(device);
348
+ yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, '');
349
+ }
350
+ catch (cleanupErr) {
351
+ this.logger.warn(`Cleanup after failed session creation had issues: ${(_a = cleanupErr === null || cleanupErr === void 0 ? void 0 : cleanupErr.message) !== null && _a !== void 0 ? _a : cleanupErr}`);
352
+ }
353
+ });
354
+ }
355
+ // A THROWN error from the driver's createSession (next()) or the remote
356
+ // forward leaves the device locked: allocation already set busy=true, and
357
+ // on the throw path neither finalizeSession nor handleSessionFailure runs,
358
+ // so without this the device gets stuck busy with no session. (finishCreate
359
+ // covers the separate case where next() RETURNS a W3C error object rather
360
+ // than throwing.)
361
+ failedCreate(allocation, err) {
362
+ return __awaiter(this, void 0, void 0, function* () {
363
+ var _a;
364
+ const { device } = allocation;
365
+ this.logger.error(`❌ Session creation failed for device ${device.udid}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}. Unblocking device.`);
366
+ yield this.releaseAllocation(allocation);
367
+ if (allocation.remote) {
368
+ typedi_1.Container.get(CircuitBreaker_1.CircuitBreaker).recordFailure(device.host);
369
+ }
370
+ });
371
+ }
372
+ finishCreate(allocation, session, driver) {
373
+ return __awaiter(this, void 0, void 0, function* () {
374
+ const { device, caps, remote } = allocation;
235
375
  this.logger.debug('📱 Session response: ', JSON.stringify(session));
236
- this.logger.debug(`📱 Removing pending session with capability_id: ${pendingSessionId}`);
237
- yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
376
+ this.logger.debug(`📱 Removing pending session with capability_id: ${allocation.pendingSessionId}`);
377
+ yield (0, pending_sessions_service_1.removePendingSession)(allocation.pendingSessionId);
238
378
  if (this.isCreateSessionResponseInternal(session)) {
239
- yield this.finalizeSession(session, device, caps, driver, isRemoteOrCloudSession, authResult.apiKeyId, authResult.userId);
379
+ yield this.finalizeSession(session, device, caps, driver, remote, allocation.apiKeyId, allocation.userId);
240
380
  }
241
381
  else {
242
- yield this.handleSessionFailure(session, device, isRemoteOrCloudSession);
382
+ yield this.handleSessionFailure(session, device, remote);
243
383
  }
244
384
  return session;
245
385
  });
246
386
  }
387
+ /**
388
+ * Sessions on a node's phones go through its hub, which owns their team
389
+ * rules, reservations and blocks and is where auth is enforced. So a node
390
+ * (a server with `hub`) refuses a create that doesn't carry the hub's
391
+ * verified grant: one sent to the node directly, or one that reached the
392
+ * plugin without the session gateway, which is where the grant is checked.
393
+ *
394
+ * Only with auth enabled. With auth disabled a node checks no credential,
395
+ * the hub's token included (sessionCreate.ts), so a create from the hub and
396
+ * one sent directly look alike, and refusing would protect nothing while
397
+ * breaking what such a node is used for: running a driver on it directly
398
+ * during local development. It works there as it always has.
399
+ */
400
+ assertCreateCameFromHub(hubGrant) {
401
+ return __awaiter(this, void 0, void 0, function* () {
402
+ const { hub } = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs;
403
+ if (hub === undefined || hubGrant)
404
+ return;
405
+ const { config: xenonConfig } = yield Promise.resolve().then(() => __importStar(require('../config')));
406
+ if (xenonConfig.authDisabled === true)
407
+ return;
408
+ this.logger.warn(`Refusing a session created directly on this node: it carries no grant from the hub (${hub}).`);
409
+ throw new base_driver_1.errors.SessionNotCreatedError(`This server is a node of the Xenon hub at ${hub}. Create sessions through the hub: ` +
410
+ 'it applies the team rules, reservations and blocks for this node’s devices, and a ' +
411
+ 'node refuses a session that does not come from it.');
412
+ });
413
+ }
414
+ /**
415
+ * A grant is for the phone the hub pinned in the caps it forwarded
416
+ * (capsForNode). Caps that name another one, or a list to pick from, are
417
+ * not a create the hub sent: refused before anything is allocated.
418
+ */
419
+ assertGrantNamesCapsPhone(caps, grant) {
420
+ var _a, _b;
421
+ const merged = (0, xenonOptions_1.mergedFirstMatch)(caps);
422
+ if (merged['appium:udid'] === grant.udid && merged['appium:udids'] === undefined)
423
+ return;
424
+ this.logger.error(`❌ Rejecting session: the hub's token is for ${grant.udid}, and the capabilities name ` +
425
+ `${(_b = (_a = merged['appium:udids']) !== null && _a !== void 0 ? _a : merged['appium:udid']) !== null && _b !== void 0 ? _b : 'no device'}`);
426
+ throw new base_driver_1.errors.InvalidArgumentError("session rejected: the hub's token for this session names another device");
427
+ }
428
+ /**
429
+ * The session's identity when the hub forwarded the create with its grant:
430
+ * the owner the hub verified, unscoped (the hub applied the team rule), and
431
+ * no lease override (the hub resolved the lease).
432
+ */
433
+ authorizeHubGrant(grant) {
434
+ return {
435
+ apiKeyId: null,
436
+ userId: grant.userId,
437
+ callerTeamIds: undefined,
438
+ scoped: false,
439
+ leaseAccess: () => __awaiter(this, void 0, void 0, function* () { return ({ canOverride: false, teamIds: undefined }); }),
440
+ requester: { userId: grant.userId, teamId: null },
441
+ };
442
+ }
247
443
  /**
248
444
  * Resolves an uploaded-app id the session names to its plain download URL,
249
445
  * written into `caps` in place, when the session may see the app (by
@@ -406,8 +602,8 @@ let SessionLifecycleService = class SessionLifecycleService {
406
602
  const requestedTeam = (0, XenonCapabilityManager_1.extractTeamCap)(caps);
407
603
  if (requestedTeam) {
408
604
  if (callerTeamIds !== undefined && !callerTeamIds.includes(requestedTeam)) {
409
- this.logger.error(`Rejecting session: xenon:team=${requestedTeam} is not one of the caller's teams (${callerTeamIds.join(', ') || 'shared pool only'})`);
410
- throw new base_driver_1.errors.InvalidArgumentError(`xenon:team '${requestedTeam}' is not allowed for this API key`);
605
+ this.logger.error(`Rejecting session: xe:options.team=${requestedTeam} is not one of the caller's teams (${callerTeamIds.join(', ') || 'shared pool only'})`);
606
+ throw new base_driver_1.errors.InvalidArgumentError(`xe:options.team '${requestedTeam}' is not allowed for this API key`);
411
607
  }
412
608
  return {
413
609
  apiKeyId: row.id,
@@ -597,21 +793,29 @@ let SessionLifecycleService = class SessionLifecycleService {
597
793
  'xenon.udid': device.udid,
598
794
  });
599
795
  }
600
- yield (0, device_service_1.updatedAllocatedDevice)(device, {
601
- busy: true,
796
+ // The session takes over the pending claim its phone was allocated with.
797
+ yield (0, device_service_1.claimDeviceForSession)(device, sessionId, {
602
798
  session_id: sessionId,
603
799
  lastCmdExecutedAt: new Date().getTime(),
604
800
  sessionStartTime: new Date().getTime(),
605
801
  sessionProgress: 'Session Active',
606
802
  });
803
+ // Where the session gateway sends this session's commands: the node's own
804
+ // base path, which need not be this hub's.
805
+ let webDriverUrl;
607
806
  if (isRemote) {
608
807
  typedi_1.Container.get(CircuitBreaker_1.CircuitBreaker).recordSuccess(device.host);
609
- (0, wd_command_proxy_1.addProxyHandler)(sessionId, device.host);
808
+ webDriverUrl = yield (0, nodeWebDriverUrl_1.nodeWebDriverUrl)(device, context.nodeBasePath);
610
809
  }
611
810
  const freshDevice = yield this.getFreshDevice(device);
612
- const sessionInstance = this.createSessionInstance(sessionId, freshDevice, sessionResponse, xenonCapabilities, driver);
811
+ const sessionInstance = this.createSessionInstance(sessionId, freshDevice, sessionResponse, xenonCapabilities, driver, webDriverUrl);
613
812
  sessionInstance.apiKeyId = apiKeyId;
614
813
  sessionInstance.userId = userId;
814
+ // A session this server drives: its owner is known while it runs, row or
815
+ // not (a node writes none for the hub's sessions; LiveSessionOwners).
816
+ if (sessionInstance instanceof LocalSession_1.LocalSession) {
817
+ typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).record(sessionId, userId);
818
+ }
615
819
  yield this.applyPostSessionLogic(sessionInstance, xenonCapabilities, freshDevice);
616
820
  });
617
821
  }
@@ -628,7 +832,7 @@ let SessionLifecycleService = class SessionLifecycleService {
628
832
  }
629
833
  });
630
834
  }
631
- createSessionInstance(sessionId, device, response, caps, driver) {
835
+ createSessionInstance(sessionId, device, response, caps, driver, webDriverUrl) {
632
836
  const context = typedi_1.Container.get(PluginContext_1.PluginContext);
633
837
  const sessionOptions = {
634
838
  sessionId,
@@ -636,7 +840,7 @@ let SessionLifecycleService = class SessionLifecycleService {
636
840
  sessionResponse: response,
637
841
  xenonOption: caps,
638
842
  };
639
- const nodeWebdriverUrl = (0, helpers_1.nodeUrl)(device, context.nodeBasePath);
843
+ const nodeWebdriverUrl = webDriverUrl !== null && webDriverUrl !== void 0 ? webDriverUrl : (0, helpers_1.nodeUrl)(device, context.nodeBasePath);
640
844
  if (device.nodeId === context.nodeId) {
641
845
  return new LocalSession_1.LocalSession(Object.assign(Object.assign({}, sessionOptions), { driver }));
642
846
  }
@@ -686,17 +890,53 @@ let SessionLifecycleService = class SessionLifecycleService {
686
890
  this.logger.warn(`⚠️ Failed to start video recording for ${session.getId()}: ${err.message}`);
687
891
  }
688
892
  }
689
- if ((isDashboardEnabled && shouldSaveLogs) || (isVideoRecordingEnabled && shouldSaveLogs)) {
893
+ // A session another server runs is always registered, whatever the
894
+ // dashboard and video settings: SESSION_MANAGER is where the session
895
+ // gateway finds it to forward its commands.
896
+ const routedElsewhere = session.getType() !== SessionType_1.default.LOCAL;
897
+ if (routedElsewhere ||
898
+ (isDashboardEnabled && shouldSaveLogs) ||
899
+ (isVideoRecordingEnabled && shouldSaveLogs)) {
690
900
  SessionManager_1.SESSION_MANAGER.addSession(session.getId(), session);
691
- if (this.isHub(context.pluginArgs) && isDashboardEnabled) {
901
+ if (this.isHub(context.pluginArgs) && isDashboardEnabled && shouldSaveLogs) {
692
902
  yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted(caps, session, device);
693
903
  }
904
+ else if (routedElsewhere) {
905
+ yield this.recordRoutedSession(caps, session, device);
906
+ }
907
+ }
908
+ });
909
+ }
910
+ /**
911
+ * The row a hub keeps for a session it routes to a node or a cloud when the
912
+ * dashboard's own (onSessionStarted) isn't written: with the dashboard off,
913
+ * and for every cloud session. After a restart the hub finds where the
914
+ * session runs, and who owns it, only from this row (SessionLocator,
915
+ * recoverActiveSessions, SessionOwnerResolver): without it every node
916
+ * session was sent to the hub's own Appium, which had no such session.
917
+ *
918
+ * Only what routing and ownership need: no profiling, logs or dashboard
919
+ * event. onSessionStopped closes it like any other row. A local session's
920
+ * row is still the dashboard's alone. A failed write is logged, not
921
+ * thrown: the session exists, and is routed from memory until a restart.
922
+ */
923
+ recordRoutedSession(caps, session, device) {
924
+ return __awaiter(this, void 0, void 0, function* () {
925
+ var _a, _b, _c;
926
+ try {
927
+ yield prisma_1.prisma.session.create({
928
+ data: Object.assign(Object.assign({ id: session.getId(), name: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.SESSION_NAME] || undefined }, (0, event_manager_1.storedSessionCapabilities)(lodash_1.default.assign({}, session.getCapabilities()))), { node_id: device.nodeId || '', has_live_video: false, video_recording_enabled: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.VIDEO_RECORDING] === true, device_udid: device.udid || '', device_platform: device.platform || '', device_version: device.sdk || '', device_name: device.name, status: 'running', api_key_id: (_a = session.apiKeyId) !== null && _a !== void 0 ? _a : null, user_id: (_b = session.userId) !== null && _b !== void 0 ? _b : null }),
929
+ });
930
+ }
931
+ catch (err) {
932
+ this.logger.warn(`Could not record session ${session.getId()} on ${device.udid}: ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : err}. ` +
933
+ 'It is routed from memory, and a restart of this hub loses it.');
694
934
  }
695
935
  });
696
936
  }
697
937
  handleSessionFailure(session, device, isRemote) {
698
938
  return __awaiter(this, void 0, void 0, function* () {
699
- yield (0, device_service_1.unblockDevice)(device.udid, device.host);
939
+ yield (0, device_service_1.releasePendingClaim)(device);
700
940
  yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, '');
701
941
  if (isRemote) {
702
942
  typedi_1.Container.get(CircuitBreaker_1.CircuitBreaker).recordFailure(device.host);
@@ -704,15 +944,26 @@ let SessionLifecycleService = class SessionLifecycleService {
704
944
  this.throwProperError(session, device.host);
705
945
  });
706
946
  }
707
- forwardSessionRequest(device, caps) {
708
- return __awaiter(this, void 0, void 0, function* () {
947
+ /**
948
+ * POST the create to the phone's node (or cloud provider). `hubToken` is
949
+ * this hub's create token for a peer Xenon node (HubSessionTokenIssuer),
950
+ * sent as `x-xenon-hub-token`; nothing else of the client's goes with it.
951
+ */
952
+ forwardSessionRequest(device_1, caps_1) {
953
+ return __awaiter(this, arguments, void 0, function* (device, caps, hubToken = null) {
709
954
  const context = typedi_1.Container.get(PluginContext_1.PluginContext);
710
- const remoteUrl = `${(0, helpers_1.nodeUrl)(device, context.nodeBasePath)}/session`;
955
+ const remoteUrl = `${yield (0, nodeWebDriverUrl_1.nodeWebDriverUrl)(device, context.nodeBasePath)}/session`;
956
+ const headers = { 'Content-Type': 'application/json' };
957
+ if (hubToken)
958
+ headers[hubSessionToken_1.HUB_TOKEN_HEADER] = hubToken;
959
+ // Sent once, with a create's timeout (REMOTE_CREATE_TIMEOUT_MS).
711
960
  const config = {
712
961
  method: 'post',
713
962
  url: remoteUrl,
714
- headers: { 'Content-Type': 'application/json' },
963
+ headers,
715
964
  data: { capabilities: caps },
965
+ timeout: exports.REMOTE_CREATE_TIMEOUT_MS,
966
+ retry: false,
716
967
  };
717
968
  if (context.pluginArgs.proxy) {
718
969
  this.logger.info(`Added proxy to axios config: ${JSON.stringify(context.pluginArgs.proxy)}`);
@@ -793,6 +1044,11 @@ let SessionLifecycleService = class SessionLifecycleService {
793
1044
  typeof session === 'object' &&
794
1045
  Object.prototype.hasOwnProperty.call(session, 'error')) {
795
1046
  let errorMessage = session.error;
1047
+ // Appium answers a refused create as `{ error: <Error> }`. Rethrow it, so
1048
+ // the client gets Appium's reason and status; JSON.stringify of an Error
1049
+ // is `{}`.
1050
+ if (errorMessage instanceof Error)
1051
+ throw errorMessage;
796
1052
  if (typeof errorMessage === 'object') {
797
1053
  errorMessage = JSON.stringify(errorMessage);
798
1054
  }
@@ -818,7 +1074,7 @@ let SessionLifecycleService = class SessionLifecycleService {
818
1074
  // which releases ports and archives video twice.
819
1075
  if (sessionId) {
820
1076
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
821
- yield (0, device_service_1.unblockDeviceMatchingFilter)({ session_id: sessionId });
1077
+ yield (0, device_service_1.releaseSessionDevices)(sessionId);
822
1078
  this.logger.info(`📱 Unblocking the device that is blocked for session ${sessionId}`);
823
1079
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
824
1080
  if (session && !session.isStopping) {
@@ -847,6 +1103,7 @@ let SessionLifecycleService = class SessionLifecycleService {
847
1103
  }
848
1104
  finally {
849
1105
  if (sessionId) {
1106
+ typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
850
1107
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
851
1108
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
852
1109
  if (!session) {
@@ -872,6 +1129,7 @@ let SessionLifecycleService = class SessionLifecycleService {
872
1129
  }
873
1130
  yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, status, reason);
874
1131
  SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
1132
+ typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).forget(sessionId);
875
1133
  try {
876
1134
  const { getSessionById } = yield Promise.resolve().then(() => __importStar(require('../dashboard/services/session-service')));
877
1135
  const sessionData = yield getSessionById(sessionId);
@@ -956,7 +1214,7 @@ let SessionLifecycleService = class SessionLifecycleService {
956
1214
  return __awaiter(this, void 0, void 0, function* () {
957
1215
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
958
1216
  try {
959
- yield (0, device_service_1.unblockDeviceMatchingFilter)({ session_id: sessionId });
1217
+ yield (0, device_service_1.releaseSessionDevices)(sessionId);
960
1218
  }
961
1219
  catch (err) {
962
1220
  this.logger.warn(`[shutdown] unblock failed for ${sessionId}: ${err.message}`);
@@ -979,6 +1237,7 @@ let SessionLifecycleService = class SessionLifecycleService {
979
1237
  this.logger.warn(`[shutdown] onSessionStopped failed for ${sessionId}: ${err.message}`);
980
1238
  }
981
1239
  SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
1240
+ typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
982
1241
  }));
983
1242
  });
984
1243
  }
@@ -23,6 +23,7 @@ const typedi_1 = require("typedi");
23
23
  const logger_1 = __importDefault(require("../logger"));
24
24
  const SessionManager_1 = require("../sessions/SessionManager");
25
25
  const SessionLifecycleService_1 = require("./SessionLifecycleService");
26
+ const SessionType_1 = __importDefault(require("../enums/SessionType"));
26
27
  // Graceful shutdown phase that runs BEFORE the existing infrastructure
27
28
  // teardown in index.ts:cleanup(). Active sessions get a bounded chance to
28
29
  // archive their video + release ports + mark themselves failed, so we don't
@@ -48,7 +49,12 @@ let ShutdownCoordinator = class ShutdownCoordinator {
48
49
  return { attempted: 0, completed: 0 };
49
50
  }
50
51
  this.draining = true;
51
- const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions();
52
+ // Only this server's own sessions: they die with the process. A node's or
53
+ // a cloud provider's session keeps running there, and the restarted hub
54
+ // routes it again from its database (SessionManager.recoverActiveSessions).
55
+ // Finalizing it here released the phone and closed the row, so recovery
56
+ // found nothing, and the node held a session no client could reach.
57
+ const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions().filter((session) => session.getType() === SessionType_1.default.LOCAL);
52
58
  if (sessions.length === 0) {
53
59
  this.logger.info('No active sessions to drain');
54
60
  return { attempted: 0, completed: 0 };
@@ -32,6 +32,9 @@ const EventLogService_1 = require("./EventLogService");
32
32
  const SocketEvents_1 = require("../enums/SocketEvents");
33
33
  const callerTeamIds_1 = require("./device-access/callerTeamIds");
34
34
  const DeviceTeamResolver_1 = require("./device-access/DeviceTeamResolver");
35
+ const upgradeRouter_1 = require("../app/ws/upgradeRouter");
36
+ /** socket.io's default path, which the dashboard and nodes connect to. */
37
+ const SOCKET_IO_PATH = '/socket.io';
35
38
  const SESSION_COOKIE = 'xenon_dashboard_session';
36
39
  function readCookie(cookieHeader, name) {
37
40
  if (!cookieHeader)
@@ -54,7 +57,13 @@ let SocketServer = SocketServer_1 = class SocketServer {
54
57
  this.deliveries = new Map();
55
58
  }
56
59
  initialize(server) {
57
- this.io = new socket_io_1.Server(server, {
60
+ // socket.io (engine.io) takes its websocket transport by adding its own
61
+ // `upgrade` listener, which would also see every other upgrade and end
62
+ // the ones it doesn't own after 1 s. The upgrade router moves that
63
+ // listener behind socket.io's path, engine.io's own test (a prefix match
64
+ // on the raw URL), so it sees only its own upgrades (upgradeRouter.ts).
65
+ this.io = (0, upgradeRouter_1.upgradeRouterFor)(server).adopt('socket.io', (req) => { var _a; return String((_a = req.url) !== null && _a !== void 0 ? _a : '').startsWith(`${SOCKET_IO_PATH}/`); }, () => new socket_io_1.Server(server, {
66
+ path: SOCKET_IO_PATH,
58
67
  // Same-origin only. Browsers on a different origin are blocked at the
59
68
  // handshake; server-to-server node connections send no Origin header
60
69
  // and are unaffected. Matches the apiRouter cors({origin:false}) policy.
@@ -62,7 +71,7 @@ let SocketServer = SocketServer_1 = class SocketServer {
62
71
  origin: false,
63
72
  methods: ['GET', 'POST'],
64
73
  },
65
- });
74
+ }));
66
75
  this.io.use((socket, next) => __awaiter(this, void 0, void 0, function* () {
67
76
  try {
68
77
  const identity = yield this.authenticate(socket);