@xenon-device-management/xenon 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +74 -0
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{Layouts-D0WSzKOh.js → Layouts-D6IPfwoe.js} +1 -1
  4. package/lib/public/assets/{ai-settings-DQWDdNd7.js → ai-settings-CflyFKan.js} +1 -1
  5. package/lib/public/assets/{apps-1sLWHOGO.js → apps-Da4dvQ1J.js} +1 -1
  6. package/lib/public/assets/{badge-BiR1gmMm.js → badge-BNR9umdu.js} +1 -1
  7. package/lib/public/assets/{button-BVazt4Z1.js → button-hZFV1ypT.js} +1 -1
  8. package/lib/public/assets/{calendar-yMyP2_Nc.js → calendar-fehdBtun.js} +1 -1
  9. package/lib/public/assets/{clock-CsVplnJ2.js → clock-DrpxSvCL.js} +1 -1
  10. package/lib/public/assets/{cpu-DNC8n7kK.js → cpu-tuyMVZ4I.js} +1 -1
  11. package/lib/public/assets/{device-explorer-DFu8Gxj4.js → device-explorer-DOfRH3zm.js} +1 -1
  12. package/lib/public/assets/{index-S71J2rWg.js → index-BaTiUCeH.js} +18 -18
  13. package/lib/public/assets/{lock-BstCxnX6.js → lock-C6CoqSr2.js} +1 -1
  14. package/lib/public/assets/{maintenance-settings-BwfG9cu2.js → maintenance-settings-CM2oC7-i.js} +1 -1
  15. package/lib/public/assets/{mouse-pointer-2-CSn_Wnc9.js → mouse-pointer-2-CXdnjXIg.js} +1 -1
  16. package/lib/public/assets/{plus-DfjM7G6e.js → plus-B4B1Hukt.js} +1 -1
  17. package/lib/public/assets/{session-dashboard-C6ek4z65.js → session-dashboard-B5OPMTz5.js} +1 -1
  18. package/lib/public/assets/{settings-BDYP8ULf.js → settings-BTHP7fj3.js} +1 -1
  19. package/lib/public/assets/{trash-2-CZWUMK5b.js → trash-2-NJMZJ2Ol.js} +1 -1
  20. package/lib/public/assets/{useSocket-CliVeWS3.js → useSocket-Ct2wo7P2.js} +2 -2
  21. package/lib/public/assets/{webhook-settings-tPiwWf8y.js → webhook-settings-Cz35-QJ7.js} +1 -1
  22. package/lib/public/assets/{zap-ZrK5B58i.js → zap-CssSMAN5.js} +1 -1
  23. package/lib/public/index.html +1 -1
  24. package/lib/schema.json +85 -38
  25. package/lib/src/InternalHttpClient.js +69 -14
  26. package/lib/src/app/index.js +92 -24
  27. package/lib/src/app/routers/apikeys.js +33 -0
  28. package/lib/src/app/routers/apps.js +4 -0
  29. package/lib/src/app/routers/auth.js +36 -0
  30. package/lib/src/app/routers/config.js +4 -0
  31. package/lib/src/app/routers/control.js +61 -10
  32. package/lib/src/app/routers/dashboard.js +5 -6
  33. package/lib/src/app/routers/grid.js +30 -12
  34. package/lib/src/app/routers/processes.js +24 -0
  35. package/lib/src/app/routers/reservation.js +15 -0
  36. package/lib/src/app/routers/webhook.js +6 -3
  37. package/lib/src/auth/nodeSecret.js +33 -0
  38. package/lib/src/config.js +5 -0
  39. package/lib/src/data-service/prisma-store.js +17 -1
  40. package/lib/src/device-managers/AndroidDeviceManager.js +2 -2
  41. package/lib/src/device-managers/NodeDevices.js +8 -1
  42. package/lib/src/device-managers/ios/IOSDiscoveryService.js +7 -4
  43. package/lib/src/device-managers/ios/IOSStreamService.js +7 -0
  44. package/lib/src/device-managers/ios/WDAClient.js +2 -0
  45. package/lib/src/device-utils.js +29 -4
  46. package/lib/src/generated/client/edge.js +2 -2
  47. package/lib/src/generated/client/index.js +2 -2
  48. package/lib/src/generated/client/package.json +1 -1
  49. package/lib/src/generated/client/schema.prisma +3 -0
  50. package/lib/src/helpers/UniversalMjpegProxy.js +23 -0
  51. package/lib/src/index.js +10 -2
  52. package/lib/src/interceptors/CommandInterceptor.js +29 -0
  53. package/lib/src/interfaces/IPluginArgs.js +0 -1
  54. package/lib/src/logger.js +30 -2
  55. package/lib/src/logging/sessionContext.js +28 -0
  56. package/lib/src/middleware/apiKeyMiddleware.js +49 -0
  57. package/lib/src/middleware/csrfMiddleware.js +73 -0
  58. package/lib/src/middleware/nodeSecretMiddleware.js +38 -0
  59. package/lib/src/middleware/rateLimitMiddleware.js +68 -0
  60. package/lib/src/middleware/scopeGuard.js +41 -0
  61. package/lib/src/plugin.js +1 -1
  62. package/lib/src/services/AIService.js +43 -8
  63. package/lib/src/services/ApiKeyService.js +102 -0
  64. package/lib/src/services/CircuitBreaker.js +158 -0
  65. package/lib/src/services/CleanupService.js +137 -39
  66. package/lib/src/services/DeviceReconciler.js +102 -0
  67. package/lib/src/services/MetricsService.js +78 -0
  68. package/lib/src/services/PortAllocator.js +13 -0
  69. package/lib/src/services/ProcessMetricsService.js +99 -0
  70. package/lib/src/services/ProcessRegistry.js +123 -0
  71. package/lib/src/services/ServerManager.js +14 -2
  72. package/lib/src/services/SessionLifecycleService.js +80 -23
  73. package/lib/src/services/ShutdownCoordinator.js +89 -0
  74. package/lib/src/services/SocketClient.js +11 -0
  75. package/lib/src/services/SocketServer.js +109 -6
  76. package/lib/src/services/VideoPipelineService.js +2 -0
  77. package/lib/src/services/healing/HealingMetrics.js +63 -0
  78. package/lib/src/services/healing/HealingOrchestrator.js +32 -4
  79. package/lib/src/services/healing/OcrHealingProvider.js +7 -0
  80. package/lib/test/unit/ApiKeyService.test.js +101 -0
  81. package/lib/test/unit/PortAllocator.test.js +14 -0
  82. package/lib/test/unit/ProcessRegistry.test.js +70 -0
  83. package/lib/test/unit/apiKeyMiddleware.test.js +58 -0
  84. package/lib/test/unit/nodeSecretMiddleware.test.js +38 -0
  85. package/lib/test/unit/rateLimitMiddleware.test.js +37 -0
  86. package/lib/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +2 -2
  88. package/prisma/migrations/20260423081701_add_session_indexes/migration.sql +8 -0
  89. package/prisma/schema.prisma +3 -0
  90. package/schema.json +85 -38
@@ -47,9 +47,13 @@ const config_service_1 = require("../../data-service/config-service");
47
47
  const typedi_1 = require("typedi");
48
48
  const logger_1 = __importStar(require("../../logger"));
49
49
  const AIService_1 = require("../../services/AIService");
50
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
50
51
  class ConfigRouter {
51
52
  static register(router, pluginArgs) {
52
53
  const configRouter = (0, express_1.Router)();
54
+ // All mutations under /config require admin scope (global plugin
55
+ // config + AI provider test probes). GET passthrough for read scope.
56
+ configRouter.use((0, scopeGuard_1.mutationScopeGuard)(['admin']));
53
57
  function getMaskedConfig(args) {
54
58
  const masked = Object.assign({}, args);
55
59
  // Boolean flags for secret status
@@ -60,7 +60,43 @@ const os_1 = __importDefault(require("os"));
60
60
  const fs_extra_1 = __importDefault(require("fs-extra"));
61
61
  const OmniVisionService_1 = require("../../services/omni-vision/OmniVisionService");
62
62
  const InspectorService_1 = require("../../services/InspectorService");
63
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
63
64
  const router = (0, express_1.Router)();
65
+ // Every mutation under /control (tap, swipe, install, shell, lock, etc.)
66
+ // requires devices scope. Read endpoints (screenshots, page source) stay
67
+ // open to any authenticated key.
68
+ router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
69
+ // Cloud metadata endpoints — never proxy to these regardless of caller.
70
+ const FORBIDDEN_PROXY_HOSTS = new Set([
71
+ '169.254.169.254', // AWS/Azure/GCP IMDS
72
+ 'metadata.google.internal',
73
+ 'metadata.goog',
74
+ '100.100.100.200', // Alibaba ECS metadata
75
+ 'fd00:ec2::254', // AWS IMDSv6
76
+ ]);
77
+ /**
78
+ * Build a safe proxy URL from a device's reported host. Strips any path,
79
+ * query, or fragment the host string carried, blocks cloud-metadata targets,
80
+ * and refuses non-http(s) schemes. Returns null if the host is unsafe.
81
+ */
82
+ function buildProxyUrl(deviceHost, req) {
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(deviceHost);
86
+ }
87
+ catch (_a) {
88
+ return null;
89
+ }
90
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
91
+ return null;
92
+ if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
93
+ return null;
94
+ // Only keep scheme + host + port; discard any attacker-baked path/query/fragment.
95
+ const origin = `${parsed.protocol}//${parsed.host}`;
96
+ // Treat req.originalUrl as a path — reparse to strip any control characters.
97
+ const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
98
+ return `${origin}${forwardPath}`;
99
+ }
64
100
  function getDeviceInfo(udid) {
65
101
  return __awaiter(this, void 0, void 0, function* () {
66
102
  return yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -89,9 +125,12 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
89
125
  return res.status(404).send('Device not found');
90
126
  const manager = yield getDeviceManagerForPlatform(device.platform);
91
127
  if (device.host && !device.host.includes(req.get('host') || '')) {
92
- logger_1.default.info(`Proxying tap for ${udid} to ${device.host}`);
128
+ const target = buildProxyUrl(device.host, req);
129
+ if (!target)
130
+ return res.status(400).send({ error: 'Unsafe device host' });
131
+ logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
93
132
  try {
94
- yield InternalHttpClient_1.InternalHttpClient.post(`${device.host}${req.originalUrl}`, req.body);
133
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
95
134
  return res.status(200).send({ success: true });
96
135
  }
97
136
  catch (err) {
@@ -130,9 +169,12 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
130
169
  return res.status(404).send('Device not found');
131
170
  const manager = yield getDeviceManagerForPlatform(device.platform);
132
171
  if (device.host && !device.host.includes(req.get('host') || '')) {
133
- logger_1.default.info(`Proxying swipe for ${udid} to ${device.host}`);
172
+ const target = buildProxyUrl(device.host, req);
173
+ if (!target)
174
+ return res.status(400).send({ error: 'Unsafe device host' });
175
+ logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
134
176
  try {
135
- yield InternalHttpClient_1.InternalHttpClient.post(`${device.host}${req.originalUrl}`, req.body);
177
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
136
178
  return res.status(200).send({ success: true });
137
179
  }
138
180
  catch (err) {
@@ -160,9 +202,12 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
160
202
  return res.status(404).send('Device not found');
161
203
  const manager = yield getDeviceManagerForPlatform(device.platform);
162
204
  if (device.host && !device.host.includes(req.get('host') || '')) {
163
- logger_1.default.info(`Proxying typeText for ${udid} to ${device.host}`);
205
+ const target = buildProxyUrl(device.host, req);
206
+ if (!target)
207
+ return res.status(400).send({ error: 'Unsafe device host' });
208
+ logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
164
209
  try {
165
- yield InternalHttpClient_1.InternalHttpClient.post(`${device.host}${req.originalUrl}`, req.body);
210
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
166
211
  return res.status(200).send({ success: true });
167
212
  }
168
213
  catch (err) {
@@ -190,9 +235,12 @@ router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, f
190
235
  return res.status(404).send('Device not found');
191
236
  const manager = yield getDeviceManagerForPlatform(device.platform);
192
237
  if (device.host && !device.host.includes(req.get('host') || '')) {
193
- logger_1.default.info(`Proxying keyevent for ${udid} to ${device.host}`);
238
+ const target = buildProxyUrl(device.host, req);
239
+ if (!target)
240
+ return res.status(400).send({ error: 'Unsafe device host' });
241
+ logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
194
242
  try {
195
- yield InternalHttpClient_1.InternalHttpClient.post(`${device.host}${req.originalUrl}`, req.body);
243
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
196
244
  return res.status(200).send({ success: true });
197
245
  }
198
246
  catch (err) {
@@ -286,9 +334,12 @@ router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void
286
334
  return res.status(404).send('Device not found');
287
335
  const manager = yield getDeviceManagerForPlatform(device.platform);
288
336
  if (device.host && !device.host.includes(req.get('host') || '')) {
289
- logger_1.default.info(`Proxying touchAndHold for ${udid} to ${device.host}`);
337
+ const target = buildProxyUrl(device.host, req);
338
+ if (!target)
339
+ return res.status(400).send({ error: 'Unsafe device host' });
340
+ logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
290
341
  try {
291
- yield InternalHttpClient_1.InternalHttpClient.post(`${device.host}${req.originalUrl}`, req.body);
342
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
292
343
  return res.status(200).send({ success: true });
293
344
  }
294
345
  catch (err) {
@@ -47,15 +47,12 @@ const SessionManager_1 = require("../../sessions/SessionManager");
47
47
  const UniversalMjpegProxy_1 = require("../../helpers/UniversalMjpegProxy");
48
48
  const web_config_service_1 = require("../../data-service/web-config-service");
49
49
  const typedi_1 = require("typedi");
50
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
50
51
  const MJPEG_PROXY_CACHE = new Map();
51
52
  //session guard
52
53
  function isValidSession(request, response, next) {
53
54
  return __awaiter(this, void 0, void 0, function* () {
54
55
  const sessionId = request.params.sessionId;
55
- // Principal Robustness: Allow virtual manual sessions
56
- if (sessionId && sessionId.startsWith('manual_')) {
57
- return next();
58
- }
59
56
  const session = yield prisma_1.prisma.session.findFirst({
60
57
  where: {
61
58
  id: sessionId,
@@ -293,8 +290,10 @@ function register(router) {
293
290
  router.get('/session/:sessionId/logs/debug', getDebugLogs);
294
291
  router.get('/session/:sessionId/profiling', getProfilingData);
295
292
  router.get('/config', getGlobalConfig);
296
- router.post('/config', updateGlobalConfig);
297
- router.post('/config/reset-metrics', resetMetrics);
293
+ // Config + destructive ops: admin-only. Read-only config stays open to any
294
+ // authenticated key so dashboards using 'read' scope can still populate.
295
+ router.post('/config', (0, scopeGuard_1.scopeGuard)(['admin']), updateGlobalConfig);
296
+ router.post('/config/reset-metrics', (0, scopeGuard_1.scopeGuard)(['admin']), resetMetrics);
298
297
  }
299
298
  exports.default = {
300
299
  register,
@@ -50,6 +50,7 @@ const queue_service_1 = require("../../data-service/queue-service");
50
50
  const InternalHttpClient_1 = require("../../InternalHttpClient");
51
51
  const lodash_1 = __importDefault(require("lodash"));
52
52
  const device_service_1 = require("../../data-service/device-service");
53
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
53
54
  const logger_1 = __importDefault(require("../../logger"));
54
55
  const device_managers_1 = require("../../device-managers");
55
56
  const typedi_1 = require("typedi");
@@ -135,24 +136,38 @@ function blockDevice(request, response) {
135
136
  return __awaiter(this, void 0, void 0, function* () {
136
137
  const requestBody = request.body;
137
138
  const device = yield (0, device_service_1.getDevice)(requestBody);
138
- if (!lodash_1.default.isNil(device)) {
139
+ if (lodash_1.default.isNil(device)) {
140
+ return response.status(404).json({ success: false, error: 'Device not found' });
141
+ }
142
+ try {
139
143
  yield (0, device_service_1.userBlockDevice)(device.udid, device.host);
140
144
  }
141
- response.status(200).send({
142
- success: true,
143
- });
145
+ catch (err) {
146
+ logger_1.default.error(`Failed to block device ${device.udid}@${device.host}: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
147
+ return response
148
+ .status(500)
149
+ .json({ success: false, error: (err === null || err === void 0 ? void 0 : err.message) || 'Failed to block device' });
150
+ }
151
+ response.status(200).send({ success: true });
144
152
  });
145
153
  }
146
154
  function unBlockDevice(request, response) {
147
155
  return __awaiter(this, void 0, void 0, function* () {
148
156
  const requestBody = request.body;
149
157
  const device = yield (0, device_service_1.getDevice)(requestBody);
150
- if (!lodash_1.default.isNil(device)) {
158
+ if (lodash_1.default.isNil(device)) {
159
+ return response.status(404).json({ success: false, error: 'Device not found' });
160
+ }
161
+ try {
151
162
  yield (0, device_service_1.userUnblockDevice)(device.udid, device.host);
152
163
  }
153
- response.status(200).send({
154
- success: true,
155
- });
164
+ catch (err) {
165
+ logger_1.default.error(`Failed to unblock device ${device.udid}@${device.host}: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
166
+ return response
167
+ .status(500)
168
+ .json({ success: false, error: (err === null || err === void 0 ? void 0 : err.message) || 'Failed to unblock device' });
169
+ }
170
+ response.status(200).send({ success: true });
156
171
  });
157
172
  }
158
173
  function getQueuedSessionLength(request, response) {
@@ -323,10 +338,13 @@ function register(router, pluginArgs) {
323
338
  router.get('/devices', getDevices);
324
339
  router.get('/device', getDevices);
325
340
  router.get('/device/:platform', getDeviceByPlatform);
326
- router.post('/register', registerNode);
327
- router.post('/block', blockDevice);
328
- router.post('/unblock', unBlockDevice);
329
- router.post('/device/tags', updateTags);
341
+ // Node registration + device manipulation all require devices scope.
342
+ // Node bootstrap keys have admin scope so they pass; operator keys
343
+ // need an explicit 'devices' grant.
344
+ router.post('/register', (0, scopeGuard_1.scopeGuard)(['devices']), registerNode);
345
+ router.post('/block', (0, scopeGuard_1.scopeGuard)(['devices']), blockDevice);
346
+ router.post('/unblock', (0, scopeGuard_1.scopeGuard)(['devices']), unBlockDevice);
347
+ router.post('/device/tags', (0, scopeGuard_1.scopeGuard)(['devices']), updateTags);
330
348
  // session related
331
349
  router.get('/queue/length', getQueuedSessionLength);
332
350
  router.get('/queue', getQueuedSessionRequests);
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.processesRouter = processesRouter;
4
+ const express_1 = require("express");
5
+ const typedi_1 = require("typedi");
6
+ const ProcessRegistry_1 = require("../../services/ProcessRegistry");
7
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
8
+ function processesRouter() {
9
+ const r = (0, express_1.Router)();
10
+ r.get('/', (0, scopeGuard_1.scopeGuard)(['admin']), (_req, res) => {
11
+ const snapshot = typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry)
12
+ .snapshot()
13
+ .map(({ id, sessionId, udid, kind, pid, startedAt }) => ({
14
+ id,
15
+ sessionId,
16
+ udid,
17
+ kind,
18
+ pid,
19
+ uptimeMs: Date.now() - startedAt,
20
+ }));
21
+ res.json(snapshot);
22
+ });
23
+ return r;
24
+ }
@@ -15,7 +15,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  const express_1 = __importDefault(require("express"));
16
16
  const device_service_1 = require("../../data-service/device-service");
17
17
  const logger_1 = __importDefault(require("../../logger"));
18
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
18
19
  const router = express_1.default.Router();
20
+ // Reserving / releasing / extending a device hold requires devices scope.
21
+ // GET listings remain open to any authenticated key.
22
+ router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
19
23
  // Duration options in milliseconds
20
24
  const DURATION_OPTIONS = {
21
25
  '1h': 60 * 60 * 1000,
@@ -77,6 +81,17 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
77
81
  error: `Invalid duration. Use: ${Object.keys(DURATION_OPTIONS).join(', ')} or a number in milliseconds`,
78
82
  });
79
83
  }
84
+ // Bounds check: refuse negative, zero, NaN, Infinity, and anything over 24h.
85
+ const MIN_DURATION_MS = 60 * 1000; // 1 minute
86
+ const MAX_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
87
+ if (!Number.isFinite(durationMs) ||
88
+ durationMs < MIN_DURATION_MS ||
89
+ durationMs > MAX_DURATION_MS) {
90
+ return res.status(400).json({
91
+ success: false,
92
+ error: `duration must be between ${MIN_DURATION_MS}ms (1 minute) and ${MAX_DURATION_MS}ms (24 hours)`,
93
+ });
94
+ }
80
95
  // Check if device exists
81
96
  const device = yield (0, device_service_1.getDevice)({ udid: [udid] });
82
97
  if (!device) {
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  const NotificationService_1 = require("../../services/NotificationService");
16
16
  const typedi_1 = require("typedi");
17
17
  const logger_1 = __importDefault(require("../../logger"));
18
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
18
19
  function getConfigs(req, res) {
19
20
  return __awaiter(this, void 0, void 0, function* () {
20
21
  try {
@@ -74,9 +75,11 @@ function testWebhook(req, res) {
74
75
  }
75
76
  function register(router) {
76
77
  router.get('/webhook', getConfigs);
77
- router.post('/webhook', addConfig);
78
- router.delete('/webhook/:id', deleteConfig);
79
- router.post('/webhook/test', testWebhook);
78
+ // Webhook mutations are admin-only: adding / removing / test-firing global
79
+ // webhooks is a fleet-wide config change.
80
+ router.post('/webhook', (0, scopeGuard_1.scopeGuard)(['admin']), addConfig);
81
+ router.delete('/webhook/:id', (0, scopeGuard_1.scopeGuard)(['admin']), deleteConfig);
82
+ router.post('/webhook/test', (0, scopeGuard_1.scopeGuard)(['admin']), testWebhook);
80
83
  }
81
84
  exports.default = {
82
85
  register,
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.validateNodeSecret = validateNodeSecret;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const logger_1 = __importDefault(require("../logger"));
9
+ function timingSafeEqStr(a, b) {
10
+ const ab = Buffer.from(a, 'utf8');
11
+ const bb = Buffer.from(b, 'utf8');
12
+ return ab.length === bb.length && crypto_1.default.timingSafeEqual(ab, bb);
13
+ }
14
+ let lastPreviousWarnAt = 0;
15
+ // Single source of truth for validating an inbound node secret. Returns
16
+ // 'reject' when no expected secret is configured — callers decide whether
17
+ // that should 401 or fall through. Timing-safe compare so the hub can't be
18
+ // probed by measuring response time differences.
19
+ function validateNodeSecret(given, expected) {
20
+ if (!given)
21
+ return 'reject';
22
+ if (expected.current && timingSafeEqStr(given, expected.current))
23
+ return 'current';
24
+ if (expected.previous && timingSafeEqStr(given, expected.previous)) {
25
+ const now = Date.now();
26
+ if (now - lastPreviousWarnAt > 60000) {
27
+ logger_1.default.warn('[nodeSecret] A caller authenticated with XENON_NODE_SECRET_PREVIOUS. Rotate the remaining callers to the new secret and drop PREVIOUS.');
28
+ lastPreviousWarnAt = now;
29
+ }
30
+ return 'previous';
31
+ }
32
+ return 'reject';
33
+ }
package/lib/src/config.js CHANGED
@@ -67,6 +67,11 @@ exports.config = {
67
67
  openaiModel: process.env.XENON_OPENAI_MODEL,
68
68
  anthropicModel: process.env.XENON_ANTHROPIC_MODEL,
69
69
  ollamaModel: process.env.XENON_OLLAMA_MODEL,
70
+ bootstrapKeyPath: process.env.XENON_BOOTSTRAP_KEY_PATH ||
71
+ path.join(basePath, 'bootstrap-key.txt'),
72
+ authDisabled: process.env.XENON_AUTH_DISABLED === 'true',
73
+ nodeSecret: process.env.XENON_NODE_SECRET,
74
+ nodeSecretPrevious: process.env.XENON_NODE_SECRET_PREVIOUS,
70
75
  };
71
76
  function updateConfig(newConfig) {
72
77
  Object.assign(exports.config, newConfig);
@@ -54,9 +54,25 @@ class PrismaDeviceStore {
54
54
  get prisma() {
55
55
  return typedi_1.Container.get(prisma_service_1.PrismaService).client;
56
56
  }
57
+ safeParse(raw, field, udid) {
58
+ if (!raw)
59
+ return undefined;
60
+ try {
61
+ return JSON.parse(raw);
62
+ }
63
+ catch (err) {
64
+ // Error (not warn) + truncated raw snippet so the corrupt row is
65
+ // findable. Returning undefined keeps getAllDevices alive, but without
66
+ // this log operators would see the field as "not set" and never know
67
+ // the DB actually has malformed data that needs manual repair.
68
+ const snippet = raw.length > 120 ? `${raw.slice(0, 120)}…` : raw;
69
+ logger_1.default.error(`[PrismaDeviceStore] Corrupt JSON in Device.${field} for udid=${udid} (${(err === null || err === void 0 ? void 0 : err.message) || err}). Raw: ${snippet}`);
70
+ return undefined;
71
+ }
72
+ }
57
73
  toIDevice(device) {
58
74
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
59
- return Object.assign(Object.assign({}, device), { cloud: device.cloud ? JSON.parse(device.cloud) : undefined, capability: device.capability ? JSON.parse(device.capability) : undefined, chromeDriverPath: device.chromeDriverPath ? JSON.parse(device.chromeDriverPath) : undefined, tags: device.tags ? JSON.parse(device.tags) : undefined, platform: (device.platform || 'android'), name: device.name || 'unknown', state: device.state || 'available', sdk: device.sdk || 'unknown', deviceType: device.deviceType || 'real', busy: (_a = device.busy) !== null && _a !== void 0 ? _a : false, userBlocked: (_b = device.userBlocked) !== null && _b !== void 0 ? _b : false, realDevice: (_c = device.realDevice) !== null && _c !== void 0 ? _c : true, lastHealthCheckAt: (_d = device.lastHealthCheckAt) !== null && _d !== void 0 ? _d : undefined, healthStatus: (_e = device.healthStatus) !== null && _e !== void 0 ? _e : 'Healthy', healthCheckError: (_f = device.healthCheckError) !== null && _f !== void 0 ? _f : undefined, batteryLevel: (_g = device.batteryLevel) !== null && _g !== void 0 ? _g : undefined, thermalStatus: (_h = device.thermalStatus) !== null && _h !== void 0 ? _h : undefined, storageFree: (_j = device.storageFree) !== null && _j !== void 0 ? _j : undefined, sessionProgress: (_k = device.sessionProgress) !== null && _k !== void 0 ? _k : '', totalHealedCount: (_l = device.totalHealedCount) !== null && _l !== void 0 ? _l : 0 });
75
+ return Object.assign(Object.assign({}, device), { cloud: this.safeParse(device.cloud, 'cloud', device.udid), capability: this.safeParse(device.capability, 'capability', device.udid), chromeDriverPath: this.safeParse(device.chromeDriverPath, 'chromeDriverPath', device.udid), tags: this.safeParse(device.tags, 'tags', device.udid), platform: (device.platform || 'android'), name: device.name || 'unknown', state: device.state || 'available', sdk: device.sdk || 'unknown', deviceType: device.deviceType || 'real', busy: (_a = device.busy) !== null && _a !== void 0 ? _a : false, userBlocked: (_b = device.userBlocked) !== null && _b !== void 0 ? _b : false, realDevice: (_c = device.realDevice) !== null && _c !== void 0 ? _c : true, lastHealthCheckAt: (_d = device.lastHealthCheckAt) !== null && _d !== void 0 ? _d : undefined, healthStatus: (_e = device.healthStatus) !== null && _e !== void 0 ? _e : 'Healthy', healthCheckError: (_f = device.healthCheckError) !== null && _f !== void 0 ? _f : undefined, batteryLevel: (_g = device.batteryLevel) !== null && _g !== void 0 ? _g : undefined, thermalStatus: (_h = device.thermalStatus) !== null && _h !== void 0 ? _h : undefined, storageFree: (_j = device.storageFree) !== null && _j !== void 0 ? _j : undefined, sessionProgress: (_k = device.sessionProgress) !== null && _k !== void 0 ? _k : '', totalHealedCount: (_l = device.totalHealedCount) !== null && _l !== void 0 ? _l : 0 });
60
76
  }
61
77
  fromIDevice(device) {
62
78
  const data = Object.assign({}, device);
@@ -525,7 +525,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
525
525
  const deviceTracked = Object.assign(Object.assign({}, trackedDevice), { nodeId: this.nodeId });
526
526
  if (this.pluginArgs.hub != undefined) {
527
527
  logger_1.default.info(`Updating Hub with device ${newDevice.udid}`);
528
- const nodeDevices = new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized);
528
+ const nodeDevices = new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized, this.pluginArgs.nodeSecret);
529
529
  yield nodeDevices.postDevicesToHub([deviceTracked], 'add');
530
530
  }
531
531
  // node also need a copy of devices, otherwise it cannot serve requests
@@ -575,7 +575,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
575
575
  state: device.type,
576
576
  };
577
577
  if (pluginArgs.hub != undefined) {
578
- const nodeDevices = new NodeDevices_1.default(pluginArgs.hub, pluginArgs.tlsRejectUnauthorized);
578
+ const nodeDevices = new NodeDevices_1.default(pluginArgs.hub, pluginArgs.tlsRejectUnauthorized, pluginArgs.nodeSecret);
579
579
  yield nodeDevices.postDevicesToHub([clonedDevice], 'remove');
580
580
  }
581
581
  // node also need a copy of devices, otherwise it cannot serve requests
@@ -15,9 +15,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  const logger_1 = __importDefault(require("../logger"));
16
16
  const InternalHttpClient_1 = require("../InternalHttpClient");
17
17
  class NodeDevices {
18
- constructor(host, tlsRejectUnauthorized) {
18
+ constructor(host, tlsRejectUnauthorized, nodeSecret) {
19
19
  this.host = host;
20
20
  this.tlsRejectUnauthorized = tlsRejectUnauthorized;
21
+ this.nodeSecret = nodeSecret;
22
+ }
23
+ nodeHeaders() {
24
+ return this.nodeSecret ? { 'x-xenon-node-secret': this.nodeSecret } : {};
21
25
  }
22
26
  postDevicesToHub(devices, arg) {
23
27
  return __awaiter(this, void 0, void 0, function* () {
@@ -30,6 +34,7 @@ class NodeDevices {
30
34
  params: {
31
35
  type: arg,
32
36
  },
37
+ headers: this.nodeHeaders(),
33
38
  });
34
39
  if (arg === 'add') {
35
40
  logger_1.default.info(`Pushed devices to hub ${JSON.stringify(devices)}`);
@@ -52,6 +57,7 @@ class NodeDevices {
52
57
  params: {
53
58
  type: 'unblock',
54
59
  },
60
+ headers: this.nodeHeaders(),
55
61
  });
56
62
  logger_1.default.info(`Unblocked device with filter: ${JSON.stringify(filter)}`);
57
63
  }
@@ -70,6 +76,7 @@ class NodeDevices {
70
76
  type: 'unregister',
71
77
  host,
72
78
  },
79
+ headers: this.nodeHeaders(),
73
80
  });
74
81
  logger_1.default.info(`Unregistered node ${host} from hub`);
75
82
  }
@@ -202,7 +202,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
202
202
  const simulators = yield this.fetchLocalSimulators();
203
203
  simulators.sort((a, b) => (a.state > b.state ? 1 : -1));
204
204
  if (this.pluginArgs.hub !== undefined) {
205
- const nodeDevices = new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized);
205
+ const nodeDevices = new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized, this.pluginArgs.nodeSecret);
206
206
  yield nodeDevices.postDevicesToHub(simulators, 'add');
207
207
  }
208
208
  return simulators;
@@ -238,9 +238,12 @@ let IOSDiscoveryService = class IOSDiscoveryService {
238
238
  const allowedSimulators = localPluginArgs.simulators;
239
239
  simulators = simulators.filter((d) => allowedSimulators.some((s) => d.name === s.name && d.sdk === s.sdk));
240
240
  }
241
+ const store = device_store_1.DeviceStoreFactory.getStore();
241
242
  return yield Promise.all(simulators.map((d) => __awaiter(this, void 0, void 0, function* () {
242
243
  var _a;
243
- return (Object.assign(Object.assign({}, d), { wdaLocalPort: yield typedi_1.Container.get(PortAllocator_1.PortAllocator).acquire('wda', d.udid), mjpegServerPort: yield typedi_1.Container.get(PortAllocator_1.PortAllocator).acquire('mjpeg', d.udid), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 }));
244
+ const storeDevice = yield store.findDevice({ udid: d.udid });
245
+ return Object.assign(Object.assign({}, d), { wdaLocalPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.wdaLocalPort) || (yield typedi_1.Container.get(PortAllocator_1.PortAllocator).acquire('wda', d.udid)), mjpegServerPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.mjpegServerPort) ||
246
+ (yield typedi_1.Container.get(PortAllocator_1.PortAllocator).acquire('mjpeg', d.udid)), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 });
244
247
  })));
245
248
  });
246
249
  }
@@ -253,7 +256,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
253
256
  try {
254
257
  const device = Object.assign(Object.assign({}, (yield this.getDeviceInfo(udid))), { nodeId: this.nodeId });
255
258
  if (this.pluginArgs.hub) {
256
- yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized).postDevicesToHub([device], 'add');
259
+ yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized, this.pluginArgs.nodeSecret).postDevicesToHub([device], 'add');
257
260
  }
258
261
  yield (0, device_service_1.addNewDevice)([device], this.pluginArgs.bindHostOrIp);
259
262
  }
@@ -264,7 +267,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
264
267
  tracker.on('detached', (udid) => __awaiter(this, void 0, void 0, function* () {
265
268
  const deviceRemoved = [{ udid, host: this.pluginArgs.bindHostOrIp }];
266
269
  if (this.pluginArgs.hub) {
267
- yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized).postDevicesToHub(deviceRemoved, 'remove');
270
+ yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized, this.pluginArgs.nodeSecret).postDevicesToHub(deviceRemoved, 'remove');
268
271
  }
269
272
  yield (0, device_service_1.removeDevice)(deviceRemoved);
270
273
  }));
@@ -63,6 +63,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
63
63
  };
64
64
  Object.defineProperty(exports, "__esModule", { value: true });
65
65
  const typedi_1 = require("typedi");
66
+ const ProcessRegistry_1 = require("../../services/ProcessRegistry");
66
67
  const child_process_1 = require("child_process");
67
68
  const util_1 = require("util");
68
69
  const path_1 = __importDefault(require("path"));
@@ -217,6 +218,7 @@ let IOSStreamService = class IOSStreamService {
217
218
  stdio: ['pipe', 'pipe', 'pipe'],
218
219
  env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
219
220
  });
221
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: tunnelProcess });
220
222
  (_a = tunnelProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => logger_1.default.debug(`Tunnel [${udid}]: ${data}`));
221
223
  (_b = tunnelProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => logger_1.default.debug(`Tunnel Err [${udid}]: ${data}`));
222
224
  // Wait for tunnel to establish by checking the go-ios agent port
@@ -434,7 +436,9 @@ let IOSStreamService = class IOSStreamService {
434
436
  const wdaIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.wdaPort}:8100`], 'Performance');
435
437
  const mjpegIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.mjpegPort}:9100`], 'Performance');
436
438
  session.forwardWDAProcess = (0, child_process_1.spawn)(wdaIproxy.command, wdaIproxy.args);
439
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: session.forwardWDAProcess });
437
440
  session.forwardMJPEGProcess = (0, child_process_1.spawn)(mjpegIproxy.command, mjpegIproxy.args);
441
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'ios-mjpeg', udid, process: session.forwardMJPEGProcess });
438
442
  logger_1.default.info(`🛡️ [${udid}] [Watchdog] Tunnels restarted successfully.`);
439
443
  });
440
444
  }
@@ -558,7 +562,9 @@ let IOSStreamService = class IOSStreamService {
558
562
  const wdaIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${wdaPort}:8100`], 'Performance');
559
563
  const mjpegIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${mjpegPort}:9100`], 'Performance');
560
564
  session.forwardWDAProcess = (0, child_process_1.spawn)(wdaIproxy.command, wdaIproxy.args);
565
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: session.forwardWDAProcess });
561
566
  session.forwardMJPEGProcess = (0, child_process_1.spawn)(mjpegIproxy.command, mjpegIproxy.args);
567
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'ios-mjpeg', udid, process: session.forwardMJPEGProcess });
562
568
  const handleIproxyProcess = (p, name) => {
563
569
  p.on('error', (err) => logger_1.default.error(`${name} [${udid}] error: ${err.message}`));
564
570
  p.on('exit', (code) => {
@@ -586,6 +592,7 @@ let IOSStreamService = class IOSStreamService {
586
592
  session.wdaProcess = (0, child_process_1.spawn)(wdaSpawn.command, wdaSpawn.args, {
587
593
  env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
588
594
  });
595
+ typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'wda', udid, process: session.wdaProcess });
589
596
  const logDir = path_1.default.join(os_1.default.tmpdir(), 'xenon-logs');
590
597
  if (!fs_extra_1.default.existsSync(logDir))
591
598
  fs_extra_1.default.mkdirSync(logDir);
@@ -24,6 +24,7 @@ const axios_1 = __importDefault(require("axios"));
24
24
  const semver_1 = __importDefault(require("semver"));
25
25
  const logger_1 = __importDefault(require("../../logger"));
26
26
  const typedi_1 = require("typedi");
27
+ const ProcessRegistry_1 = require("../../services/ProcessRegistry");
27
28
  const IOSStreamService_1 = __importDefault(require("./IOSStreamService"));
28
29
  const device_store_1 = require("../../data-service/device-store");
29
30
  const child_process_1 = require("child_process");
@@ -765,6 +766,7 @@ let WDAClient = WDAClient_1 = class WDAClient {
765
766
  env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
766
767
  stdio: ['ignore', 'pipe', 'pipe'],
767
768
  });
769
+ typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'log-tailer', udid, process: proc });
768
770
  const entry = {
769
771
  proc,
770
772
  buffer: [],
@@ -71,6 +71,7 @@ exports.cleanPendingSessions = cleanPendingSessions;
71
71
  exports.setupCronCleanPendingSessions = setupCronCleanPendingSessions;
72
72
  exports.setupCronCleanExpiredReservations = setupCronCleanExpiredReservations;
73
73
  exports.setupCronCleanupBuilds = setupCronCleanupBuilds;
74
+ exports.setupCronReconcileDevices = setupCronReconcileDevices;
74
75
  exports.setupCronSweepOrphanSessions = setupCronSweepOrphanSessions;
75
76
  exports.stopAllTimers = stopAllTimers;
76
77
  /* eslint-disable no-prototype-builtins */
@@ -408,7 +409,7 @@ function getBusyDevicesCount() {
408
409
  }).length;
409
410
  });
410
411
  }
411
- function updateDeviceList(host, hubArgument, tlsRejectUnauthorized) {
412
+ function updateDeviceList(host, hubArgument, tlsRejectUnauthorized, nodeSecret) {
412
413
  return __awaiter(this, void 0, void 0, function* () {
413
414
  const allExistingDevices = yield (0, device_service_1.getAllDevices)();
414
415
  const devices = yield getDeviceManager().getDevices(allExistingDevices);
@@ -427,7 +428,7 @@ function updateDeviceList(host, hubArgument, tlsRejectUnauthorized) {
427
428
  }
428
429
  if (hubArgument) {
429
430
  if (yield (0, helpers_1.isXenonRunning)(hubArgument, tlsRejectUnauthorized)) {
430
- const nodeDevices = new NodeDevices_1.default(hubArgument, tlsRejectUnauthorized);
431
+ const nodeDevices = new NodeDevices_1.default(hubArgument, tlsRejectUnauthorized, nodeSecret);
431
432
  try {
432
433
  yield nodeDevices.postDevicesToHub(devices, 'add');
433
434
  if (staleLocalDevices.length > 0) {
@@ -566,14 +567,14 @@ function setupCronReleaseBlockedDevices(intervalMs, newCommandTimeoutSec) {
566
567
  }), intervalMs);
567
568
  });
568
569
  }
569
- function setupCronUpdateDeviceList(host, hubArgument, intervalMs, tlsRejectUnauthorized) {
570
+ function setupCronUpdateDeviceList(host, hubArgument, intervalMs, tlsRejectUnauthorized, nodeSecret) {
570
571
  return __awaiter(this, void 0, void 0, function* () {
571
572
  if (cronTimerToUpdateDevices) {
572
573
  clearInterval(cronTimerToUpdateDevices);
573
574
  }
574
575
  logger_1.default.info(`This node will send device list update to the hub (${hubArgument}) every ${intervalMs} ms`);
575
576
  cronTimerToUpdateDevices = setInterval(() => __awaiter(this, void 0, void 0, function* () {
576
- yield updateDeviceList(host, hubArgument, tlsRejectUnauthorized);
577
+ yield updateDeviceList(host, hubArgument, tlsRejectUnauthorized, nodeSecret);
577
578
  }), intervalMs);
578
579
  });
579
580
  }
@@ -658,6 +659,28 @@ function setupCronCleanupBuilds(pluginArgs) {
658
659
  });
659
660
  }
660
661
  let cronTimerSweepOrphanSessions;
662
+ let cronTimerReconcileDevices;
663
+ /**
664
+ * Cross-checks the device store against SESSION_MANAGER and frees devices
665
+ * that are busy with a session ID the driver layer no longer knows about.
666
+ * Fills the gap between OrphanSweeper (Prisma heartbeat-driven) and
667
+ * releaseBlockedDevices (command-idle-driven).
668
+ */
669
+ function setupCronReconcileDevices(intervalMs) {
670
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
671
+ const { DeviceReconciler } = require('./services/DeviceReconciler');
672
+ const reconciler = typedi_1.Container.get(DeviceReconciler);
673
+ logger_1.default.info(`Device reconcile scheduled every ${intervalMs}ms`);
674
+ if (cronTimerReconcileDevices) {
675
+ clearInterval(cronTimerReconcileDevices);
676
+ }
677
+ cronTimerReconcileDevices = setInterval(() => {
678
+ reconciler.reconcile().catch((err) => {
679
+ logger_1.default.error(`Device reconcile crashed: ${err.message}`);
680
+ });
681
+ }, intervalMs);
682
+ return cronTimerReconcileDevices;
683
+ }
661
684
  /**
662
685
  * Periodically sweep sessions whose heartbeat has gone stale, marking them as
663
686
  * failed and releasing their devices.
@@ -698,4 +721,6 @@ function stopAllTimers() {
698
721
  cronTimerToCleanupBuilds.cancel();
699
722
  if (cronTimerSweepOrphanSessions)
700
723
  clearInterval(cronTimerSweepOrphanSessions);
724
+ if (cronTimerReconcileDevices)
725
+ clearInterval(cronTimerReconcileDevices);
701
726
  }