@xenon-device-management/xenon 2.14.0 → 2.15.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 (259) hide show
  1. package/README.md +7 -4
  2. package/lib/package.json +5 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
  17. package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
  36. package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
  38. package/lib/public/assets/index-B9KQ6LsY.js +15 -0
  39. package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
  40. package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
  56. package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
  60. package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
  61. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
  62. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +5 -5
  77. package/lib/src/app/dashboardPluginLink.js +117 -0
  78. package/lib/src/app/index.js +13 -34
  79. package/lib/src/app/openapi/control.yaml +6 -4
  80. package/lib/src/app/openapi/grid.yaml +8 -2
  81. package/lib/src/app/openapi/identity.yaml +103 -31
  82. package/lib/src/app/openapi/platform.yaml +33 -15
  83. package/lib/src/app/openapi/sessions.yaml +10 -3
  84. package/lib/src/app/routers/apikeys.js +14 -2
  85. package/lib/src/app/routers/auth.js +29 -5
  86. package/lib/src/app/routers/control.js +13 -5
  87. package/lib/src/app/routers/dashboard.js +42 -27
  88. package/lib/src/app/routers/grid.js +5 -3
  89. package/lib/src/app/routers/profile.js +21 -0
  90. package/lib/src/app/routers/users.js +10 -5
  91. package/lib/src/app/swagger.js +2 -0
  92. package/lib/src/app/ws/h264StreamWs.js +10 -1
  93. package/lib/src/config.js +1 -0
  94. package/lib/src/dashboard/commands.js +5 -5
  95. package/lib/src/dashboard/event-manager.js +49 -61
  96. package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
  97. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  98. package/lib/src/data-service/device-store.js +14 -1
  99. package/lib/src/data-service/prisma-store.js +19 -7
  100. package/lib/src/data-service/web-config-service.js +7 -0
  101. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  102. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  103. package/lib/src/device-utils.js +20 -2
  104. package/lib/src/gateway/defaultGateway.js +1 -1
  105. package/lib/src/gateway/forwardToNode.js +2 -0
  106. package/lib/src/gateway/healReport.js +87 -0
  107. package/lib/src/gateway/sessionGateway.js +12 -2
  108. package/lib/src/generated/client/edge.js +4 -3
  109. package/lib/src/generated/client/index-browser.js +1 -0
  110. package/lib/src/generated/client/index.d.ts +32 -0
  111. package/lib/src/generated/client/index.js +4 -3
  112. package/lib/src/generated/client/package.json +1 -1
  113. package/lib/src/generated/client/schema.prisma +3 -0
  114. package/lib/src/generated/client/wasm.js +1 -0
  115. package/lib/src/interceptors/CommandInterceptor.js +243 -181
  116. package/lib/src/middleware/authMiddleware.js +15 -3
  117. package/lib/src/middleware/commandCaller.js +3 -3
  118. package/lib/src/middleware/verifyCredential.js +58 -1
  119. package/lib/src/plugin.js +8 -3
  120. package/lib/src/services/AICommandService.js +9 -2
  121. package/lib/src/services/AIService.js +192 -42
  122. package/lib/src/services/EmailService.js +29 -0
  123. package/lib/src/services/OrphanSweeper.js +2 -0
  124. package/lib/src/services/SelectorVerificationJob.js +15 -4
  125. package/lib/src/services/ServerManager.js +11 -1
  126. package/lib/src/services/SessionLifecycleService.js +86 -26
  127. package/lib/src/services/SocketServer.js +19 -3
  128. package/lib/src/services/bug-report/video-slice.js +4 -1
  129. package/lib/src/services/device-access/leaseOverride.js +7 -5
  130. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  131. package/lib/src/services/healing/HealEtalonService.js +3 -2
  132. package/lib/src/services/healing/HealingOrchestrator.js +55 -72
  133. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  134. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  135. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  136. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  137. package/lib/src/services/healing/resilioPath.js +274 -0
  138. package/lib/src/services/logcat/SessionDeviceLogs.js +271 -0
  139. package/lib/src/services/logcat/deviceLogBook.js +163 -0
  140. package/lib/src/services/ocr/ocrData.js +118 -0
  141. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  142. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  143. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  144. package/lib/src/services/passwordResetLink.js +50 -9
  145. package/lib/src/services/passwordResetMode.js +3 -1
  146. package/lib/src/services/publicUrl.js +45 -0
  147. package/lib/src/services/session/sessionIdentity.js +5 -3
  148. package/lib/src/services/session/shutdownReason.js +26 -0
  149. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  150. package/lib/src/services/token/mintedLifetime.js +51 -0
  151. package/lib/src/sessions/SessionManager.js +8 -2
  152. package/lib/src/sessions/sessionMemory.js +25 -0
  153. package/lib/test/helpers/fake-ai-provider.js +188 -0
  154. package/lib/test/helpers/loki-stores.js +33 -0
  155. package/lib/test/helpers/page-sources.js +164 -0
  156. package/lib/test/helpers/scratch-database.js +57 -13
  157. package/lib/test/integration/audit-ingest.spec.js +10 -10
  158. package/lib/test/integration/auth-flow.spec.js +9 -14
  159. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  160. package/lib/test/integration/bug-report-route.spec.js +22 -2
  161. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  162. package/lib/test/integration/forgot-password.spec.js +17 -18
  163. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  164. package/lib/test/integration/migration.spec.js +6 -1
  165. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  166. package/lib/test/integration/ports-router.spec.js +4 -4
  167. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  168. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  169. package/lib/test/integration/profile-tokens.spec.js +10 -16
  170. package/lib/test/integration/recordings-router.spec.js +16 -16
  171. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  172. package/lib/test/integration/role-matrix.spec.js +4 -9
  173. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  174. package/lib/test/integration/sdk-version.spec.js +2 -2
  175. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  176. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  177. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  178. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  179. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  180. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  181. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  182. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  183. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  184. package/lib/test/integration/users-flow.spec.js +7 -12
  185. package/lib/test/integration/users-lockout.spec.js +5 -3
  186. package/lib/test/unit/EmailService.test.js +66 -0
  187. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  188. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  189. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  190. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  191. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  192. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  193. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  194. package/lib/test/unit/apikeys-router.spec.js +84 -0
  195. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  196. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  197. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  198. package/lib/test/unit/dashboard-events-device-logs.spec.js +207 -0
  199. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  200. package/lib/test/unit/device-log-book.spec.js +180 -0
  201. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  202. package/lib/test/unit/event-log-session-data.spec.js +244 -0
  203. package/lib/test/unit/failure-analysis.spec.js +182 -0
  204. package/lib/test/unit/failure-categories.spec.js +128 -0
  205. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  206. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  207. package/lib/test/unit/heal-report.spec.js +71 -0
  208. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  209. package/lib/test/unit/healing-tier-gate.spec.js +73 -29
  210. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  211. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
  212. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  213. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  214. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  215. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  216. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  217. package/lib/test/unit/ocr-offline.spec.js +213 -0
  218. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  219. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  220. package/lib/test/unit/omni-vision.spec.js +5 -7
  221. package/lib/test/unit/package-engines.spec.js +27 -0
  222. package/lib/test/unit/passwordResetMode.test.js +10 -2
  223. package/lib/test/unit/profile-router.test.js +122 -1
  224. package/lib/test/unit/queue-requester.spec.js +1 -1
  225. package/lib/test/unit/reset-link-address.spec.js +266 -0
  226. package/lib/test/unit/reset-link.test.js +10 -4
  227. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  228. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  229. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  230. package/lib/test/unit/selector-verification-job.spec.js +13 -13
  231. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  232. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  233. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  234. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  235. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  236. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  237. package/lib/test/unit/session-device-logs.spec.js +347 -0
  238. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  239. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  240. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  241. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  242. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  243. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  244. package/lib/test/unit/verify-credential.spec.js +33 -0
  245. package/lib/test/unit/vision-assertion.spec.js +3 -2
  246. package/lib/test/unit/web-config-service.spec.js +19 -0
  247. package/lib/tsconfig.tsbuildinfo +1 -1
  248. package/package.json +5 -5
  249. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  250. package/prisma/schema.prisma +3 -0
  251. package/schema.json +5 -5
  252. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  253. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  254. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  255. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  256. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  257. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  258. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  259. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -23,6 +23,7 @@ const typedi_1 = require("typedi");
23
23
  const nodemailer_1 = __importDefault(require("nodemailer"));
24
24
  const config_1 = require("../config");
25
25
  const logger_1 = __importDefault(require("../logger"));
26
+ const passwordResetLink_1 = require("./passwordResetLink");
26
27
  let EmailService = class EmailService {
27
28
  constructor() {
28
29
  this.log = logger_1.default.scope('Email');
@@ -55,6 +56,34 @@ let EmailService = class EmailService {
55
56
  'over the account for the link lifetime. Configure XENON_SMTP_URL, or unset this and ' +
56
57
  'issue links from the dashboard Users page.');
57
58
  }
59
+ /**
60
+ * Startup notice about XENON_PUBLIC_URL, the address links Xenon hands out
61
+ * are built from (never the request's Host, which the asker chooses):
62
+ *
63
+ * - set but refused (not an http(s) server address): whatever the mail
64
+ * setup, since it also decides each device's dashboard_link. The value is
65
+ * not logged: a user name and password in it is one reason to refuse it.
66
+ * - unset while Xenon could send reset links: the sign-in page's "forgot
67
+ * password" then sends people to an administrator, and an administrator's
68
+ * reset link is handed back to them rather than emailed.
69
+ */
70
+ warnAboutPublicUrl() {
71
+ var _a;
72
+ if (((_a = config_1.config.publicUrl) === null || _a === void 0 ? void 0 : _a.trim()) && (0, passwordResetLink_1.resetLinkBase)() === null) {
73
+ this.warnLog('XENON_PUBLIC_URL is set but is not an http(s) server address such as ' +
74
+ 'https://xenon.example.com or http://lab-mac:4723 (a trailing /xenon is fine; any ' +
75
+ 'other path, a query, a fragment or a user name is not), so Xenon ignores it: it ' +
76
+ 'emails no password-reset links, and device dashboard links are paths on this server.');
77
+ return;
78
+ }
79
+ if (!this.canDeliver() || (0, passwordResetLink_1.resetLinkBase)() !== null)
80
+ return;
81
+ this.warnLog("XENON_PUBLIC_URL is not set to this server's address, so Xenon sends no password-reset " +
82
+ 'links: "Forgot password" asks people to contact an administrator, and the Users ' +
83
+ 'page hands an administrator the link instead of emailing it. Set XENON_PUBLIC_URL to ' +
84
+ 'the http(s) address people reach this server at, such as https://xenon.example.com ' +
85
+ 'or http://lab-mac:4723 (a trailing /xenon is fine; any other path is not).');
86
+ }
58
87
  send(mail) {
59
88
  return __awaiter(this, void 0, void 0, function* () {
60
89
  const smtpUrl = config_1.config.smtpUrl;
@@ -26,6 +26,7 @@ const event_manager_1 = require("../dashboard/event-manager");
26
26
  const SessionStatus_1 = require("../types/SessionStatus");
27
27
  const device_service_1 = require("../data-service/device-service");
28
28
  const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
29
+ const sessionMemory_1 = require("../sessions/sessionMemory");
29
30
  let OrphanSweeper = class OrphanSweeper {
30
31
  constructor() {
31
32
  this.log = logger_1.default.scope('OrphanSweeper');
@@ -74,6 +75,7 @@ let OrphanSweeper = class OrphanSweeper {
74
75
  // back before the phone is released; for a previous process's it is
75
76
  // the ledger's, at boot (PhoneNetworkRestore).
76
77
  yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(s.id, 'heartbeat timeout');
78
+ (0, sessionMemory_1.forgetSessionMemory)(s.id);
77
79
  // This session's claim only, not every row with its udid: the phone
78
80
  // may be another session's by now (deviceClaims.ts).
79
81
  yield (0, device_service_1.releaseSessionDevices)(s.id);
@@ -32,8 +32,11 @@ const selectorKeys_1 = require("./selector-health/selectorKeys");
32
32
  const log = logger_1.default.scope('SelectorVerification');
33
33
  /**
34
34
  * Threshold of distinct clean build_ids required to promote a Pending
35
- * SelectorState row to Resolved. "Clean" means at least one findElement
36
- * for the (strategy, selector) tuple ran in the build with no heal.
35
+ * SelectorState row to Resolved. "Clean" means the selector was found without
36
+ * healing in the build: a findElement of the (strategy, selector) tuple that
37
+ * answered an element, or a findElements that answered at least one, and no
38
+ * heal of it. Through 2.14 a find that failed outright counted too, so a
39
+ * selector that was never found again was verified as fixed.
37
40
  */
38
41
  const CLEAN_BUILDS_TO_RESOLVE = 3;
39
42
  /**
@@ -127,12 +130,20 @@ let SelectorVerificationJob = class SelectorVerificationJob {
127
130
  // that ran under a CI build (build_id IS NOT NULL); ad-hoc local runs
128
131
  // intentionally don't move verification forward.
129
132
  //
133
+ // A find that found the selector is one that didn't fail (`is_error`,
134
+ // the dashboard's record of an error answer) and didn't answer an empty
135
+ // list (`{"value":[]`, findElements finding nothing). The command log
136
+ // keeps the answer as JSON.stringify wrote it, or as the node sent it.
137
+ //
130
138
  // Identifier names (PascalCase tables, camelCase createdAt) match the
131
139
  // actual SQLite schema generated by Prisma — see
132
140
  // `sqlite3 prisma/dev.db ".schema SessionLog"`.
133
141
  const heals = yield this.prisma.$queryRaw `
134
142
  SELECT s."build_id" AS build_id,
135
- MAX(CASE WHEN sl."is_healed" THEN 1 ELSE 0 END) AS healed
143
+ MAX(CASE WHEN sl."is_healed" THEN 1 ELSE 0 END) AS healed,
144
+ MAX(CASE WHEN NOT sl."is_healed" AND NOT sl."is_error"
145
+ AND sl."response" NOT LIKE '{"value":[]%'
146
+ THEN 1 ELSE 0 END) AS found
136
147
  FROM "SessionLog" sl
137
148
  INNER JOIN "Session" s ON sl."session_id" = s."id"
138
149
  WHERE sl."original_strategy" = ${row.original_strategy}
@@ -142,7 +153,7 @@ let SelectorVerificationJob = class SelectorVerificationJob {
142
153
  AND s."build_id" IS NOT NULL
143
154
  GROUP BY s."build_id"
144
155
  `;
145
- const cleanBuildCount = heals.filter((r) => Number(r.healed) === 0).length;
156
+ const cleanBuildCount = heals.filter((r) => Number(r.healed) === 0 && Number(r.found) === 1).length;
146
157
  if (cleanBuildCount >= CLEAN_BUILDS_TO_RESOLVE) {
147
158
  yield this.promoteToResolved(row);
148
159
  }
@@ -79,6 +79,7 @@ const IPluginArgs_1 = require("../interfaces/IPluginArgs");
79
79
  const config_service_1 = require("../data-service/config-service");
80
80
  const PluginContext_1 = require("../PluginContext");
81
81
  const SelfHealingSwitch_1 = require("./settings/SelfHealingSwitch");
82
+ const aiEngineSettings_1 = require("./settings/aiEngineSettings");
82
83
  const device_store_1 = require("../data-service/device-store");
83
84
  const device_utils_1 = require("../device-utils");
84
85
  const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
@@ -140,6 +141,9 @@ let ServerManager = ServerManager_1 = class ServerManager {
140
141
  // The Settings page's AI self-healing toggle, which the command interceptor
141
142
  // reads from memory at every command: load it before a command can arrive.
142
143
  yield typedi_1.Container.get(SelfHealingSwitch_1.SelfHealingSwitch).load();
144
+ // The AI engine page's provider, model and base URL, over the startup
145
+ // options applied above (what a cleared value goes back to).
146
+ yield typedi_1.Container.get(aiEngineSettings_1.AiEngineSettings).load();
143
147
  this.registerRoutes(expressApp, httpServer, cliArgs, pluginArgs);
144
148
  yield this.bootEmulators(pluginArgs);
145
149
  this.registerDependenciesInContainer(pluginArgs, cliArgs, nodeId);
@@ -397,6 +401,7 @@ let ServerManager = ServerManager_1 = class ServerManager {
397
401
  const { bootstrapIdentity } = yield Promise.resolve().then(() => __importStar(require('./identity/bootstrap')));
398
402
  yield bootstrapIdentity();
399
403
  typedi_1.Container.get(EmailService_1.EmailService).warnIfLogFallbackEnabled();
404
+ typedi_1.Container.get(EmailService_1.EmailService).warnAboutPublicUrl();
400
405
  const { startUserSessionCleanupCron } = yield Promise.resolve().then(() => __importStar(require('./identity/sessionCleanupCron')));
401
406
  startUserSessionCleanupCron();
402
407
  // Hub-issued JWT signing key (REST/MCP tokens, stream tickets) — key
@@ -445,7 +450,12 @@ let ServerManager = ServerManager_1 = class ServerManager {
445
450
  });
446
451
  }
447
452
  registerRoutes(expressApp, httpServer, cliArgs, pluginArgs) {
448
- expressApp.use('/xenon', (0, app_1.createRouter)(pluginArgs));
453
+ expressApp.use('/xenon', (0, app_1.createRouter)(pluginArgs, {
454
+ address: cliArgs.address,
455
+ port: cliArgs.port,
456
+ // Appium serves HTTPS with both of these (base-driver's server()).
457
+ tls: !!(cliArgs.sslCertificatePath && cliArgs.sslKeyPath),
458
+ }));
449
459
  // The session gateway goes in front of Appium's routes: Xenon's own
450
460
  // /wd-internal calls (with the per-process secret), per-command auth
451
461
  // (XENON_REQUIRE_COMMAND_AUTH), then, on a hub, forwarding of the sessions
@@ -69,7 +69,6 @@ const device_utils_1 = require("../device-utils");
69
69
  const TracingService_1 = require("./TracingService");
70
70
  const PortAllocator_1 = require("./PortAllocator");
71
71
  const XenonCapabilityManager_1 = require("../XenonCapabilityManager");
72
- const JwtKeyService_1 = require("./token/JwtKeyService");
73
72
  const sessionIdentity_1 = require("./session/sessionIdentity");
74
73
  const leaseSessionCaps_1 = require("./lease/leaseSessionCaps");
75
74
  const sessionCredentials_1 = require("./session/sessionCredentials");
@@ -80,6 +79,7 @@ const callerTeamIds_1 = require("./device-access/callerTeamIds");
80
79
  const queueVisibility_1 = require("./device-access/queueVisibility");
81
80
  const appVisibility_1 = require("./device-access/appVisibility");
82
81
  const LiveSessionOwners_1 = require("./device-access/LiveSessionOwners");
82
+ const sessionMemory_1 = require("../sessions/sessionMemory");
83
83
  const SessionMetricsService_1 = require("./metrics/SessionMetricsService");
84
84
  const appCapability_1 = require("./session/appCapability");
85
85
  const CircuitBreaker_1 = require("../data-service/CircuitBreaker");
@@ -100,6 +100,21 @@ const async_lock_1 = __importDefault(require("async-lock"));
100
100
  const base_driver_1 = require("@appium/base-driver");
101
101
  const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
102
102
  const interceptorOptions_1 = require("./interceptor/interceptorOptions");
103
+ /**
104
+ * A session token admits a session only with the `sessions` scope, as a key
105
+ * pair does. Tokens minted before this check carry no `scopes` claim, and
106
+ * POST /auth/token gave one to any credential, a read-only one included:
107
+ * they are refused too.
108
+ */
109
+ function sessionTokenGrantsSessions(scopes) {
110
+ if (typeof scopes !== 'string')
111
+ return false;
112
+ const owned = scopes.split(',').map((s) => s.trim());
113
+ return owned.includes('sessions') || owned.includes('admin');
114
+ }
115
+ const SESSION_TOKEN_LACKS_SCOPE = 'session rejected: xe:options.sessionToken carries no `sessions` scope; tokens minted by ' +
116
+ 'Xenon 2.14 or earlier carry none — mint a new one with POST /xenon/api/auth/token ' +
117
+ '({"audience":"xenon-mcp"}) using a credential that has the `sessions` scope.';
103
118
  /**
104
119
  * How long the hub waits for a node (or cloud provider) to create a session.
105
120
  * A create is sent once (InternalRequestConfig `retry: false`): the node may
@@ -263,6 +278,7 @@ let SessionLifecycleService = class SessionLifecycleService {
263
278
  apiKeyId: authResult.apiKeyId,
264
279
  userId: authResult.userId,
265
280
  appDownload,
281
+ allocatedAt: Date.now(),
266
282
  };
267
283
  // The grant opens one phone, on this node. A token taken to another node
268
284
  // (an emulator's udid repeats across machines), or naming a phone this
@@ -391,7 +407,7 @@ let SessionLifecycleService = class SessionLifecycleService {
391
407
  this.logger.debug(`📱 Removing pending session with capability_id: ${allocation.pendingSessionId}`);
392
408
  yield (0, pending_sessions_service_1.removePendingSession)(allocation.pendingSessionId);
393
409
  if (this.isCreateSessionResponseInternal(session)) {
394
- yield this.finalizeSession(session, device, caps, driver, remote, allocation.apiKeyId, allocation.userId);
410
+ yield this.finalizeSession(session, device, caps, driver, remote, allocation.apiKeyId, allocation.userId, allocation.allocatedAt);
395
411
  }
396
412
  else {
397
413
  yield this.handleSessionFailure(session, device, remote);
@@ -505,9 +521,11 @@ let SessionLifecycleService = class SessionLifecycleService {
505
521
  // took out of `xe:options` (or its `xenon:options` alias): an access-key +
506
522
  // token pair, or a session token. Today this is best-effort: if none is
507
523
  // supplied we log a warning and let the request through to avoid breaking
508
- // pre-existing test clients, unless XENON_REQUIRE_SESSION_TOKEN is on. A
509
- // supplied key that is invalid / revoked / has insufficient scope is
510
- // rejected. Respects the global `authDisabled` flag.
524
+ // pre-existing test clients, unless XENON_REQUIRE_SESSION_TOKEN is on.
525
+ // Credentials that don't verify — a wrong, revoked or expired key or token,
526
+ // or one whose user is deleted or not ACTIVE — count as none. Credentials
527
+ // that verify without the `sessions` scope are rejected. Respects the
528
+ // global `authDisabled` flag.
511
529
  //
512
530
  // Returns the caller's { apiKeyId, callerTeamIds } so allocation can filter
513
531
  // devices, and app resolution uploaded apps, by team. `apiKeyId` is null
@@ -549,23 +567,47 @@ let SessionLifecycleService = class SessionLifecycleService {
549
567
  }
550
568
  const { ApiKeyService } = yield Promise.resolve().then(() => __importStar(require('./ApiKeyService')));
551
569
  const svc = typedi_1.Container.get(ApiKeyService);
570
+ const { verifyKeyPairCredential, verifySessionTokenCredential, isWrongSessionToken } = yield Promise.resolve().then(() => __importStar(require('../middleware/verifyCredential')));
552
571
  // xe:options.{accessKey, token} — the only key-pair credential shape.
572
+ // Checked by REST's rule (verifyKeyPairCredential): a live key whose owner
573
+ // exists and is ACTIVE. The key alone (verifyPair) let a user set to
574
+ // Inactive, or deleted, keep creating sessions as themselves. The owner
575
+ // found here is the one lookup the session's scope and lease use.
553
576
  const pair = credentials.pair;
554
- const row = pair ? yield svc.verifyPair(pair.accessKey, pair.token) : null;
577
+ const verifiedPair = pair ? yield verifyKeyPairCredential(pair.accessKey, pair.token) : null;
578
+ // The session token, verified once for both the gate and attribution, by
579
+ // REST's rule too: a live signature and an ACTIVE subject, looked up now.
580
+ let tokenCheck;
581
+ const verifyToken = (t) => {
582
+ if (!tokenCheck)
583
+ tokenCheck = verifySessionTokenCredential(t);
584
+ return tokenCheck;
585
+ };
586
+ // Both readers below treat any failure as a token that doesn't check out:
587
+ // the gate refuses it as "invalid or expired", attribution ignores it. A
588
+ // check that could not run (the database or the signing key unavailable)
589
+ // is not that: it refuses the create here, as a key whose owner can't be
590
+ // looked up does, rather than run the session with no owner.
591
+ if (!verifiedPair && credentials.sessionToken) {
592
+ yield verifyToken(credentials.sessionToken).catch((err) => {
593
+ if (!isWrongSessionToken(err))
594
+ throw err;
595
+ });
596
+ }
555
597
  const { assertSessionTokenGate, sessionTokenGateEnabled } = yield Promise.resolve().then(() => __importStar(require('./sessionTokenGate')));
556
598
  try {
557
599
  yield assertSessionTokenGate({
558
600
  enabled: sessionTokenGateEnabled(),
559
- hasValidKeyPair: !!row,
601
+ hasValidKeyPair: !!verifiedPair,
560
602
  token: credentials.sessionToken,
561
- verify: (t) => typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(t, { audience: 'xenon-session' }),
603
+ verify: verifyToken,
562
604
  });
563
605
  }
564
606
  catch (err) {
565
607
  this.logger.error(`❌ ${err.message}`);
566
608
  throw new base_driver_1.errors.InvalidArgumentError(err.message);
567
609
  }
568
- if (!row) {
610
+ if (!verifiedPair) {
569
611
  // No key pair. An xe:options.sessionToken still identifies the caller,
570
612
  // so read it for attribution even when the gate is off — otherwise the
571
613
  // ownership guard fails closed on a session whose owner we actually know.
@@ -573,23 +615,36 @@ let SessionLifecycleService = class SessionLifecycleService {
573
615
  // The token's teamId claim narrows it the way a key's team does; kept
574
616
  // from the one verify rather than verifying again.
575
617
  let tokenTeamId = null;
618
+ let tokenScopes;
619
+ let tokenOwner;
576
620
  const identity = yield (0, sessionIdentity_1.resolveSessionIdentity)({
577
621
  row: null,
578
622
  sessionToken: credentials.sessionToken,
579
623
  verify: (t) => __awaiter(this, void 0, void 0, function* () {
580
- const payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(t, {
581
- audience: 'xenon-session',
582
- });
624
+ const { payload, user } = yield verifyToken(t);
583
625
  tokenTeamId = typeof (payload === null || payload === void 0 ? void 0 : payload.teamId) === 'string' ? payload.teamId : null;
626
+ tokenScopes = payload === null || payload === void 0 ? void 0 : payload.scopes;
627
+ tokenOwner = user;
584
628
  return payload;
585
629
  }),
586
630
  });
631
+ // A token that names its owner may create sessions only with the
632
+ // `sessions` scope, as a key pair may: refused, like a key without it.
633
+ if (identity.userId && !sessionTokenGrantsSessions(tokenScopes)) {
634
+ this.logger.error('Rejecting session: xe:options.sessionToken lacks the `sessions` scope');
635
+ throw new base_driver_1.errors.InvalidArgumentError(SESSION_TOKEN_LACKS_SCOPE);
636
+ }
587
637
  if (!identity.userId) {
588
638
  this.logger.warn('Session created without valid credentials. Pass `xe:options.accessKey` + ' +
589
639
  '`xe:options.token`, or `xe:options.sessionToken`.');
590
640
  }
591
- const leaseAccess = this.leaseAccessFor(identity.userId
592
- ? { kind: 'session-token', userId: identity.userId, teamId: tokenTeamId }
641
+ const leaseAccess = this.leaseAccessFor(identity.userId && tokenOwner
642
+ ? {
643
+ kind: 'session-token',
644
+ scopes: String(tokenScopes),
645
+ user: tokenOwner,
646
+ teamId: tokenTeamId,
647
+ }
593
648
  : { kind: 'none' });
594
649
  // A token's user is scoped as REST scopes them. No credentials at all
595
650
  // stays unscoped (the back-compat path), with no lookup.
@@ -599,6 +654,7 @@ let SessionLifecycleService = class SessionLifecycleService {
599
654
  teamId: identity.userId ? tokenTeamId : null,
600
655
  } });
601
656
  }
657
+ const { row, user: owner } = verifiedPair;
602
658
  if (!svc.hasScope(row, ['sessions'])) {
603
659
  this.logger.error('Rejecting session: credentials lack the `sessions` scope');
604
660
  throw new base_driver_1.errors.InvalidArgumentError('credentials are invalid, revoked, or lack the `sessions` scope');
@@ -607,7 +663,7 @@ let SessionLifecycleService = class SessionLifecycleService {
607
663
  const leaseAccess = this.leaseAccessFor({
608
664
  kind: 'api-key',
609
665
  scopes: row.scopes,
610
- userId: row.userId,
666
+ user: owner,
611
667
  teamId: (_a = row.teamId) !== null && _a !== void 0 ? _a : null,
612
668
  });
613
669
  // The phones and apps this session may use: REST's rule
@@ -642,7 +698,8 @@ let SessionLifecycleService = class SessionLifecycleService {
642
698
  // How a session that names a lease is judged, looked up at most once and
643
699
  // only when asked. Who may override follows canOverrideLease; which phones
644
700
  // it can see follows REST's computeTeamIds. Both need the owner's live role,
645
- // as authMiddleware reads it for every REST request.
701
+ // as authMiddleware reads it for every REST request: the owner the
702
+ // credential check looked up a moment ago, ACTIVE then.
646
703
  leaseAccessFor(credential) {
647
704
  let looked;
648
705
  return () => {
@@ -659,24 +716,24 @@ let SessionLifecycleService = class SessionLifecycleService {
659
716
  if (credential.kind === 'none') {
660
717
  return { canOverride: (0, leaseOverride_1.canOverrideLease)({ kind: 'none' }), teamIds: undefined };
661
718
  }
719
+ const { user } = credential;
662
720
  try {
663
- const { UserService } = yield Promise.resolve().then(() => __importStar(require('./UserService')));
664
- const found = yield typedi_1.Container.get(UserService).findById(credential.userId);
665
- const user = found && found.status === 'ACTIVE' ? found : null;
666
- const canOverride = (0, leaseOverride_1.canOverrideLease)(credential.kind === 'api-key'
667
- ? { kind: 'api-key', scopes: credential.scopes, user }
668
- : { kind: 'session-token', user });
669
- const role = (user === null || user === void 0 ? void 0 : user.role) === 'SUPER_ADMIN' || (user === null || user === void 0 ? void 0 : user.role) === 'ADMIN' ? user.role : 'MEMBER';
721
+ const canOverride = (0, leaseOverride_1.canOverrideLease)({
722
+ kind: credential.kind,
723
+ scopes: credential.scopes,
724
+ user,
725
+ });
726
+ const role = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' ? user.role : 'MEMBER';
670
727
  const teamIds = yield (0, callerTeamIds_1.computeTeamIds)({
671
728
  role,
672
- userId: credential.userId,
729
+ userId: user.id,
673
730
  apiKeyTeamId: credential.teamId,
674
731
  });
675
732
  return { canOverride, teamIds };
676
733
  }
677
734
  catch (err) {
678
735
  // Fail closed: no override, and the shared pool only.
679
- this.logger.warn(`Could not look up how user ${credential.userId} may use a lease: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
736
+ this.logger.warn(`Could not look up how user ${user.id} may use a lease: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
680
737
  return { canOverride: false, teamIds: [] };
681
738
  }
682
739
  });
@@ -790,7 +847,7 @@ let SessionLifecycleService = class SessionLifecycleService {
790
847
  }
791
848
  }
792
849
  finalizeSession(session_1, device_1, caps_1, driver_1, isRemote_1) {
793
- return __awaiter(this, arguments, void 0, function* (session, device, caps, driver, isRemote, apiKeyId = null, userId = null) {
850
+ return __awaiter(this, arguments, void 0, function* (session, device, caps, driver, isRemote, apiKeyId = null, userId = null, allocatedAt) {
794
851
  var _a;
795
852
  const sessionId = session.value[0];
796
853
  const sessionResponse = session.value[1];
@@ -826,6 +883,7 @@ let SessionLifecycleService = class SessionLifecycleService {
826
883
  const sessionInstance = this.createSessionInstance(sessionId, freshDevice, sessionResponse, xenonCapabilities, driver, webDriverUrl);
827
884
  sessionInstance.apiKeyId = apiKeyId;
828
885
  sessionInstance.userId = userId;
886
+ sessionInstance.allocatedAt = allocatedAt;
829
887
  // A session this server drives: its owner is known while it runs, row or
830
888
  // not (a node writes none for the hub's sessions; LiveSessionOwners).
831
889
  if (sessionInstance instanceof LocalSession_1.LocalSession) {
@@ -1148,6 +1206,7 @@ let SessionLifecycleService = class SessionLifecycleService {
1148
1206
  finally {
1149
1207
  if (sessionId) {
1150
1208
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
1209
+ (0, sessionMemory_1.forgetSessionMemory)(sessionId);
1151
1210
  // Before the lock's "still in memory?" check, which a dashboard-off
1152
1211
  // node fails. On a node this ends the figures it holds for its hub;
1153
1212
  // on a hub it collects a node session's last ones. Idempotent.
@@ -1263,6 +1322,7 @@ let SessionLifecycleService = class SessionLifecycleService {
1263
1322
  }
1264
1323
  SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
1265
1324
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
1325
+ (0, sessionMemory_1.forgetSessionMemory)(sessionId);
1266
1326
  yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
1267
1327
  }));
1268
1328
  });
@@ -33,9 +33,14 @@ 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
35
  const upgradeRouter_1 = require("../app/ws/upgradeRouter");
36
+ const sessionCommandSummary_1 = require("../dashboard/sessionCommandSummary");
36
37
  /** socket.io's default path, which the dashboard and nodes connect to. */
37
38
  const SOCKET_IO_PATH = '/socket.io';
38
39
  const SESSION_COOKIE = 'xenon_dashboard_session';
40
+ const EVENT_LOG_KEEPS = new Map([
41
+ [SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, null],
42
+ [SocketEvents_1.SocketEvents.SESSION_COMMAND, sessionCommandSummary_1.sessionCommandSummary],
43
+ ]);
39
44
  function readCookie(cookieHeader, name) {
40
45
  if (!cookieHeader)
41
46
  return undefined;
@@ -242,12 +247,23 @@ let SocketServer = SocketServer_1 = class SocketServer {
242
247
  if (this.io) {
243
248
  this.io.to('dashboard').emit(event, data);
244
249
  }
245
- typedi_1.Container.get(EventLogService_1.EventLogService).appendSafe({ type: event, payload: data });
250
+ this.logEvent(event, data);
251
+ }
252
+ /** Once per event, unscoped: whole, or what EVENT_LOG_KEEPS keeps of it. */
253
+ logEvent(event, data) {
254
+ const keep = EVENT_LOG_KEEPS.get(event);
255
+ if (keep === null)
256
+ return;
257
+ typedi_1.Container.get(EventLogService_1.EventLogService).appendSafe({
258
+ type: event,
259
+ payload: keep ? keep(data) : data,
260
+ });
246
261
  }
247
262
  /**
248
263
  * A dashboard event about one or more phones: each dashboard socket gets it
249
264
  * only if its caller can see the phone ({@link DeviceEventScope}), by the
250
- * same team rule as REST. The event log records it once, unscoped.
265
+ * same team rule as REST. The event log records it once, unscoped, as
266
+ * EVENT_LOG_KEEPS says.
251
267
  *
252
268
  * Each phone's events are delivered through one chain, in the order they
253
269
  * were emitted, whatever their scope shape: one that waits on a team lookup
@@ -262,7 +278,7 @@ let SocketServer = SocketServer_1 = class SocketServer {
262
278
  */
263
279
  emitToDashboardForDevices(event, data, scope) {
264
280
  var _a, _b;
265
- typedi_1.Container.get(EventLogService_1.EventLogService).appendSafe({ type: event, payload: data });
281
+ this.logEvent(event, data);
266
282
  const udids = 'udids' in scope ? scope.udids : [(_a = scope.udid) !== null && _a !== void 0 ? _a : ''];
267
283
  if (!this.hasScopedDashboard() && udids.every((udid) => !this.deliveries.has(udid))) {
268
284
  this.noteRow(scope);
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.sliceVideo = sliceVideo;
7
7
  const child_process_1 = require("child_process");
8
+ const ffmpegPath_1 = require("../../helpers/ffmpegPath");
8
9
  const logger_1 = __importDefault(require("../../logger"));
9
10
  function sliceVideo(inputPath, startSec, endSec, outPath, spawnFn = child_process_1.spawn) {
10
11
  return new Promise((resolve) => {
@@ -16,7 +17,9 @@ function sliceVideo(inputPath, startSec, endSec, outPath, spawnFn = child_proces
16
17
  '-c', 'copy',
17
18
  outPath,
18
19
  ];
19
- const proc = spawnFn('ffmpeg', args);
20
+ // Xenon's own ffmpeg, as everywhere else: a bare name isn't found by a
21
+ // server the Mac app started, and finds a system ffmpeg first otherwise.
22
+ const proc = spawnFn((0, ffmpegPath_1.resolveFfmpegPath)(), args);
20
23
  let stderr = '';
21
24
  if (proc.stderr) {
22
25
  proc.stderr.on('data', (chunk) => {
@@ -2,18 +2,20 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.canOverrideLease = canOverrideLease;
4
4
  function canOverrideLease(credential) {
5
- var _a, _b;
6
5
  switch (credential.kind) {
7
6
  case 'auth-disabled':
8
7
  return true;
9
- case 'api-key': {
8
+ case 'api-key':
9
+ case 'session-token': {
10
10
  if (!credential.user)
11
11
  return false;
12
12
  const scopes = new Set(credential.scopes.split(',').map((s) => s.trim()));
13
- return scopes.has('admin') || credential.user.role === 'SUPER_ADMIN';
13
+ const { role } = credential.user;
14
+ if (credential.kind === 'session-token') {
15
+ return role === 'SUPER_ADMIN' || (role === 'ADMIN' && scopes.has('admin'));
16
+ }
17
+ return scopes.has('admin') || role === 'SUPER_ADMIN';
14
18
  }
15
- case 'session-token':
16
- return ((_a = credential.user) === null || _a === void 0 ? void 0 : _a.role) === 'SUPER_ADMIN' || ((_b = credential.user) === null || _b === void 0 ? void 0 : _b.role) === 'ADMIN';
17
19
  default:
18
20
  return false;
19
21
  }
@@ -20,6 +20,9 @@ const xpath_1 = require("xpath");
20
20
  const typedi_1 = require("typedi");
21
21
  const logger_1 = __importDefault(require("../../logger"));
22
22
  const HealedLocatorGenerator_1 = require("./HealedLocatorGenerator");
23
+ const fingerprintIdentity_1 = require("./fingerprintIdentity");
24
+ /** What an element says in words, for telling it apart once its identifier differs. */
25
+ const TEXTS = ['text', 'label', 'content-desc'];
23
26
  class FuzzyXmlHealingProvider {
24
27
  constructor(etalonService) {
25
28
  this.etalonService = etalonService;
@@ -41,6 +44,12 @@ class FuzzyXmlHealingProvider {
41
44
  if (this.etalonService) {
42
45
  etalon = yield this.etalonService.getSignature(context.selector);
43
46
  }
47
+ // A fingerprint with only a rect and a type (every learnt one through
48
+ // 2.14) would heal to whatever element of that type is at that spot.
49
+ if (etalon && !(0, fingerprintIdentity_1.hasIdentity)(etalon.attributes)) {
50
+ this.logger.info('The baseline signature says nothing about which element it is; matching without it.');
51
+ etalon = null;
52
+ }
44
53
  const keywords = this.extractKeywords(context.selector);
45
54
  if (keywords.length === 0 && !etalon)
46
55
  return null;
@@ -135,6 +144,9 @@ class FuzzyXmlHealingProvider {
135
144
  if (etalonTag !== nodeTag && etalonTag !== 'xcuielementtypeany' && etalonTag !== 'unknown') {
136
145
  return 0; // Immediate rejection — wrong element type
137
146
  }
147
+ // Position can't heal to an element whose identifier says it's another one.
148
+ if (this.contradictsIdentifier(node, etalon))
149
+ return 0;
138
150
  // Tag matches perfectly
139
151
  totalWeight += 1.0;
140
152
  totalScore += 1.0;
@@ -260,6 +272,45 @@ class FuzzyXmlHealingProvider {
260
272
  }
261
273
  return finalScore;
262
274
  }
275
+ /**
276
+ * Whether `node` is, by its identifier, another element than the
277
+ * fingerprint's: the fingerprint has an identifier (an Android resource-id,
278
+ * compared without its package, or an iOS accessibility identifier), `node`
279
+ * has another one (sameIdentifier), and no text of it (text, label,
280
+ * content-desc) reads the same as the fingerprint's (sameText). Its
281
+ * position then counts for nothing.
282
+ *
283
+ * A renamed id that kept its text or description still heals, and so does
284
+ * a renamed text that kept its id. An element without an identifier, where
285
+ * the fingerprint has one, is judged by its text alone: an Android element
286
+ * with no resource-id, or an iOS one, whose name is then its label. Only
287
+ * where neither has one does position decide, as through 2.14.
288
+ * What this stops is the button that took the old one's place: "Cancel
289
+ * order" (cancel) where "Pay now" (pay) was, "Log out" where "Log in" was.
290
+ */
291
+ contradictsIdentifier(node, etalon) {
292
+ const identifier = (0, fingerprintIdentity_1.stableIdentifierOf)(etalon.attributes);
293
+ if (!identifier)
294
+ return false;
295
+ // Another identifier, or none where the fingerprint has one, and the
296
+ // text decides. iOS gives an element without one its label as name.
297
+ const theirs = this.getAttrValue(node, identifier.attribute);
298
+ if ((0, fingerprintIdentity_1.meaningful)(theirs)) {
299
+ const [mine, other] = identifier.attribute === 'resource-id'
300
+ ? [(0, fingerprintIdentity_1.resourceIdName)(identifier.value), (0, fingerprintIdentity_1.resourceIdName)(theirs)]
301
+ : [identifier.value, theirs];
302
+ if ((0, fingerprintIdentity_1.sameIdentifier)(mine, other))
303
+ return false;
304
+ }
305
+ const texts = TEXTS.map((name) => etalon.attributes[name]).filter(fingerprintIdentity_1.meaningful);
306
+ if (texts.length === 0)
307
+ return true;
308
+ const nodeTexts = [
309
+ node.textContent,
310
+ ...TEXTS.map((name) => this.getAttrValue(node, name)),
311
+ ].filter(fingerprintIdentity_1.meaningful);
312
+ return !nodeTexts.some((t) => texts.some((w) => (0, fingerprintIdentity_1.sameText)(t, w)));
313
+ }
263
314
  getAttrValue(node, name) {
264
315
  if (typeof node.getAttribute === 'function') {
265
316
  const val = node.getAttribute(name);
@@ -37,7 +37,9 @@ let HealEtalonService = class HealEtalonService {
37
37
  return __awaiter(this, void 0, void 0, function* () {
38
38
  try {
39
39
  const attributes = {};
40
- // Extract identifying attributes
40
+ // Extract identifying attributes. Not `value`: on a text field it is
41
+ // what the test typed, an email or a name, which says nothing about
42
+ // which field it is.
41
43
  const nodeAttrs = node.attributes || [];
42
44
  const anchorNames = [
43
45
  'content-desc',
@@ -48,7 +50,6 @@ let HealEtalonService = class HealEtalonService {
48
50
  'hint',
49
51
  'label',
50
52
  'accessibility-id',
51
- 'value',
52
53
  'x',
53
54
  'y',
54
55
  'width',