@xenon-device-management/xenon 2.13.1 → 2.13.2
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/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-BXruClDk.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-qTS4LPLC.js} +1 -1
- package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BoGhlI7F.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-ChhHnpFQ.js → DeviceMosaicView-8J0oYqMM.js} +1 -1
- package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-Cr17PdDv.js} +1 -1
- package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Bvn08svv.js} +1 -1
- package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-DDSO8aDf.js} +1 -1
- package/lib/public/assets/{Menu-C173JJ0l.js → Menu-B-Qn2X3T.js} +1 -1
- package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-Cg9V5JYU.js} +1 -1
- package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-BTUs9VTW.js} +1 -1
- package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-DYcfxITK.js} +1 -1
- package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Zy7suhT5.js} +1 -1
- package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-CWYmycoe.js} +1 -1
- package/lib/public/assets/{Table-B1DB_ZN-.js → Table-io9fwa98.js} +1 -1
- package/lib/public/assets/{activity-Css0xNvp.js → activity-Bdf_uIWl.js} +1 -1
- package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-Bce5SJob.js} +1 -1
- package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-CPqtVpm3.js} +1 -1
- package/lib/public/assets/{apps-DW1pw2re.js → apps-BgZan0H9.js} +1 -1
- package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-B27ckB31.js} +1 -1
- package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-DOF8ITCz.js} +1 -1
- package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-eFG5KTFS.js} +1 -1
- package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-DlC3_27j.js} +2 -2
- package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-BuXpPfKo.js} +1 -1
- package/lib/public/assets/{button-Cq_zHETv.js → button-fIW8owMI.js} +1 -1
- package/lib/public/assets/{calendar-CKxABX9q.js → calendar-WNjmrF-a.js} +1 -1
- package/lib/public/assets/{check-C7xu-hGU.js → check-BdTh1Sq7.js} +1 -1
- package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-B4BKlsqk.js} +1 -1
- package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-zJU80_EF.js} +1 -1
- package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-5Uwquo0h.js} +1 -1
- package/lib/public/assets/{clock-U1rDTT1h.js → clock-BtUprDsZ.js} +1 -1
- package/lib/public/assets/{copy-BIM0ZX0L.js → copy-DGSbZI3u.js} +1 -1
- package/lib/public/assets/{device-explorer-DSpAA_t9.js → device-explorer-C-KnhMHI.js} +1 -1
- package/lib/public/assets/{download-DDBRXnAZ.js → download-WG6l7BgD.js} +1 -1
- package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-DnudqFFo.js} +1 -1
- package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
- package/lib/public/assets/{index-BGBqohfQ.js → index-DI1fGIyj.js} +2 -2
- package/lib/public/assets/{index-BQ2luT7L.js → index-HDQVe1gi.js} +1 -1
- package/lib/public/assets/{input-BvB1cjHE.js → input-BjDRKCea.js} +1 -1
- package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-C0zdgd0v.js} +1 -1
- package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-BdW5CjM8.js} +1 -1
- package/lib/public/assets/{lock-loz8txQm.js → lock-Dy3L4I8E.js} +1 -1
- package/lib/public/assets/{login-BYOvz4lx.js → login-BHbY9CLG.js} +1 -1
- package/lib/public/assets/{maintenance-settings-BxbA7SkB.js → maintenance-settings-zDVlOMVK.js} +1 -1
- package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-8xjkeHHi.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-CXdKMXQ8.js} +1 -1
- package/lib/public/assets/{network-C5e0MIWo.js → network-Cj8JOKON.js} +1 -1
- package/lib/public/assets/{overview-tlu8ijGd.js → overview-CTg7F0im.js} +1 -1
- package/lib/public/assets/{page-header-FXPlo6az.js → page-header--aTyJXq6.js} +1 -1
- package/lib/public/assets/{play-CWdk0_Sy.js → play-B-FodgHV.js} +1 -1
- package/lib/public/assets/{plus-B2xCnntV.js → plus-CsIk10Cq.js} +1 -1
- package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-DkFKkMfl.js} +1 -1
- package/lib/public/assets/{recording-group-store-B75435xR.js → recording-group-store-WVM3hoXn.js} +1 -1
- package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-I2-zqLlI.js} +1 -1
- package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-DJTVCJX7.js} +1 -1
- package/lib/public/assets/{select-12RThAXc.js → select-DvCSv25z.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BLVfMJs_.js} +1 -1
- package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-oqadRokn.js} +1 -1
- package/lib/public/assets/session-detail-page-CGn55Pvl.js +4 -0
- package/lib/public/assets/{settings-C1VIluuT.js → settings-MsVU2ukE.js} +1 -1
- package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-DV9Ix-4O.js} +1 -1
- package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-6b_SEN2X.js} +1 -1
- package/lib/public/assets/{teams-uL4fjTDR.js → teams-BjZyfuHh.js} +1 -1
- package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-CTZpPSdd.js} +1 -1
- package/lib/public/assets/{upload-B3k8WCSP.js → upload-P3XzwQUb.js} +1 -1
- package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-1cOCMf-z.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-DgF9w088.js} +1 -1
- package/lib/public/assets/{users-DjQSucKb.js → users-CiNTKUiz.js} +1 -1
- package/lib/public/assets/{users-CqBuxMlX.js → users-DMF9Mk5H.js} +1 -1
- package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-CHp1KU_T.js} +1 -1
- package/lib/public/assets/{webhook-settings-DAHgnJyZ.js → webhook-settings-DiS8geIm.js} +1 -1
- package/lib/public/assets/{zap-gBHqutZV.js → zap-DRz0tDq5.js} +1 -1
- package/lib/public/index.html +2 -2
- package/lib/schema.json +0 -25
- package/lib/src/app/routers/control.js +19 -4
- package/lib/src/app/routers/streamStartConflict.js +3 -0
- package/lib/src/data-service/device-service.js +40 -0
- package/lib/src/data-service/device-store.js +4 -1
- package/lib/src/data-service/prisma-store.js +2 -1
- package/lib/src/device-utils.js +63 -13
- package/lib/src/middleware/deviceAccessGuard.js +63 -9
- package/lib/src/services/ServerManager.js +3 -0
- package/lib/src/services/SessionLifecycleService.js +16 -4
- package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
- package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
- package/lib/src/services/device-access/leaseHold.js +26 -0
- package/lib/src/services/device-access/ticketActorAccess.js +6 -0
- package/lib/src/services/lease/activeLeases.js +37 -0
- package/lib/src/services/recording/busy-precheck.js +24 -3
- package/lib/test/unit/device-access-lease.spec.js +270 -0
- package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
- package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
- package/lib/test/unit/lease-held-phone.spec.js +196 -0
- package/lib/test/unit/partial-config-file.spec.js +100 -0
- package/lib/test/unit/schema-required-args.spec.js +21 -16
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/schema.json +0 -25
- package/scripts/generate-types-from-schema.js +6 -0
- package/scripts/lib/always-present-plugin-args.js +39 -0
- package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
|
@@ -88,6 +88,8 @@ const streamLeave_1 = require("./streamLeave");
|
|
|
88
88
|
const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
|
|
89
89
|
const actor_1 = require("../../services/device-access/actor");
|
|
90
90
|
const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
|
|
91
|
+
const leaseHold_1 = require("../../services/device-access/leaseHold");
|
|
92
|
+
const activeLeases_1 = require("../../services/lease/activeLeases");
|
|
91
93
|
/**
|
|
92
94
|
* The answer for an unknown udid, and for another team's phone (see
|
|
93
95
|
* deviceTeamGuard): the same JSON on every route. Most routes used to send
|
|
@@ -623,7 +625,7 @@ const MJPEG_PROXY_CACHE = new Map();
|
|
|
623
625
|
* Start stream endpoint - Initiates WDA and MJPEG streaming for iOS devices
|
|
624
626
|
*/
|
|
625
627
|
router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
626
|
-
var _a, _b, _c, _d;
|
|
628
|
+
var _a, _b, _c, _d, _e;
|
|
627
629
|
const { udid } = req.params;
|
|
628
630
|
// Someone is about to watch: a page that just left (a reload, or another tab
|
|
629
631
|
// closing) must not stop the stream this page is starting.
|
|
@@ -654,6 +656,18 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
|
|
|
654
656
|
return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
655
657
|
}
|
|
656
658
|
}
|
|
659
|
+
// Busy with nothing in session_id is a lease's lock: is it the caller's?
|
|
660
|
+
let leaseHolder = false;
|
|
661
|
+
if (device.busy && !device.session_id && !actor.isAdmin) {
|
|
662
|
+
try {
|
|
663
|
+
const held = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, (u, h) => (0, activeLeases_1.activeLeaseOn)(u, h), (id) => ownerResolver.leaseHolderOf(id));
|
|
664
|
+
leaseHolder = (0, deviceAccessPolicy_1.isLeaseHolder)(held, actorUserId, actor.apiKeyId);
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
logger_1.default.error(`stream/start: lease lookup failed for ${udid}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
|
|
668
|
+
return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
669
|
+
}
|
|
670
|
+
}
|
|
657
671
|
const conflict = (0, streamStartConflict_1.decideStreamStartConflict)({
|
|
658
672
|
udid,
|
|
659
673
|
busy: !!device.busy,
|
|
@@ -663,6 +677,7 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
|
|
|
663
677
|
actorUserId,
|
|
664
678
|
actorApiKeyId: actor.apiKeyId,
|
|
665
679
|
isAdmin: actor.isAdmin,
|
|
680
|
+
leaseHolder,
|
|
666
681
|
});
|
|
667
682
|
if (conflict.action === 'deny') {
|
|
668
683
|
// holderName lookup is cosmetic — a failure here must still deny (never
|
|
@@ -673,7 +688,7 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
|
|
|
673
688
|
holderName = yield ownerResolver.displayName(conflict.holderId);
|
|
674
689
|
}
|
|
675
690
|
catch (e) {
|
|
676
|
-
logger_1.default.warn(`stream/start: holder name lookup failed for ${conflict.holderId}: ${(
|
|
691
|
+
logger_1.default.warn(`stream/start: holder name lookup failed for ${conflict.holderId}: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
|
|
677
692
|
}
|
|
678
693
|
}
|
|
679
694
|
logger_1.default.warn(`Manual Control refused for ${udid}: ${conflict.code} (holder=${conflict.holderId || 'unknown'})`);
|
|
@@ -685,11 +700,11 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
|
|
|
685
700
|
yield (0, device_service_1.unblockDevice)(udid, device.host);
|
|
686
701
|
}
|
|
687
702
|
catch (e) {
|
|
688
|
-
logger_1.default.warn(`Failed to clear orphaned lock on ${udid}: ${(
|
|
703
|
+
logger_1.default.warn(`Failed to clear orphaned lock on ${udid}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
|
|
689
704
|
}
|
|
690
705
|
}
|
|
691
706
|
try {
|
|
692
|
-
const h264Cfg = (0, androidH264Config_1.resolveAndroidH264)((
|
|
707
|
+
const h264Cfg = (0, androidH264Config_1.resolveAndroidH264)((_e = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs.streaming) === null || _e === void 0 ? void 0 : _e.androidH264);
|
|
693
708
|
const flagOn = h264Cfg.enabled;
|
|
694
709
|
// A recording device keeps MJPEG preview so we never run screenrecord and
|
|
695
710
|
// the recording's screencap pipeline against the same device at once.
|
|
@@ -21,6 +21,9 @@ function decideStreamStartConflict(i) {
|
|
|
21
21
|
var _a, _b;
|
|
22
22
|
if (!i.busy)
|
|
23
23
|
return PROCEED;
|
|
24
|
+
// Busy only by the caller's own lease: their phone to preview.
|
|
25
|
+
if (i.leaseHolder && !i.sessionId)
|
|
26
|
+
return PROCEED;
|
|
24
27
|
if ((0, manualLock_1.isManualLock)(i.sessionId)) {
|
|
25
28
|
// Orphan: lock persisted but nothing is serving (server restart, crashed
|
|
26
29
|
// stop, H.264 stop that skipped unlock). Any caller may reclaim it.
|
|
@@ -53,6 +53,7 @@ const prisma_1 = require("../prisma");
|
|
|
53
53
|
const manualLock_1 = require("../services/recording/manualLock");
|
|
54
54
|
const deviceVisibility_1 = require("../services/device-access/deviceVisibility");
|
|
55
55
|
const DeviceTeamResolver_1 = require("../services/device-access/DeviceTeamResolver");
|
|
56
|
+
const activeLeases_1 = require("../services/lease/activeLeases");
|
|
56
57
|
// Use a Proxy to ensure we're always using the latest store from the factory,
|
|
57
58
|
// which is critical for test isolation when the factory cache is cleared.
|
|
58
59
|
const store = new Proxy({}, {
|
|
@@ -434,10 +435,49 @@ function releaseClaimOn(device, ref) {
|
|
|
434
435
|
}
|
|
435
436
|
yield (0, device_utils_1.setUtilizationTime)(device.udid, totalUtilization);
|
|
436
437
|
logger_1.default.debug(`Released ${JSON.stringify(ref)} on ${device.udid}`);
|
|
438
|
+
if (yield keepLeaseLock(device))
|
|
439
|
+
return true;
|
|
437
440
|
emitUnblocked(device);
|
|
438
441
|
return true;
|
|
439
442
|
});
|
|
440
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* A session's phone that an SDK lease still holds goes back to the lease,
|
|
446
|
+
* not to the pool: the release above cleared `busy`, which is the lease's
|
|
447
|
+
* only lock (deviceClaims.ts). Allocation would skip the phone anyway, but
|
|
448
|
+
* the device list, the ownership guard and the dashboard read `busy`. The
|
|
449
|
+
* lease's own end (release, or LeaseOrphanSweeper) clears it. True when the
|
|
450
|
+
* phone was kept for its lease.
|
|
451
|
+
*/
|
|
452
|
+
function keepLeaseLock(device) {
|
|
453
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
454
|
+
try {
|
|
455
|
+
if (!(yield (0, activeLeases_1.activeLeaseOn)(device.udid, device.host)))
|
|
456
|
+
return false;
|
|
457
|
+
// lastCmdExecutedAt, so that if this lock outlives its lease (the undo
|
|
458
|
+
// below fails) the idle sweeper still frees it: it skips the phone only
|
|
459
|
+
// while a live lease holds it.
|
|
460
|
+
yield store.updateDevice(device.udid, device.host, {
|
|
461
|
+
busy: true,
|
|
462
|
+
lastCmdExecutedAt: Date.now(),
|
|
463
|
+
});
|
|
464
|
+
// The lease may have ended between the check and the write; its own
|
|
465
|
+
// release ran before ours and cleared nothing. Then undo, by the same
|
|
466
|
+
// conditional clear it uses. A release after this check runs after the
|
|
467
|
+
// write, and clears it itself.
|
|
468
|
+
if (!(yield (0, activeLeases_1.activeLeaseOn)(device.udid, device.host))) {
|
|
469
|
+
yield store.releaseLeaseLock(device.udid, device.host);
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
logger_1.default.debug(`Kept ${device.udid} at ${device.host} busy for its lease`);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
logger_1.default.warn(`Could not check ${device.udid} for a lease after its session: ${error}`);
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
441
481
|
/** The phone's total use once the session on it now ends. */
|
|
442
482
|
function utilizationAfter(device) {
|
|
443
483
|
var _a;
|
|
@@ -246,7 +246,10 @@ class LokiDeviceStore {
|
|
|
246
246
|
});
|
|
247
247
|
const blockedKeys = new Set(activeLeases.map((l) => `${l.deviceUdid}@${l.deviceHost}`));
|
|
248
248
|
const devices = yield this.getDevices(filterOptions);
|
|
249
|
-
const available = devices.find((d) => !d.busy &&
|
|
249
|
+
const available = devices.find((d) => !d.busy &&
|
|
250
|
+
!d.userBlocked &&
|
|
251
|
+
!blockedKeys.has(`${d.udid}@${d.host}`) &&
|
|
252
|
+
(!options.only || options.only.has(`${d.udid}@${d.host}`)));
|
|
250
253
|
if (available) {
|
|
251
254
|
yield this.updateDevice(available.udid, available.host, Object.assign({ busy: true }, (options.claim ? { claimedAt: Date.now(), claimSessionId: null } : {})));
|
|
252
255
|
return available;
|
|
@@ -323,7 +323,8 @@ class PrismaDeviceStore {
|
|
|
323
323
|
const candidates = yield this.getDevices(Object.assign(Object.assign({}, filterOptions), { busy: false }));
|
|
324
324
|
// 2. Attempt to atomically lock them one by one, skipping lease-blocked devices
|
|
325
325
|
for (const device of candidates) {
|
|
326
|
-
|
|
326
|
+
const key = `${device.udid}@${device.host}`;
|
|
327
|
+
if (blockedKeys.has(key) || (options.only && !options.only.has(key))) {
|
|
327
328
|
continue;
|
|
328
329
|
}
|
|
329
330
|
const now = Date.now();
|
package/lib/src/device-utils.js
CHANGED
|
@@ -99,6 +99,7 @@ const config_1 = require("./config");
|
|
|
99
99
|
const device_store_1 = require("./data-service/device-store");
|
|
100
100
|
const leaseSessionCaps_1 = require("./services/lease/leaseSessionCaps");
|
|
101
101
|
const deviceVisibility_1 = require("./services/device-access/deviceVisibility");
|
|
102
|
+
const activeLeases_1 = require("./services/lease/activeLeases");
|
|
102
103
|
// Use a Proxy to ensure we're always using the latest store from the factory,
|
|
103
104
|
// which is critical for test isolation when the factory cache is cleared.
|
|
104
105
|
const pendingStore = new Proxy({}, {
|
|
@@ -182,6 +183,14 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
182
183
|
// text or its stack tells them apart.
|
|
183
184
|
if (!leasedDevice)
|
|
184
185
|
throw new Error((0, leaseSessionCaps_1.leaseRefusalMessage)(leaseIdCap));
|
|
186
|
+
// As for an allocated phone below: the idle sweeper reads this session's
|
|
187
|
+
// own timeout from the row, and its idle time from now, not from when the
|
|
188
|
+
// lease was taken. Without it a leased session was ended after the
|
|
189
|
+
// server-wide newCommandTimeoutSec, whatever the session asked for.
|
|
190
|
+
yield (0, device_service_1.updatedAllocatedDevice)(leasedDevice, {
|
|
191
|
+
newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
|
|
192
|
+
lastCmdExecutedAt: Date.now(),
|
|
193
|
+
});
|
|
185
194
|
return leasedDevice;
|
|
186
195
|
}
|
|
187
196
|
const filters = getDeviceFiltersFromCapability(firstMatch, pluginArgs);
|
|
@@ -195,29 +204,45 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
195
204
|
const intervalBetweenAttempts = firstMatch[customCapability.deviceQueryInterval] || deviceQueryIntervalMs;
|
|
196
205
|
let device = null;
|
|
197
206
|
const store = device_store_1.DeviceStoreFactory.getStore();
|
|
207
|
+
// Set once the wait has given up. waitUntil rejects on its timeout but
|
|
208
|
+
// doesn't stop an attempt already running, which could then claim a phone
|
|
209
|
+
// for a create that has failed: a pending claim nobody would ever use.
|
|
210
|
+
let abandoned = false;
|
|
198
211
|
try {
|
|
199
212
|
yield (0, async_wait_until_1.default)(() => __awaiter(this, void 0, void 0, function* () {
|
|
213
|
+
if (abandoned)
|
|
214
|
+
return false;
|
|
200
215
|
const maxSessions = getDeviceManager().getMaxSessionCount();
|
|
201
216
|
const busyDevicesCount = yield getBusyDevicesCount();
|
|
202
217
|
if (maxSessions !== undefined && busyDevicesCount === maxSessions) {
|
|
203
218
|
logger_1.default.info(`Waiting for session available, already at max session count of: ${maxSessions}`);
|
|
204
219
|
return false;
|
|
205
220
|
}
|
|
206
|
-
// Get matching devices
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
if (availableCandidate) {
|
|
221
|
+
// Get matching devices that are not reserved
|
|
222
|
+
const candidates = (yield (0, device_service_1.getDevices)(filters)).filter((d) => !(0, device_service_1.isDeviceReserved)(d));
|
|
223
|
+
if (candidates.length > 0) {
|
|
210
224
|
// Principal Intelligence: Multi-node consistent locking
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
225
|
+
// Atomically claim one of them: busy, with a pending claim for the
|
|
226
|
+
// session being created (deviceClaims.ts). Every candidate, not just
|
|
227
|
+
// the first: the lock skips a phone an SDK lease holds, and offering
|
|
228
|
+
// it alone stalled the create while other phones were free. Exact
|
|
229
|
+
// (udid, host) pairs: a udid repeats across hosts, and a reserved
|
|
230
|
+
// twin of a candidate must not be locked in its place.
|
|
231
|
+
const locked = yield store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [...new Set(candidates.map((d) => d.udid))] }), { claim: true, only: new Set(candidates.map((d) => `${d.udid}@${d.host}`)) });
|
|
232
|
+
if (locked && abandoned) {
|
|
233
|
+
yield (0, device_service_1.releasePendingClaim)(locked);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
device = locked;
|
|
237
|
+
if (device !== null)
|
|
238
|
+
return true;
|
|
215
239
|
}
|
|
216
240
|
logger_1.default.info(`Waiting for free device. Filter: ${JSON.stringify(filters)}}`);
|
|
217
241
|
return false;
|
|
218
242
|
}), { timeout, intervalBetweenAttempts });
|
|
219
243
|
}
|
|
220
244
|
catch (err) {
|
|
245
|
+
abandoned = true;
|
|
221
246
|
// figure out whether the device is simply busy or non-existent
|
|
222
247
|
const filterCopy = Object.assign({}, filters);
|
|
223
248
|
delete filterCopy.busy;
|
|
@@ -258,11 +283,9 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
258
283
|
// Since findAndLockDevice already sets busy: true, we don't need blockDevice(device.udid, device.host);
|
|
259
284
|
logger_1.default.info(`📱 Locked device ${lockedDevice.udid} at host ${lockedDevice.host} for new session`);
|
|
260
285
|
yield updateCapabilityForDevice(capability, lockedDevice);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, { newCommandTimeout });
|
|
286
|
+
yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, {
|
|
287
|
+
newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
|
|
288
|
+
});
|
|
266
289
|
return lockedDevice;
|
|
267
290
|
}
|
|
268
291
|
else {
|
|
@@ -271,6 +294,10 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
271
294
|
}
|
|
272
295
|
});
|
|
273
296
|
}
|
|
297
|
+
/** The session's own `appium:newCommandTimeout` (seconds), else the server's. */
|
|
298
|
+
function sessionNewCommandTimeout(firstMatch, pluginArgs) {
|
|
299
|
+
return firstMatch['appium:newCommandTimeout'] || pluginArgs.newCommandTimeoutSec;
|
|
300
|
+
}
|
|
274
301
|
/**
|
|
275
302
|
* The device a lease-bound session may use, or null when it may not: the lease
|
|
276
303
|
* is missing, inactive or expired, the session did not prove it holds it, or
|
|
@@ -604,6 +631,23 @@ function releaseBlockedDevices(newCommandTimeout) {
|
|
|
604
631
|
return __awaiter(this, void 0, void 0, function* () {
|
|
605
632
|
var _a;
|
|
606
633
|
const busyDevices = yield unblockCandidateDevices();
|
|
634
|
+
// A phone an SDK lease holds is the lease's to time out (its heartbeats and
|
|
635
|
+
// expiry, LeaseOrphanSweeper), not idle until a session runs on it. A lease
|
|
636
|
+
// sets lastCmdExecutedAt when taken, so without this the sweeper freed a
|
|
637
|
+
// leased phone the new-command timeout later, before a slow first session
|
|
638
|
+
// had even started on it.
|
|
639
|
+
let leases = new Map();
|
|
640
|
+
if (busyDevices.length) {
|
|
641
|
+
try {
|
|
642
|
+
leases = yield (0, activeLeases_1.activeLeasesByDevice)();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
// Not knowing which phones are leased, sweeping would free all of them:
|
|
646
|
+
// their lastCmdExecutedAt is when the lease was taken. Next tick.
|
|
647
|
+
logger_1.default.warn(`Idle sweep skipped: could not read active leases: ${err}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
607
651
|
logger_1.default.debug(`Found ${busyDevices.length} device candidates to be released`);
|
|
608
652
|
for (const device of busyDevices) {
|
|
609
653
|
// need to keep this to make typescript happy. good thing tho.
|
|
@@ -622,6 +666,12 @@ function releaseBlockedDevices(newCommandTimeout) {
|
|
|
622
666
|
}
|
|
623
667
|
continue;
|
|
624
668
|
}
|
|
669
|
+
// Leased, with no session or hold on it: not idle.
|
|
670
|
+
if (leases.has((0, activeLeases_1.leaseKey)(device.udid, device.host)) &&
|
|
671
|
+
!device.claimSessionId &&
|
|
672
|
+
!device.session_id) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
625
675
|
const timeoutSeconds = device.newCommandTimeout != undefined ? device.newCommandTimeout : newCommandTimeout;
|
|
626
676
|
const timeSinceLastCmdExecuted = (currentEpoch - device.lastCmdExecutedAt) / 1000;
|
|
627
677
|
if (timeSinceLastCmdExecuted > timeoutSeconds) {
|
|
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
|
|
15
|
+
exports.LEASE_CHECKED_MUTATIONS = exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
|
|
16
16
|
exports.deviceAccessGuard = deviceAccessGuard;
|
|
17
17
|
const typedi_1 = require("typedi");
|
|
18
18
|
const logger_1 = __importDefault(require("../logger"));
|
|
@@ -20,8 +20,10 @@ const manualLock_1 = require("../services/recording/manualLock");
|
|
|
20
20
|
const controlDevice_1 = require("./controlDevice");
|
|
21
21
|
const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
|
|
22
22
|
const deviceAccessPolicy_1 = require("../services/device-access/deviceAccessPolicy");
|
|
23
|
+
const leaseHold_1 = require("../services/device-access/leaseHold");
|
|
23
24
|
const actor_1 = require("../services/device-access/actor");
|
|
24
25
|
const deviceClaims_1 = require("../data-service/deviceClaims");
|
|
26
|
+
const activeLeases_1 = require("../services/lease/activeLeases");
|
|
25
27
|
const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
26
28
|
/**
|
|
27
29
|
* Mutations under /control that this guard must NOT handle.
|
|
@@ -56,6 +58,14 @@ exports.UNGUARDED_CONTROL_MUTATIONS = [
|
|
|
56
58
|
* "reads are cheap" assumption the mosaic depends on.
|
|
57
59
|
*/
|
|
58
60
|
exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
|
|
61
|
+
/**
|
|
62
|
+
* Unguarded mutations that still take the lease rule: starting a preview
|
|
63
|
+
* takes a hold on the phone. stream/start's own conflict handling knows
|
|
64
|
+
* holds and sessions, not leases, and on a hub the call goes on to the
|
|
65
|
+
* node, which knows nothing of the hub's leases. So a stranger is refused a
|
|
66
|
+
* leased phone here, before either, and the lease holder passes on to it.
|
|
67
|
+
*/
|
|
68
|
+
exports.LEASE_CHECKED_MUTATIONS = ['stream/start'];
|
|
59
69
|
/**
|
|
60
70
|
* Refuse /control mutations against a device somebody else holds.
|
|
61
71
|
*
|
|
@@ -65,14 +75,16 @@ exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
|
|
|
65
75
|
* handlers and was remembered in none of them.
|
|
66
76
|
*/
|
|
67
77
|
function deviceAccessGuard(deps = {}) {
|
|
68
|
-
var _a, _b, _c;
|
|
78
|
+
var _a, _b, _c, _d, _e;
|
|
69
79
|
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
70
80
|
const resolveSessionOwner = (_b = deps.resolveSessionOwner) !== null && _b !== void 0 ? _b : ((sid) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sid));
|
|
71
81
|
const describeHolder = (_c = deps.describeHolder) !== null && _c !== void 0 ? _c : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id));
|
|
82
|
+
const findActiveLease = (_d = deps.findActiveLease) !== null && _d !== void 0 ? _d : ((udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host));
|
|
83
|
+
const resolveLeaseHolder = (_e = deps.resolveLeaseHolder) !== null && _e !== void 0 ? _e : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(id));
|
|
72
84
|
const unavailable = (res) => res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
73
85
|
return function (req, res, next) {
|
|
74
86
|
return __awaiter(this, void 0, void 0, function* () {
|
|
75
|
-
var _a, _b, _c;
|
|
87
|
+
var _a, _b, _c, _d, _e;
|
|
76
88
|
// Router-level middleware has no req.params; controlAction reads the path.
|
|
77
89
|
// It lowercases, as Express routes case-insensitively: `Stream/Start`
|
|
78
90
|
// still reaches stream/start's own richer handling, and `Clipboard`
|
|
@@ -81,8 +93,9 @@ function deviceAccessGuard(deps = {}) {
|
|
|
81
93
|
// Decide whether this request is in scope BEFORE touching the udid, so an
|
|
82
94
|
// ordinary read (screenshot, stream, logs, page source) short-circuits here
|
|
83
95
|
// exactly as it did when this guard was mutations-only.
|
|
96
|
+
const leaseOnly = req.method === 'POST' && exports.LEASE_CHECKED_MUTATIONS.includes(action);
|
|
84
97
|
const inScope = STATE_CHANGING.has(req.method)
|
|
85
|
-
? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action)
|
|
98
|
+
? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action) || leaseOnly
|
|
86
99
|
: req.method === 'GET' && exports.OWNERSHIP_CHECKED_READS.includes(action);
|
|
87
100
|
if (!action || !inScope)
|
|
88
101
|
return next();
|
|
@@ -116,6 +129,26 @@ function deviceAccessGuard(deps = {}) {
|
|
|
116
129
|
// resolved device to reason about ownership at all.
|
|
117
130
|
if (!device)
|
|
118
131
|
return next();
|
|
132
|
+
if (leaseOnly) {
|
|
133
|
+
if (actor.isAdmin)
|
|
134
|
+
return next();
|
|
135
|
+
let held;
|
|
136
|
+
try {
|
|
137
|
+
held = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
|
|
141
|
+
return unavailable(res);
|
|
142
|
+
}
|
|
143
|
+
if (!held || (0, deviceAccessPolicy_1.isLeaseHolder)(held, actor.userId, actor.apiKeyId))
|
|
144
|
+
return next();
|
|
145
|
+
return deny(req, res, udid, actor.userId, {
|
|
146
|
+
allow: false,
|
|
147
|
+
code: 'device_held_by_another_user',
|
|
148
|
+
holderId: (_c = held.holderUserId) !== null && _c !== void 0 ? _c : '',
|
|
149
|
+
heldBy: 'lease',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
119
152
|
let sessionOwnerUserId = null;
|
|
120
153
|
if (device.busy && device.session_id && !(0, manualLock_1.isManualLock)(device.session_id)) {
|
|
121
154
|
try {
|
|
@@ -124,7 +157,19 @@ function deviceAccessGuard(deps = {}) {
|
|
|
124
157
|
catch (e) {
|
|
125
158
|
// Same reasoning as the device lookup above: not knowing who owns a
|
|
126
159
|
// live session is exactly the case where we must not guess allow.
|
|
127
|
-
logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(
|
|
160
|
+
logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
|
|
161
|
+
return unavailable(res);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let lease = null;
|
|
165
|
+
if (!actor.isAdmin) {
|
|
166
|
+
try {
|
|
167
|
+
lease = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
// A leased phone is held whatever `busy` says: not knowing whether a
|
|
171
|
+
// lease holds it is not knowing who owns it.
|
|
172
|
+
logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_e = e === null || e === void 0 ? void 0 : e.message) !== null && _e !== void 0 ? _e : e}`);
|
|
128
173
|
return unavailable(res);
|
|
129
174
|
}
|
|
130
175
|
}
|
|
@@ -138,9 +183,16 @@ function deviceAccessGuard(deps = {}) {
|
|
|
138
183
|
actorUserId: actor.userId,
|
|
139
184
|
actorApiKeyId: actor.apiKeyId,
|
|
140
185
|
isAdmin: actor.isAdmin,
|
|
186
|
+
lease,
|
|
141
187
|
});
|
|
142
188
|
if (decision.allow)
|
|
143
189
|
return next();
|
|
190
|
+
return deny(req, res, udid, actor.userId, decision);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
function deny(req, res, udid, actorUserId, decision) {
|
|
194
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
195
|
+
var _a;
|
|
144
196
|
// describeHolder is cosmetic — it only resolves a display name for the
|
|
145
197
|
// deny message. A failure here must still deny (never flip to allow) but
|
|
146
198
|
// must not hang the request either; fall back to no holder name.
|
|
@@ -150,12 +202,14 @@ function deviceAccessGuard(deps = {}) {
|
|
|
150
202
|
holderName = yield describeHolder(decision.holderId);
|
|
151
203
|
}
|
|
152
204
|
catch (e) {
|
|
153
|
-
logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(
|
|
205
|
+
logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
|
|
154
206
|
}
|
|
155
207
|
}
|
|
156
|
-
logger_1.default.warn(`Device access denied: ${
|
|
208
|
+
logger_1.default.warn(`Device access denied: ${actorUserId} -> ${req.method} ${req.originalUrl} ` +
|
|
157
209
|
`on ${udid} (${decision.code}, holder=${decision.holderId || 'unknown'})`);
|
|
158
|
-
return res
|
|
210
|
+
return res
|
|
211
|
+
.status(409)
|
|
212
|
+
.json((0, deviceAccessPolicy_1.denyBody)(decision.code, decision.holderId, holderName, decision.heldBy));
|
|
159
213
|
});
|
|
160
|
-
}
|
|
214
|
+
}
|
|
161
215
|
}
|
|
@@ -71,6 +71,7 @@ const LogcatStreamService_1 = require("../device-managers/android/LogcatStreamSe
|
|
|
71
71
|
const IOSLogStreamService_1 = require("../device-managers/ios/IOSLogStreamService");
|
|
72
72
|
const logSource_1 = require("./logcat/logSource");
|
|
73
73
|
const SessionOwnerResolver_1 = require("./device-access/SessionOwnerResolver");
|
|
74
|
+
const activeLeases_1 = require("./lease/activeLeases");
|
|
74
75
|
const ticketActorAccess_1 = require("./device-access/ticketActorAccess");
|
|
75
76
|
// enable resolveJsonModule in tsconfig must be true for this to work
|
|
76
77
|
const package_json_1 = __importDefault(require("../../package.json"));
|
|
@@ -169,6 +170,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
169
170
|
authorize: (0, ticketActorAccess_1.makeTicketActorAuthorizer)({
|
|
170
171
|
findDevice: (udid) => device_store_1.DeviceStoreFactory.getStore().findDevice({ udid }),
|
|
171
172
|
resolveSessionOwner: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId),
|
|
173
|
+
findActiveLease: (udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host),
|
|
174
|
+
resolveLeaseHolder: (actorId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(actorId),
|
|
172
175
|
}),
|
|
173
176
|
// One WebSocket, two transports. The device's platform picks which —
|
|
174
177
|
// see resolveLogSource. The filter is only meaningful for ostrace,
|
|
@@ -222,9 +222,7 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
222
222
|
// Who asked, for the queue's team scoping (queueVisibility). On this row
|
|
223
223
|
// only, never in `caps`, and last so a client can't supply its own.
|
|
224
224
|
[queueVisibility_1.REQUESTER_KEY]: authResult.requester }));
|
|
225
|
-
const
|
|
226
|
-
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
227
|
-
const device = yield commandsQueueGuard.acquire(lockName, () => __awaiter(this, void 0, void 0, function* () {
|
|
225
|
+
const allocate = () => __awaiter(this, void 0, void 0, function* () {
|
|
228
226
|
var _a;
|
|
229
227
|
try {
|
|
230
228
|
return yield (0, device_utils_1.allocateDeviceForSession)(caps, pluginArgs.deviceAvailabilityTimeoutMs, pluginArgs.deviceAvailabilityQueryIntervalMs, pluginArgs, sessionTeamIds, {
|
|
@@ -238,7 +236,21 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
238
236
|
yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
|
|
239
237
|
throw err;
|
|
240
238
|
}
|
|
241
|
-
})
|
|
239
|
+
});
|
|
240
|
+
// Creates for a platform queue on one lock, first come first served, and
|
|
241
|
+
// each has its full deviceAvailabilityTimeoutMs from the head of the
|
|
242
|
+
// queue. A lease-bound session allocates nothing (its phone is the
|
|
243
|
+
// lease's) and doesn't queue: behind the lock it waited for every create
|
|
244
|
+
// ahead of it, each of which may wait minutes for a busy phone.
|
|
245
|
+
let device;
|
|
246
|
+
if ((0, leaseSessionCaps_1.leaseIdOf)(caps)) {
|
|
247
|
+
device = yield allocate();
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const lockName = this.getLockName(caps);
|
|
251
|
+
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
252
|
+
device = yield commandsQueueGuard.acquire(lockName, allocate);
|
|
253
|
+
}
|
|
242
254
|
yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
|
|
243
255
|
this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
|
|
244
256
|
const allocation = {
|
|
@@ -46,9 +46,34 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
46
46
|
this.db = db;
|
|
47
47
|
this.ownerCache = new Map();
|
|
48
48
|
this.nameCache = new Map();
|
|
49
|
+
this.leaseHolderCache = new Map();
|
|
49
50
|
/** The owners of this server's live sessions. A field, not a constructor parameter: TypeDI. */
|
|
50
51
|
this.liveOwners = () => typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners);
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The user behind a lease's `actorId`: the owner of the API key that
|
|
55
|
+
* created it, or, for a lease made with a bearer token, the user id itself.
|
|
56
|
+
* Positive results are cached, as for sessions: a lease's creator never
|
|
57
|
+
* changes.
|
|
58
|
+
*/
|
|
59
|
+
leaseHolderOf(actorId) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a;
|
|
62
|
+
if (!actorId)
|
|
63
|
+
return null;
|
|
64
|
+
const cached = this.leaseHolderCache.get(actorId);
|
|
65
|
+
if (cached)
|
|
66
|
+
return cached;
|
|
67
|
+
const key = yield this.db.apiKey.findUnique({
|
|
68
|
+
where: { id: actorId },
|
|
69
|
+
select: { userId: true },
|
|
70
|
+
});
|
|
71
|
+
const holder = key ? ((_a = key.userId) !== null && _a !== void 0 ? _a : null) : actorId;
|
|
72
|
+
if (holder)
|
|
73
|
+
this.remember(this.leaseHolderCache, actorId, holder);
|
|
74
|
+
return holder;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
52
77
|
ownerOf(sessionId) {
|
|
53
78
|
return __awaiter(this, void 0, void 0, function* () {
|
|
54
79
|
var _a, _b;
|
|
@@ -165,6 +190,7 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
165
190
|
clear() {
|
|
166
191
|
this.ownerCache.clear();
|
|
167
192
|
this.nameCache.clear();
|
|
193
|
+
this.leaseHolderCache.clear();
|
|
168
194
|
}
|
|
169
195
|
remember(cache, key, value) {
|
|
170
196
|
if (cache.size >= MAX_CACHE_ENTRIES)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isSelfManualLock = isSelfManualLock;
|
|
4
4
|
exports.isOwnSession = isOwnSession;
|
|
5
|
+
exports.isLeaseHolder = isLeaseHolder;
|
|
5
6
|
exports.evaluateDeviceAccess = evaluateDeviceAccess;
|
|
6
7
|
exports.ownershipUnavailableBody = ownershipUnavailableBody;
|
|
7
8
|
exports.denyBody = denyBody;
|
|
@@ -23,10 +24,39 @@ function isOwnSession(sessionOwnerUserId, actorUserId) {
|
|
|
23
24
|
const owner = sessionOwnerUserId !== null && sessionOwnerUserId !== void 0 ? sessionOwnerUserId : '';
|
|
24
25
|
return !!(owner && actorUserId && owner === actorUserId);
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* True when the caller holds the lease: by the key that created it, or as
|
|
29
|
+
* the user behind it (any credential of theirs, the dashboard included), as
|
|
30
|
+
* ownership is keyed on the user everywhere else.
|
|
31
|
+
*/
|
|
32
|
+
function isLeaseHolder(lease, actorUserId, actorApiKeyId) {
|
|
33
|
+
if (!lease)
|
|
34
|
+
return false;
|
|
35
|
+
if (actorApiKeyId && lease.actorId === actorApiKeyId)
|
|
36
|
+
return true;
|
|
37
|
+
return !!actorUserId && (lease.actorId === actorUserId || lease.holderUserId === actorUserId);
|
|
38
|
+
}
|
|
26
39
|
function evaluateDeviceAccess(input) {
|
|
27
|
-
var _a;
|
|
40
|
+
var _a, _b;
|
|
28
41
|
if (input.isAdmin)
|
|
29
42
|
return ALLOW;
|
|
43
|
+
if (input.lease) {
|
|
44
|
+
if (isLeaseHolder(input.lease, input.actorUserId, input.actorApiKeyId))
|
|
45
|
+
return ALLOW;
|
|
46
|
+
// A session on a leased phone proved it may use the lease; its owner
|
|
47
|
+
// keeps driving it, as with any session of their own.
|
|
48
|
+
if (input.sessionId &&
|
|
49
|
+
!(0, manualLock_1.isManualLock)(input.sessionId) &&
|
|
50
|
+
isOwnSession(input.sessionOwnerUserId, input.actorUserId)) {
|
|
51
|
+
return ALLOW;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
allow: false,
|
|
55
|
+
code: 'device_held_by_another_user',
|
|
56
|
+
holderId: (_a = input.lease.holderUserId) !== null && _a !== void 0 ? _a : '',
|
|
57
|
+
heldBy: 'lease',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
30
60
|
if (!input.busy)
|
|
31
61
|
return ALLOW;
|
|
32
62
|
const asUser = (0, manualLock_1.inspectManualLock)(input.sessionId, input.actorUserId, input.udid);
|
|
@@ -42,7 +72,7 @@ function evaluateDeviceAccess(input) {
|
|
|
42
72
|
}
|
|
43
73
|
// Not a manual lock: a real Appium session holds the device (or the row is
|
|
44
74
|
// busy with no id at all, which is an inconsistent state we fail closed on).
|
|
45
|
-
const owner = (
|
|
75
|
+
const owner = (_b = input.sessionOwnerUserId) !== null && _b !== void 0 ? _b : '';
|
|
46
76
|
if (isOwnSession(input.sessionOwnerUserId, input.actorUserId))
|
|
47
77
|
return ALLOW;
|
|
48
78
|
return { allow: false, code: 'device_in_use_by_session', holderId: owner };
|
|
@@ -62,11 +92,13 @@ function ownershipUnavailableBody() {
|
|
|
62
92
|
message: 'Could not verify device ownership. Try again.',
|
|
63
93
|
};
|
|
64
94
|
}
|
|
65
|
-
function denyBody(code, holderId, holderName) {
|
|
95
|
+
function denyBody(code, holderId, holderName, heldBy) {
|
|
66
96
|
const who = holderName || 'another user';
|
|
67
|
-
const message =
|
|
68
|
-
? `Device is
|
|
69
|
-
:
|
|
97
|
+
const message = heldBy === 'lease'
|
|
98
|
+
? `Device is leased by ${who} through the SDK. It is free again when their lease ends.`
|
|
99
|
+
: code === 'device_held_by_another_user'
|
|
100
|
+
? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
|
|
101
|
+
: `Device is in use by an Appium session owned by ${who}.`;
|
|
70
102
|
return {
|
|
71
103
|
success: false,
|
|
72
104
|
error: code,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.leaseHoldFor = leaseHoldFor;
|
|
13
|
+
/**
|
|
14
|
+
* The lease hold on a device row for an ownership decision, or null. Throws
|
|
15
|
+
* when either lookup does: the caller fails closed.
|
|
16
|
+
*/
|
|
17
|
+
function leaseHoldFor(device, findActiveLease, resolveLeaseHolder) {
|
|
18
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
19
|
+
if (!(device === null || device === void 0 ? void 0 : device.udid) || !device.host)
|
|
20
|
+
return null;
|
|
21
|
+
const lease = yield findActiveLease(device.udid, device.host);
|
|
22
|
+
if (!lease)
|
|
23
|
+
return null;
|
|
24
|
+
return { actorId: lease.actorId, holderUserId: yield resolveLeaseHolder(lease.actorId) };
|
|
25
|
+
});
|
|
26
|
+
}
|