@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
@@ -24,7 +24,13 @@ const PluginContext_1 = require("../PluginContext");
24
24
  const hubSessionToken_1 = require("../gateway/hubSessionToken");
25
25
  const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
26
26
  const SESSION_COOKIE = 'xenon_dashboard_session';
27
- const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
27
+ /** An API key's expiresAt as epoch ms, or undefined for a key that never expires. */
28
+ function keyExpiresAt(expiresAt) {
29
+ if (!expiresAt)
30
+ return undefined;
31
+ const ms = new Date(expiresAt).getTime();
32
+ return Number.isNaN(ms) ? undefined : ms;
33
+ }
28
34
  // Role → scopes derivation. The same table is used in profile token creation
29
35
  // to enforce that members can never grant 'admin'.
30
36
  // Cookie-session scopes: a logged-in user gets the full set of scopes
@@ -185,6 +191,7 @@ function authMiddleware(req, res, next) {
185
191
  apiKeyId: row.id,
186
192
  rateLimit: row.rateLimit,
187
193
  teamIds,
194
+ credentialExpiresAt: keyExpiresAt(row.expiresAt),
188
195
  };
189
196
  req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_b = row.teamId) !== null && _b !== void 0 ? _b : null };
190
197
  return next();
@@ -224,6 +231,7 @@ function authMiddleware(req, res, next) {
224
231
  teamId: (_e = payload.teamId) !== null && _e !== void 0 ? _e : null,
225
232
  rateLimit: 300,
226
233
  teamIds,
234
+ credentialExpiresAt: typeof payload.exp === 'number' ? payload.exp * 1000 : undefined,
227
235
  };
228
236
  return next();
229
237
  }
@@ -242,11 +250,14 @@ function authMiddleware(req, res, next) {
242
250
  if (!user || user.status !== 'ACTIVE')
243
251
  return res.status(401).json({ error: 'invalid session' });
244
252
  const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
253
+ // Renewed for as long as the session row now lasts
254
+ // (XENON_USER_SESSION_TTL_MS). A fixed 24 hours here capped any longer
255
+ // setting: the browser dropped the cookie after a day away.
245
256
  res.cookie(SESSION_COOKIE, cookie, {
246
257
  httpOnly: true,
247
258
  secure: isSecure,
248
259
  sameSite: 'strict',
249
- maxAge: SESSION_TTL_MS,
260
+ maxAge: userSessionSvc.ttlMs(),
250
261
  });
251
262
  const teamIds = yield (0, callerTeamIds_1.computeTeamIds)({
252
263
  role: user.role,
@@ -279,7 +290,7 @@ function authMiddleware(req, res, next) {
279
290
  httpOnly: true,
280
291
  secure: isSecure,
281
292
  sameSite: 'strict',
282
- maxAge: SESSION_TTL_MS,
293
+ maxAge: userSessionSvc.ttlMs(),
283
294
  });
284
295
  const teamIds = yield (0, callerTeamIds_1.computeTeamIds)({
285
296
  role: user.role,
@@ -295,6 +306,7 @@ function authMiddleware(req, res, next) {
295
306
  apiKeyId: row.id,
296
307
  rateLimit: row.rateLimit,
297
308
  teamIds,
309
+ credentialExpiresAt: keyExpiresAt(row.expiresAt),
298
310
  };
299
311
  req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_g = row.teamId) !== null && _g !== void 0 ? _g : null };
300
312
  return next();
@@ -103,9 +103,9 @@ class CommandCallerVerifier {
103
103
  if (!out)
104
104
  return null;
105
105
  // A Bearer token carries the scopes of the credential that minted it
106
- // (/auth/token), so it is judged like a key. Judging it by role alone,
107
- // as a xenon-session token is for leases, would let an ADMIN's ordinary
108
- // key mint its way to an override.
106
+ // (/auth/token), so it is judged like a key. Judging it by role alone
107
+ // would let an ADMIN's ordinary key mint its way to an override, as a
108
+ // xenon-session token's lease override did through 2.14.
109
109
  return {
110
110
  caller: {
111
111
  userId: out.user.id,
@@ -42,9 +42,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
42
42
  });
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.ACCEPTED_BEARER_AUDIENCES = void 0;
45
+ exports.SessionTokenSubjectError = exports.ACCEPTED_BEARER_AUDIENCES = void 0;
46
46
  exports.verifyKeyPairCredential = verifyKeyPairCredential;
47
47
  exports.verifyBearerCredential = verifyBearerCredential;
48
+ exports.isWrongSessionToken = isWrongSessionToken;
49
+ exports.verifySessionTokenCredential = verifySessionTokenCredential;
48
50
  const jose = __importStar(require("jose"));
49
51
  const typedi_1 = require("typedi");
50
52
  const ApiKeyService_1 = require("../services/ApiKeyService");
@@ -96,6 +98,12 @@ function verifyKeyPairCredential(accessKey, token) {
96
98
  * JWT) means the token is wrong. Any other error (typically "JWT key service
97
99
  * not initialized") means it could not be checked, and is rethrown once no
98
100
  * audience has verified.
101
+ *
102
+ * The payload's `scopes` are the token's as of now: `admin` is dropped once
103
+ * its user is a MEMBER. A token's scopes are fixed when it is minted, for up
104
+ * to a day, and REST (resolveActor, scopeGuard) and per-command auth's
105
+ * override read them, so through 2.14 an admin demoted since kept admin
106
+ * powers until the token expired.
99
107
  */
100
108
  function verifyBearerCredential(token) {
101
109
  return __awaiter(this, void 0, void 0, function* () {
@@ -119,6 +127,55 @@ function verifyBearerCredential(token) {
119
127
  const user = yield typedi_1.Container.get(UserService_1.UserService).findById(String(payload.sub));
120
128
  if (!user || user.status !== 'ACTIVE')
121
129
  return null;
130
+ return { payload: Object.assign(Object.assign({}, payload), { scopes: scopesForLiveRole(payload.scopes, user.role) }), user };
131
+ });
132
+ }
133
+ /** A token's `scopes` claim without `admin` when its user is now a MEMBER. */
134
+ function scopesForLiveRole(scopes, role) {
135
+ const list = String(scopes !== null && scopes !== void 0 ? scopes : '')
136
+ .split(',')
137
+ .map((s) => s.trim())
138
+ .filter(Boolean);
139
+ if (role === 'ADMIN' || role === 'SUPER_ADMIN')
140
+ return list.join(',');
141
+ return list.filter((s) => s !== 'admin').join(',');
142
+ }
143
+ /** The subject of a session token is no longer an ACTIVE user (deleted or Inactive). */
144
+ class SessionTokenSubjectError extends Error {
145
+ constructor() {
146
+ super('the session token names a user who is deleted or not active');
147
+ this.name = 'SessionTokenSubjectError';
148
+ }
149
+ }
150
+ exports.SessionTokenSubjectError = SessionTokenSubjectError;
151
+ /**
152
+ * Whether verifySessionTokenCredential's error means the token is wrong (a
153
+ * bad signature, audience or lifetime, not a JWT, or a departed user), as
154
+ * opposed to a check that could not run (the signing key or the database
155
+ * unavailable). A wrong token counts as none; a check that could not run
156
+ * refuses the create, as a key whose owner can't be looked up does.
157
+ */
158
+ function isWrongSessionToken(err) {
159
+ return err instanceof jose.errors.JOSEError || err instanceof SessionTokenSubjectError;
160
+ }
161
+ /**
162
+ * A `xenon-session` token (the `xe:options.sessionToken` capability), checked
163
+ * as REST checks a Bearer token: a valid signature, audience and lifetime,
164
+ * and a subject who is an ACTIVE user, looked up now. Through 2.14 session
165
+ * create checked the signature alone, so a deleted or Inactive user's token
166
+ * created sessions as them until it expired.
167
+ *
168
+ * Throws for a token that does not verify, as JwtKeyService.verify does, so
169
+ * the session-token gate and attribution treat a wrong signature and a
170
+ * departed user alike. Whether the token may create sessions at all (its
171
+ * `scopes`) is the caller's to judge.
172
+ */
173
+ function verifySessionTokenCredential(token) {
174
+ return __awaiter(this, void 0, void 0, function* () {
175
+ const payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(token, { audience: 'xenon-session' });
176
+ const user = yield typedi_1.Container.get(UserService_1.UserService).findById(String(payload.sub));
177
+ if (!user || user.status !== 'ACTIVE')
178
+ throw new SessionTokenSubjectError();
122
179
  return { payload, user: user };
123
180
  });
124
181
  }
package/lib/src/plugin.js CHANGED
@@ -66,6 +66,7 @@ const IPluginArgs_1 = require("./interfaces/IPluginArgs");
66
66
  const NodeDevices_1 = __importDefault(require("./device-managers/NodeDevices"));
67
67
  const appiumUmbrella_1 = require("./sessions/appiumUmbrella");
68
68
  const LiveSessionOwners_1 = require("./services/device-access/LiveSessionOwners");
69
+ const sessionMemory_1 = require("./sessions/sessionMemory");
69
70
  const SessionMetricsService_1 = require("./services/metrics/SessionMetricsService");
70
71
  const PhoneNetworkRestore_1 = require("./services/network/PhoneNetworkRestore");
71
72
  const config_1 = require("./config");
@@ -73,6 +74,7 @@ const SessionManager_1 = require("./sessions/SessionManager");
73
74
  const event_manager_1 = require("./dashboard/event-manager");
74
75
  const asset_manager_1 = require("./dashboard/asset-manager");
75
76
  const session_service_1 = require("./dashboard/services/session-service");
77
+ const shutdownReason_1 = require("./services/session/shutdownReason");
76
78
  const DEVICE_MANAGER_LOCK_NAME = 'DeviceManager';
77
79
  class XenonPlugin extends base_plugin_1.default {
78
80
  constructor(pluginName, cliArgs) {
@@ -104,11 +106,12 @@ class XenonPlugin extends base_plugin_1.default {
104
106
  return yield typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).handle(next, driver, commandName, args, this.pluginArgs, XenonPlugin.IS_HUB);
105
107
  });
106
108
  }
107
- onUnexpectedShutdown(driver, _cause) {
109
+ onUnexpectedShutdown(driver, cause) {
108
110
  return __awaiter(this, void 0, void 0, function* () {
109
111
  var _a;
110
112
  const sessionId = driver.sessionId;
111
113
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
114
+ (0, sessionMemory_1.forgetSessionMemory)(sessionId);
112
115
  if (sessionId)
113
116
  yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
114
117
  // Appium's new-command timeout ends a session here, not in deleteSession:
@@ -136,7 +139,9 @@ class XenonPlugin extends base_plugin_1.default {
136
139
  logger_1.default.info(`Unblocking device mapped with filter ${JSON.stringify(deviceFilter)} onUnexpectedShutdown from server`);
137
140
  if (XenonPlugin.IS_HUB && this.pluginArgs.enableDashboard && sessionId) {
138
141
  const sessionLog = this.xenonLog.withSession(sessionId, (_a = driver.caps) === null || _a === void 0 ? void 0 : _a.udid);
139
- sessionLog.info('Unexpected shutdown for session, updating dashboard...');
142
+ // Appium's cause, so an idle session is filed as a timeout.
143
+ const reason = (0, shutdownReason_1.unexpectedShutdownReason)(cause);
144
+ sessionLog.info(`Unexpected shutdown for session (${reason}), updating dashboard...`);
140
145
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
141
146
  if (session && session.isVideoRecordingInProgress()) {
142
147
  try {
@@ -154,7 +159,7 @@ class XenonPlugin extends base_plugin_1.default {
154
159
  logger_1.default.debug(`[${sessionId}] rescue-video: Failed: ${err.message}`);
155
160
  }
156
161
  }
157
- yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, SessionStatus_1.SessionStatus.FAILED, 'Driver shut down unexpectedly');
162
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, SessionStatus_1.SessionStatus.FAILED, reason);
158
163
  typedi_1.Container.get(TracingService_1.TracingService).endSpan(sessionId, 'ERROR', {
159
164
  'xenon.session.stop_reason': 'Unexpected shutdown',
160
165
  });
@@ -49,12 +49,19 @@ let AICommandService = class AICommandService {
49
49
  return __awaiter(this, void 0, void 0, function* () {
50
50
  this.logger.info(`Testing AI locator: ${locator.strategy}=${locator.selector}`);
51
51
  const omniService = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
52
+ // A session's own route: its test may act on what it found.
53
+ const keep = (found) => {
54
+ if (driver === null || driver === void 0 ? void 0 : driver.sessionId)
55
+ for (const el of found)
56
+ omniService.remember(driver.sessionId, el);
57
+ return found;
58
+ };
52
59
  if (locator.strategy === '-custom:ai-text') {
53
- return yield omniService.findByText(driver, locator.selector);
60
+ return keep(yield omniService.findByText(driver, locator.selector));
54
61
  }
55
62
  else if (locator.strategy === '-custom:ai-icon') {
56
63
  const result = yield omniService.findByIcon(driver, locator.selector);
57
- return result ? [result] : [];
64
+ return keep(result ? [result] : []);
58
65
  }
59
66
  throw new Error(`Unsupported AI strategy: ${locator.strategy}`);
60
67
  });
@@ -45,7 +45,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
45
45
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.AI_SERVICE = exports.AIService = void 0;
48
+ exports.AI_SERVICE = exports.AIService = exports.FAILURE_ANALYSIS_TIMEOUT_MS = exports.AI_CALL_TIMEOUT_MS = exports.AITimeoutError = exports.AIProviderPausedError = exports.AIRateLimitedError = void 0;
49
49
  exports.parseVisualVerdict = parseVisualVerdict;
50
50
  const generative_ai_1 = require("@google/generative-ai");
51
51
  const openai_1 = __importDefault(require("openai"));
@@ -56,8 +56,88 @@ const fs = __importStar(require("fs"));
56
56
  const path = __importStar(require("path"));
57
57
  const config_1 = require("../config");
58
58
  const CircuitBreaker_1 = require("./CircuitBreaker");
59
- /** What GeminiProvider answers, instead of throwing, when the provider is rate-limited (429). */
60
- const RATE_LIMITED = 'CONNECTION_OK_RATE_LIMITED';
59
+ /**
60
+ * A provider refused a call for its rate limit or quota (HTTP 429), whichever
61
+ * provider it is. A failed call like any other: its caller gets no answer and
62
+ * the circuit breaker counts it (`status`). Through 2.14 Gemini's provider
63
+ * answered a 429 with the text 'CONNECTION_OK_RATE_LIMITED', which was saved
64
+ * as a failed session's AI analysis and counted by the breaker as a success.
65
+ */
66
+ class AIRateLimitedError extends Error {
67
+ constructor(detail) {
68
+ super('The AI provider is rate-limited or out of quota');
69
+ this.name = 'AIRateLimitedError';
70
+ this.status = 429;
71
+ this.detail = detail;
72
+ }
73
+ }
74
+ exports.AIRateLimitedError = AIRateLimitedError;
75
+ /** A provider's 429, as its SDK (`status`) or axios (`response.status`) reports it. */
76
+ function isRateLimit(err) {
77
+ var _a;
78
+ return err instanceof AIRateLimitedError || (err === null || err === void 0 ? void 0 : err.status) === 429 || ((_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status) === 429;
79
+ }
80
+ /**
81
+ * The circuit breaker didn't send the call: the provider's recent calls
82
+ * failed. Still a CircuitOpenError, in words for testers: the Omni-Scan, Test
83
+ * locator and visual assertion answers show it, and "Circuit open for
84
+ * 'ai:gemini:<model>'; retry in 43120ms" isn't.
85
+ */
86
+ class AIProviderPausedError extends CircuitBreaker_1.CircuitOpenError {
87
+ constructor(from) {
88
+ super(from.key, from.retryAfterMs);
89
+ this.seconds = Math.max(1, Math.ceil(from.retryAfterMs / 1000));
90
+ this.message = `The AI provider has been failing, so Xenon will call it again in ${this.seconds} s`;
91
+ }
92
+ }
93
+ exports.AIProviderPausedError = AIProviderPausedError;
94
+ /** A call that outlasted its time limit; its request was cancelled. The circuit breaker counts it (`code`). */
95
+ class AITimeoutError extends Error {
96
+ constructor(ms) {
97
+ super(`The AI provider didn't answer within ${Math.round(ms / 1000)} s`);
98
+ this.name = 'AITimeoutError';
99
+ this.code = 'ETIMEDOUT';
100
+ }
101
+ }
102
+ exports.AITimeoutError = AITimeoutError;
103
+ /**
104
+ * How long an AI call may take, retries included, unless it says otherwise
105
+ * (failure analysis). Most are made while a test command runs: the LLM and
106
+ * visual healing tiers inside a failing findElement (both: 2 of these), the
107
+ * visual assertion and the screen description inside an execute script, an
108
+ * ai-icon find. The OpenAI and Anthropic SDKs otherwise wait up to 10 minutes
109
+ * a try, three tries, and Gemini has no limit, which held the command past
110
+ * the client's own timeout. Ollama's generate call already had 30 s.
111
+ */
112
+ exports.AI_CALL_TIMEOUT_MS = 30000;
113
+ /**
114
+ * How long a failed session's AI analysis may take, retries included. The
115
+ * OpenAI and Anthropic SDKs otherwise wait up to 10 minutes a try, three
116
+ * tries. The analysis runs after the session has ended (onSessionStopped
117
+ * doesn't wait for it), so this bounds the work left running, not a client's
118
+ * wait: an answer with a screenshot usually takes seconds.
119
+ */
120
+ exports.FAILURE_ANALYSIS_TIMEOUT_MS = 120000;
121
+ /** `call`, given a signal that cancels it after `ms`, when it fails with AITimeoutError. */
122
+ function withTimeLimit(ms, call) {
123
+ return __awaiter(this, void 0, void 0, function* () {
124
+ const controller = new AbortController();
125
+ let timer;
126
+ const expired = new Promise((_, reject) => {
127
+ timer = setTimeout(() => {
128
+ // The time-out first, so it, not the cancelled request's error, is the answer.
129
+ reject(new AITimeoutError(ms));
130
+ controller.abort();
131
+ }, ms);
132
+ });
133
+ try {
134
+ return yield Promise.race([call(controller.signal), expired]);
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ }
139
+ });
140
+ }
61
141
  /**
62
142
  * A provider's answer to the assertion prompt, `{ "result": true|false,
63
143
  * "reason": "..." }`, read leniently (code fences, text around the JSON,
@@ -97,7 +177,7 @@ class GeminiProvider {
97
177
  this.modelName =
98
178
  modelName && modelName.trim() !== '' ? modelName.trim() : 'gemini-3-flash-preview';
99
179
  }
100
- analyze(prompt, screenshotBase64) {
180
+ analyze(prompt, screenshotBase64, signal) {
101
181
  return __awaiter(this, void 0, void 0, function* () {
102
182
  const parts = [prompt];
103
183
  if (screenshotBase64) {
@@ -116,17 +196,17 @@ class GeminiProvider {
116
196
  for (const version of versions) {
117
197
  try {
118
198
  const model = this.genAI.getGenerativeModel({ model: this.modelName }, { apiVersion: version });
119
- const result = yield model.generateContent(parts);
199
+ const result = yield model.generateContent(parts, signal ? { signal } : undefined);
120
200
  const response = yield result.response;
121
201
  return response.text();
122
202
  }
123
203
  catch (err) {
124
204
  lastError = err;
125
- // If we get a 429, the connection IS WORKING, just rate limited.
126
- if (err.message.includes('429') || err.message.includes('Quota exceeded')) {
127
- logger_1.default.info(`[Gemini] Connection verified (Rate Limited) via ${version} endpoint.`);
128
- return 'CONNECTION_OK_RATE_LIMITED';
129
- }
205
+ // The other endpoint has the same quota. By status only: the message
206
+ // holds the URL and the provider's text, where "429" can be a token
207
+ // count or part of the model's name.
208
+ if (err.status === 429)
209
+ throw new AIRateLimitedError(err.message);
130
210
  if (err.message.includes('404') ||
131
211
  err.message.includes('not found') ||
132
212
  err.message.includes('unsupported')) {
@@ -145,7 +225,7 @@ class OpenAIProvider {
145
225
  this.client = new openai_1.default({ apiKey, baseURL });
146
226
  this.model = model;
147
227
  }
148
- analyze(prompt, screenshotBase64) {
228
+ analyze(prompt, screenshotBase64, signal) {
149
229
  return __awaiter(this, void 0, void 0, function* () {
150
230
  const messages = [
151
231
  {
@@ -163,7 +243,7 @@ class OpenAIProvider {
163
243
  model: this.model,
164
244
  messages,
165
245
  max_tokens: 500,
166
- });
246
+ }, { signal });
167
247
  return response.choices[0].message.content || '';
168
248
  });
169
249
  }
@@ -173,7 +253,7 @@ class AnthropicProvider {
173
253
  this.client = new sdk_1.default({ apiKey });
174
254
  this.model = model;
175
255
  }
176
- analyze(prompt, screenshotBase64) {
256
+ analyze(prompt, screenshotBase64, signal) {
177
257
  return __awaiter(this, void 0, void 0, function* () {
178
258
  const content = [{ type: 'text', text: prompt }];
179
259
  if (screenshotBase64) {
@@ -190,7 +270,7 @@ class AnthropicProvider {
190
270
  model: this.model,
191
271
  max_tokens: 500,
192
272
  messages: [{ role: 'user', content }],
193
- });
273
+ }, { signal });
194
274
  return response.content[0].text || '';
195
275
  });
196
276
  }
@@ -206,10 +286,11 @@ class OllamaProvider {
206
286
  checkAvailability() {
207
287
  return __awaiter(this, void 0, void 0, function* () {
208
288
  const now = Date.now();
209
- // Use cached status if checked recently
210
- if (this.isAvailable !== null &&
211
- now - this.lastAvailabilityCheck < this.AVAILABILITY_CHECK_INTERVAL) {
212
- return this.isAvailable;
289
+ // An answer is kept for a while; "down" is checked again at the next call
290
+ // (a refused local connection costs nothing), so Ollama is used as soon as
291
+ // it is back.
292
+ if (this.isAvailable && now - this.lastAvailabilityCheck < this.AVAILABILITY_CHECK_INTERVAL) {
293
+ return true;
213
294
  }
214
295
  try {
215
296
  // Quick health check - Ollama has a /api/tags endpoint
@@ -225,7 +306,7 @@ class OllamaProvider {
225
306
  }
226
307
  });
227
308
  }
228
- analyze(prompt, screenshotBase64) {
309
+ analyze(prompt, screenshotBase64, signal) {
229
310
  return __awaiter(this, void 0, void 0, function* () {
230
311
  var _a;
231
312
  // Check if Ollama is available before attempting
@@ -239,7 +320,7 @@ class OllamaProvider {
239
320
  prompt: prompt,
240
321
  images: screenshotBase64 ? [screenshotBase64] : [],
241
322
  stream: false,
242
- }, { timeout: 30000 });
323
+ }, { timeout: 30000, signal });
243
324
  return response.data.response || '';
244
325
  }
245
326
  catch (err) {
@@ -257,6 +338,8 @@ class AIService {
257
338
  constructor() {
258
339
  this.provider = null;
259
340
  this.isMock = false;
341
+ /** The settings `provider` was set up from (initializeProvider). */
342
+ this.setUpFrom = null;
260
343
  this.initializeProvider();
261
344
  }
262
345
  // Key the breaker by provider + resolved model name so a misbehaving model
@@ -276,16 +359,60 @@ class AIService {
276
359
  return `ai:${provider}:${model || 'default'}`;
277
360
  }
278
361
  // Single choke point for every LLM call so circuit-breaker state is shared
279
- // across analyzeFailure / visualFind / healLocator.
280
- callProvider(prompt, screenshotBase64) {
281
- return __awaiter(this, void 0, void 0, function* () {
282
- return CircuitBreaker_1.CIRCUIT_BREAKERS.execute(this.breakerKey(), () => this.provider.analyze(prompt, screenshotBase64));
362
+ // across analyzeFailure / visualFind / healLocator. A rate limit is
363
+ // AIRateLimitedError whichever provider sent it; a call is cancelled after
364
+ // `timeoutMs` (AI_CALL_TIMEOUT_MS unless given: AITimeoutError). The breaker
365
+ // counts both, and a call it holds back is AIProviderPausedError.
366
+ callProvider(prompt_1, screenshotBase64_1) {
367
+ return __awaiter(this, arguments, void 0, function* (prompt, screenshotBase64, opts = {}) {
368
+ var _a;
369
+ const provider = this.provider;
370
+ try {
371
+ return yield CircuitBreaker_1.CIRCUIT_BREAKERS.execute(this.breakerKey(), () => {
372
+ var _a;
373
+ return withTimeLimit((_a = opts.timeoutMs) !== null && _a !== void 0 ? _a : exports.AI_CALL_TIMEOUT_MS, (signal) => provider.analyze(prompt, screenshotBase64, signal));
374
+ });
375
+ }
376
+ catch (err) {
377
+ if (isRateLimit(err) && !(err instanceof AIRateLimitedError)) {
378
+ throw new AIRateLimitedError((_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err));
379
+ }
380
+ if (err instanceof CircuitBreaker_1.CircuitOpenError && !(err instanceof AIProviderPausedError)) {
381
+ throw new AIProviderPausedError(err);
382
+ }
383
+ throw err;
384
+ }
283
385
  });
284
386
  }
387
+ /**
388
+ * Sets the provider up from the server's AI settings, which the AI engine
389
+ * page changes while the server runs (`POST /config`). Again only when they
390
+ * changed, so a provider's client, and Ollama's availability check, carry
391
+ * over from one call to the next.
392
+ */
285
393
  initializeProvider() {
286
394
  const providerType = config_1.config.aiProvider;
287
395
  const genericModel = config_1.config.aiModel;
288
396
  const baseUrl = config_1.config.aiBaseUrl;
397
+ const settings = JSON.stringify([
398
+ providerType,
399
+ genericModel,
400
+ baseUrl,
401
+ config_1.config.geminiApiKey,
402
+ config_1.config.openaiApiKey,
403
+ config_1.config.anthropicApiKey,
404
+ config_1.config.geminiModel,
405
+ config_1.config.openaiModel,
406
+ config_1.config.anthropicModel,
407
+ config_1.config.ollamaModel,
408
+ process.env.GEMINI_API_KEY === 'mock',
409
+ ]);
410
+ if (settings === this.setUpFrom)
411
+ return;
412
+ this.setUpFrom = settings;
413
+ // A provider chosen without a key is no provider, never the previous one.
414
+ this.provider = null;
415
+ this.isMock = false;
289
416
  logger_1.default.info(`[AIService] Initializing with provider: ${providerType}`);
290
417
  if (process.env.GEMINI_API_KEY === 'mock') {
291
418
  this.isMock = true;
@@ -321,7 +448,15 @@ class AIService {
321
448
  logger_1.default.error(`[AIService] Initialization error: ${err.message}`);
322
449
  }
323
450
  }
451
+ /**
452
+ * Whether a provider is set up for the settings in force now. Failure
453
+ * analysis and the LLM and visual healing tiers ask this first; it used to
454
+ * answer for the provider set up last, so choosing a configured provider on
455
+ * the AI engine page never turned them on, and choosing one without a key
456
+ * left the previous provider answering.
457
+ */
324
458
  isEnabled() {
459
+ this.initializeProvider();
325
460
  return this.provider !== null || this.isMock;
326
461
  }
327
462
  analyzeFailure(context) {
@@ -341,7 +476,9 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
341
476
  try {
342
477
  const prompt = this.constructPrompt(context);
343
478
  const screenshotBase64 = this.getScreenshotBase64(context.screenshotPath);
344
- const text = yield this.callProvider(prompt, screenshotBase64 || undefined);
479
+ const text = yield this.callProvider(prompt, screenshotBase64 || undefined, {
480
+ timeoutMs: exports.FAILURE_ANALYSIS_TIMEOUT_MS,
481
+ });
345
482
  logger_1.default.info(`[AIService] Analysis complete for ${context.sessionId}`);
346
483
  return text;
347
484
  }
@@ -349,6 +486,12 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
349
486
  if (err instanceof CircuitBreaker_1.CircuitOpenError) {
350
487
  logger_1.default.debug(`[AIService] Analysis skipped: ${err.message}`);
351
488
  }
489
+ else if (err instanceof AIRateLimitedError) {
490
+ logger_1.default.warn(`[AIService] No analysis for ${context.sessionId}: ${err.message}: ${err.detail}`);
491
+ }
492
+ else if (err instanceof AITimeoutError) {
493
+ logger_1.default.warn(`[AIService] No analysis for ${context.sessionId}: ${err.message}`);
494
+ }
352
495
  else {
353
496
  logger_1.default.error(`[AIService] Analysis failed: ${err.message}`);
354
497
  }
@@ -429,11 +572,14 @@ Answer with JSON only, exactly: {"result": true or false, "reason": "one sentenc
429
572
  answer = yield this.callProvider(prompt, screenshotBase64);
430
573
  }
431
574
  catch (err) {
575
+ if (err instanceof AIRateLimitedError || err instanceof AITimeoutError) {
576
+ throw new Error(`${err.message}, ${notChecked}. Try again later.`);
577
+ }
578
+ if (err instanceof AIProviderPausedError) {
579
+ throw new Error(`The AI provider has been failing, ${notChecked}. Xenon will call it again in ${err.seconds} s.`);
580
+ }
432
581
  throw new Error(`The AI provider failed, ${notChecked}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
433
582
  }
434
- if (answer === RATE_LIMITED) {
435
- throw new Error(`The AI provider is rate-limited, ${notChecked}. Try again later.`);
436
- }
437
583
  const verdict = parseVisualVerdict(answer);
438
584
  if (!verdict) {
439
585
  const said = answer.replace(/\s+/g, ' ').trim().slice(0, 200);
@@ -446,7 +592,9 @@ Answer with JSON only, exactly: {"result": true or false, "reason": "one sentenc
446
592
  /**
447
593
  * A short description of the screen in the screenshot, for
448
594
  * `xenon: analyzeScreen` and device control's Omni-Scan. Throws when there
449
- * is no provider or the call fails, so the caller can say why there is none.
595
+ * is no provider or the call fails (AIRateLimitedError for a rate limit,
596
+ * AITimeoutError after AI_CALL_TIMEOUT_MS), so the caller can say why there
597
+ * is none.
450
598
  */
451
599
  describeScreen(screenshotBase64) {
452
600
  return __awaiter(this, void 0, void 0, function* () {
@@ -458,10 +606,7 @@ You are looking at a screenshot of a mobile app, for a software tester.
458
606
  Describe the screen in a few short sentences: which screen it is, its main elements and their state (for example filled in, empty, selected or disabled), and anything unexpected, such as an error message, a system dialog, a crash or a loading indicator.
459
607
  Describe only what is visible. Answer in plain text.
460
608
  `.trim();
461
- const answer = yield this.callProvider(prompt, screenshotBase64);
462
- if (answer === RATE_LIMITED)
463
- throw new Error('The AI provider is rate-limited');
464
- return answer;
609
+ return this.callProvider(prompt, screenshotBase64);
465
610
  });
466
611
  }
467
612
  /**
@@ -512,9 +657,10 @@ Describe only what is visible. Answer in plain text.
512
657
  }
513
658
  testConnection(testConfig) {
514
659
  return __awaiter(this, void 0, void 0, function* () {
660
+ var _a;
661
+ const providerType = testConfig.aiProvider || config_1.config.aiProvider;
515
662
  try {
516
663
  let testProvider = null;
517
- const providerType = testConfig.aiProvider || config_1.config.aiProvider;
518
664
  const model = testConfig.aiModel || config_1.config.aiModel;
519
665
  const baseUrl = testConfig.aiBaseUrl || config_1.config.aiBaseUrl;
520
666
  logger_1.default.info(`[AIService] Testing connection for provider: ${providerType}`);
@@ -547,17 +693,21 @@ Describe only what is visible. Answer in plain text.
547
693
  }
548
694
  if (!testProvider)
549
695
  throw new Error('Failed to initialize provider for testing');
550
- // Send a minimal ping command
551
- const responseText = yield testProvider.analyze('Hello. Response: OK');
552
- if (responseText === 'CONNECTION_OK_RATE_LIMITED') {
553
- return {
554
- success: true,
555
- message: `Successfully connected to ${providerType}! (Note: You are currently out of quota/rate-limited, but the setup is correct)`,
556
- };
557
- }
696
+ // Send a minimal ping command. Not through the circuit breaker (a test
697
+ // shouldn't pause the provider for everyone), but with the time limit.
698
+ const pinged = testProvider;
699
+ yield withTimeLimit(exports.AI_CALL_TIMEOUT_MS, (signal) => pinged.analyze('Hello. Response: OK', undefined, signal));
558
700
  return { success: true, message: `Successfully connected to ${providerType}!` };
559
701
  }
560
702
  catch (err) {
703
+ // Not a success: every AI call fails the same way until it has quota again.
704
+ if (isRateLimit(err)) {
705
+ logger_1.default.warn(`[AIService] Connection test: ${providerType} is rate-limited: ${(_a = err.detail) !== null && _a !== void 0 ? _a : err.message}`);
706
+ return {
707
+ success: false,
708
+ message: `${providerType} answered, but it is rate-limited or out of quota. AI features won't work with it until it has quota again.`,
709
+ };
710
+ }
561
711
  logger_1.default.error(`[AIService] Connection test failed: ${err.message}`);
562
712
  return { success: false, message: `Connection failed: ${err.message}` };
563
713
  }