@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.
- package/README.md +7 -4
- package/lib/package.json +5 -5
- package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
- package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
- package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
- package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
- package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
- package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
- package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
- package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
- package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
- package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
- package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
- package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
- package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
- package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
- package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
- package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
- package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
- package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
- package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
- package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
- package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
- package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
- package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
- package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
- package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
- package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
- package/lib/public/assets/derive-UnLRp4r3.js +1 -0
- package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
- package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
- package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
- package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
- package/lib/public/assets/index-B9KQ6LsY.js +15 -0
- package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
- package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
- package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
- package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
- package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
- package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
- package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
- package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
- package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
- package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
- package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
- package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
- package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
- package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
- package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
- package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
- package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
- package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
- package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
- package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
- package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
- package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
- package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
- package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
- package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
- package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
- package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
- package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
- package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
- package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
- package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
- package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
- package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +5 -5
- package/lib/src/app/dashboardPluginLink.js +117 -0
- package/lib/src/app/index.js +13 -34
- package/lib/src/app/openapi/control.yaml +6 -4
- package/lib/src/app/openapi/grid.yaml +8 -2
- package/lib/src/app/openapi/identity.yaml +103 -31
- package/lib/src/app/openapi/platform.yaml +33 -15
- package/lib/src/app/openapi/sessions.yaml +10 -3
- package/lib/src/app/routers/apikeys.js +14 -2
- package/lib/src/app/routers/auth.js +29 -5
- package/lib/src/app/routers/control.js +13 -5
- package/lib/src/app/routers/dashboard.js +42 -27
- package/lib/src/app/routers/grid.js +5 -3
- package/lib/src/app/routers/profile.js +21 -0
- package/lib/src/app/routers/users.js +10 -5
- package/lib/src/app/swagger.js +2 -0
- package/lib/src/app/ws/h264StreamWs.js +10 -1
- package/lib/src/config.js +1 -0
- package/lib/src/dashboard/commands.js +5 -5
- package/lib/src/dashboard/event-manager.js +49 -61
- package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
- package/lib/src/dashboard/sessionCommandSummary.js +39 -0
- package/lib/src/data-service/device-store.js +14 -1
- package/lib/src/data-service/prisma-store.js +19 -7
- package/lib/src/data-service/web-config-service.js +7 -0
- package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
- package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
- package/lib/src/device-utils.js +20 -2
- package/lib/src/gateway/defaultGateway.js +1 -1
- package/lib/src/gateway/forwardToNode.js +2 -0
- package/lib/src/gateway/healReport.js +87 -0
- package/lib/src/gateway/sessionGateway.js +12 -2
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +32 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +3 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/interceptors/CommandInterceptor.js +243 -181
- package/lib/src/middleware/authMiddleware.js +15 -3
- package/lib/src/middleware/commandCaller.js +3 -3
- package/lib/src/middleware/verifyCredential.js +58 -1
- package/lib/src/plugin.js +8 -3
- package/lib/src/services/AICommandService.js +9 -2
- package/lib/src/services/AIService.js +192 -42
- package/lib/src/services/EmailService.js +29 -0
- package/lib/src/services/OrphanSweeper.js +2 -0
- package/lib/src/services/SelectorVerificationJob.js +15 -4
- package/lib/src/services/ServerManager.js +11 -1
- package/lib/src/services/SessionLifecycleService.js +86 -26
- package/lib/src/services/SocketServer.js +19 -3
- package/lib/src/services/bug-report/video-slice.js +4 -1
- package/lib/src/services/device-access/leaseOverride.js +7 -5
- package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
- package/lib/src/services/healing/HealEtalonService.js +3 -2
- package/lib/src/services/healing/HealingOrchestrator.js +55 -72
- package/lib/src/services/healing/OcrHealingProvider.js +41 -64
- package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
- package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
- package/lib/src/services/healing/fingerprintIdentity.js +126 -0
- package/lib/src/services/healing/resilioPath.js +274 -0
- package/lib/src/services/logcat/SessionDeviceLogs.js +271 -0
- package/lib/src/services/logcat/deviceLogBook.js +163 -0
- package/lib/src/services/ocr/ocrData.js +118 -0
- package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
- package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
- package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
- package/lib/src/services/passwordResetLink.js +50 -9
- package/lib/src/services/passwordResetMode.js +3 -1
- package/lib/src/services/publicUrl.js +45 -0
- package/lib/src/services/session/sessionIdentity.js +5 -3
- package/lib/src/services/session/shutdownReason.js +26 -0
- package/lib/src/services/settings/aiEngineSettings.js +219 -0
- package/lib/src/services/token/mintedLifetime.js +51 -0
- package/lib/src/sessions/SessionManager.js +8 -2
- package/lib/src/sessions/sessionMemory.js +25 -0
- package/lib/test/helpers/fake-ai-provider.js +188 -0
- package/lib/test/helpers/loki-stores.js +33 -0
- package/lib/test/helpers/page-sources.js +164 -0
- package/lib/test/helpers/scratch-database.js +57 -13
- package/lib/test/integration/audit-ingest.spec.js +10 -10
- package/lib/test/integration/auth-flow.spec.js +9 -14
- package/lib/test/integration/auth-rate-limit.spec.js +6 -3
- package/lib/test/integration/bug-report-route.spec.js +22 -2
- package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
- package/lib/test/integration/forgot-password.spec.js +17 -18
- package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
- package/lib/test/integration/migration.spec.js +6 -1
- package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
- package/lib/test/integration/ports-router.spec.js +4 -4
- package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
- package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
- package/lib/test/integration/profile-tokens.spec.js +10 -16
- package/lib/test/integration/recordings-router.spec.js +16 -16
- package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
- package/lib/test/integration/role-matrix.spec.js +4 -9
- package/lib/test/integration/sdk-leases-router.spec.js +9 -9
- package/lib/test/integration/sdk-version.spec.js +2 -2
- package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
- package/lib/test/integration/team-membership-migration.spec.js +73 -12
- package/lib/test/integration/team-visibility-apps.spec.js +15 -22
- package/lib/test/integration/team-visibility-control.spec.js +96 -69
- package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
- package/lib/test/integration/team-visibility-grid.spec.js +16 -15
- package/lib/test/integration/team-visibility-leases.spec.js +8 -11
- package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
- package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
- package/lib/test/integration/users-flow.spec.js +7 -12
- package/lib/test/integration/users-lockout.spec.js +5 -3
- package/lib/test/unit/EmailService.test.js +66 -0
- package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
- package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
- package/lib/test/unit/ai-engine-settings.spec.js +338 -0
- package/lib/test/unit/ai-provider-switch.spec.js +130 -0
- package/lib/test/unit/ai-rate-limit.spec.js +126 -0
- package/lib/test/unit/android-h264-viewers.spec.js +272 -0
- package/lib/test/unit/android-preview-hold.spec.js +3 -2
- package/lib/test/unit/apikeys-router.spec.js +84 -0
- package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
- package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
- package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
- package/lib/test/unit/dashboard-events-device-logs.spec.js +207 -0
- package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
- package/lib/test/unit/device-log-book.spec.js +180 -0
- package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
- package/lib/test/unit/event-log-session-data.spec.js +244 -0
- package/lib/test/unit/failure-analysis.spec.js +182 -0
- package/lib/test/unit/failure-categories.spec.js +128 -0
- package/lib/test/unit/fingerprint-identity.spec.js +433 -0
- package/lib/test/unit/h264-multiplexer.spec.js +46 -0
- package/lib/test/unit/heal-report.spec.js +71 -0
- package/lib/test/unit/healing-instrumentation.spec.js +6 -1
- package/lib/test/unit/healing-tier-gate.spec.js +73 -29
- package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
- package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
- package/lib/test/unit/lease/lease-override.spec.js +23 -6
- package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
- package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
- package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
- package/lib/test/unit/ocr-offline.spec.js +213 -0
- package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
- package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
- package/lib/test/unit/omni-vision.spec.js +5 -7
- package/lib/test/unit/package-engines.spec.js +27 -0
- package/lib/test/unit/passwordResetMode.test.js +10 -2
- package/lib/test/unit/profile-router.test.js +122 -1
- package/lib/test/unit/queue-requester.spec.js +1 -1
- package/lib/test/unit/reset-link-address.spec.js +266 -0
- package/lib/test/unit/reset-link.test.js +10 -4
- package/lib/test/unit/resilio-path-healing.spec.js +270 -0
- package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
- package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
- package/lib/test/unit/selector-verification-job.spec.js +13 -13
- package/lib/test/unit/self-healing-boot.spec.js +10 -5
- package/lib/test/unit/self-healing-setting.spec.js +7 -6
- package/lib/test/unit/session-app-resolution.spec.js +5 -1
- package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
- package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
- package/lib/test/unit/session-credential-strip.spec.js +1 -1
- package/lib/test/unit/session-device-logs.spec.js +347 -0
- package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
- package/lib/test/unit/session-log-indexes.spec.js +64 -3
- package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
- package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
- package/lib/test/unit/token-mint-limits.spec.js +165 -0
- package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
- package/lib/test/unit/verify-credential.spec.js +33 -0
- package/lib/test/unit/vision-assertion.spec.js +3 -2
- package/lib/test/unit/web-config-service.spec.js +19 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
- package/prisma/schema.prisma +3 -0
- package/schema.json +5 -5
- package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
- package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
- package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
- package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
- package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
- package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
- package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
- /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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
108
|
-
//
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
}
|