@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
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
+ return new (P || (P = Promise))(function (resolve, reject) {
11
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
12
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
13
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
14
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
15
+ });
16
+ };
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.ShutdownCoordinator = void 0;
22
+ const typedi_1 = require("typedi");
23
+ const logger_1 = __importDefault(require("../logger"));
24
+ const SessionManager_1 = require("../sessions/SessionManager");
25
+ const SessionLifecycleService_1 = require("./SessionLifecycleService");
26
+ // Graceful shutdown phase that runs BEFORE the existing infrastructure
27
+ // teardown in index.ts:cleanup(). Active sessions get a bounded chance to
28
+ // archive their video + release ports + mark themselves failed, so we don't
29
+ // leak a half-recorded session and a permanently-busy device every time the
30
+ // hub is redeployed.
31
+ //
32
+ // Bounded by two timeouts:
33
+ // - per-session: a single hung driver can't block the rest from finishing
34
+ // - overall: if the fleet is very large, the shutdown still exits in time
35
+ // for init systems (systemd defaults to 90s before SIGKILL)
36
+ let ShutdownCoordinator = class ShutdownCoordinator {
37
+ constructor() {
38
+ this.draining = false;
39
+ this.logger = logger_1.default.scope('Shutdown');
40
+ }
41
+ get isDraining() {
42
+ return this.draining;
43
+ }
44
+ drain(timeoutMs_1) {
45
+ return __awaiter(this, arguments, void 0, function* (timeoutMs, perSessionTimeoutMs = 10000) {
46
+ if (this.draining) {
47
+ this.logger.warn('drain() called while already draining; ignoring');
48
+ return { attempted: 0, completed: 0 };
49
+ }
50
+ this.draining = true;
51
+ const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions();
52
+ if (sessions.length === 0) {
53
+ this.logger.info('No active sessions to drain');
54
+ return { attempted: 0, completed: 0 };
55
+ }
56
+ this.logger.info(`Draining ${sessions.length} active session(s) (overall=${timeoutMs}ms, per-session=${perSessionTimeoutMs}ms)`);
57
+ const lifecycle = typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService);
58
+ const deadline = Date.now() + timeoutMs;
59
+ const tasks = sessions.map((session) => __awaiter(this, void 0, void 0, function* () {
60
+ const id = session.getId();
61
+ const remaining = Math.max(0, deadline - Date.now());
62
+ const budget = Math.min(perSessionTimeoutMs, remaining);
63
+ if (budget <= 0) {
64
+ this.logger.warn(`[shutdown] No budget left for session ${id}; skipping`);
65
+ return false;
66
+ }
67
+ try {
68
+ yield Promise.race([
69
+ lifecycle.stopSessionForShutdown(id, 'Hub shutdown'),
70
+ new Promise((_, reject) => setTimeout(() => reject(new Error('per-session drain timeout')), budget)),
71
+ ]);
72
+ return true;
73
+ }
74
+ catch (err) {
75
+ this.logger.warn(`[shutdown] Failed to drain session ${id}: ${err.message}`);
76
+ return false;
77
+ }
78
+ }));
79
+ const results = yield Promise.allSettled(tasks);
80
+ const completed = results.filter((r) => r.status === 'fulfilled' && r.value).length;
81
+ this.logger.info(`Drain complete: ${completed}/${sessions.length} session(s) finalized`);
82
+ return { attempted: sessions.length, completed };
83
+ });
84
+ }
85
+ };
86
+ exports.ShutdownCoordinator = ShutdownCoordinator;
87
+ exports.ShutdownCoordinator = ShutdownCoordinator = __decorate([
88
+ (0, typedi_1.Service)()
89
+ ], ShutdownCoordinator);
@@ -13,6 +13,7 @@ exports.SocketClient = void 0;
13
13
  const socket_io_client_1 = require("socket.io-client");
14
14
  const typedi_1 = require("typedi");
15
15
  const logger_1 = __importDefault(require("../logger"));
16
+ const config_1 = require("../config");
16
17
  const SocketEvents_1 = require("../enums/SocketEvents");
17
18
  let SocketClient = class SocketClient {
18
19
  constructor() {
@@ -26,11 +27,21 @@ let SocketClient = class SocketClient {
26
27
  // Remove wd/hub if present in hubUrl
27
28
  const normalizedHubUrl = hubUrl.replace(/\/wd\/hub$/, '');
28
29
  logger_1.default.info(`[SocketClient] Connecting to Hub WebSocket: ${normalizedHubUrl}`);
30
+ // Hub now authenticates every socket handshake. Nodes present the same
31
+ // shared secret they already use for REST calls; without it the hub will
32
+ // reject the handshake with "unauthorized". auth-disabled mode on the hub
33
+ // (XENON_AUTH_DISABLED=true) ignores this field, so it's safe to send
34
+ // unconditionally.
35
+ const nodeSecret = config_1.config.nodeSecret;
36
+ if (!nodeSecret) {
37
+ logger_1.default.warn('[SocketClient] XENON_NODE_SECRET not set; hub will reject the handshake unless it also has auth disabled.');
38
+ }
29
39
  this.socket = (0, socket_io_client_1.io)(normalizedHubUrl, {
30
40
  reconnection: true,
31
41
  reconnectionAttempts: Infinity,
32
42
  reconnectionDelay: 1000,
33
43
  reconnectionDelayMax: 5000,
44
+ auth: nodeSecret ? { nodeSecret } : undefined,
34
45
  });
35
46
  this.socket.on('connect', () => {
36
47
  var _a, _b, _c;
@@ -5,31 +5,74 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  };
8
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
+ return new (P || (P = Promise))(function (resolve, reject) {
11
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
12
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
13
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
14
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
15
+ });
16
+ };
8
17
  var __importDefault = (this && this.__importDefault) || function (mod) {
9
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
10
19
  };
20
+ var SocketServer_1;
11
21
  Object.defineProperty(exports, "__esModule", { value: true });
12
22
  exports.SocketServer = void 0;
13
23
  const socket_io_1 = require("socket.io");
14
24
  const typedi_1 = require("typedi");
15
25
  const logger_1 = __importDefault(require("../logger"));
26
+ const config_1 = require("../config");
27
+ const ApiKeyService_1 = require("./ApiKeyService");
28
+ const nodeSecret_1 = require("../auth/nodeSecret");
16
29
  const SocketEvents_1 = require("../enums/SocketEvents");
17
- let SocketServer = class SocketServer {
30
+ const SESSION_COOKIE = 'xenon_dashboard_session';
31
+ function readCookie(cookieHeader, name) {
32
+ if (!cookieHeader)
33
+ return undefined;
34
+ for (const part of cookieHeader.split(';')) {
35
+ const eq = part.indexOf('=');
36
+ if (eq < 0)
37
+ continue;
38
+ if (part.slice(0, eq).trim() === name) {
39
+ return decodeURIComponent(part.slice(eq + 1).trim());
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ let SocketServer = SocketServer_1 = class SocketServer {
18
45
  constructor() {
19
46
  this.io = null;
20
47
  this.nodes = new Map(); // socketId -> nodeHost
21
48
  }
22
49
  initialize(server) {
23
50
  this.io = new socket_io_1.Server(server, {
51
+ // Same-origin only. Browsers on a different origin are blocked at the
52
+ // handshake; server-to-server node connections send no Origin header
53
+ // and are unaffected. Matches the apiRouter cors({origin:false}) policy.
24
54
  cors: {
25
- origin: '*',
55
+ origin: false,
26
56
  methods: ['GET', 'POST'],
27
57
  },
28
58
  });
59
+ this.io.use((socket, next) => __awaiter(this, void 0, void 0, function* () {
60
+ try {
61
+ const principal = yield this.authenticate(socket);
62
+ socket.data.principal = principal;
63
+ next();
64
+ }
65
+ catch (err) {
66
+ logger_1.default.warn(`[SocketServer] Handshake rejected for ${socket.id}: ${err.message}`);
67
+ next(new Error('unauthorized'));
68
+ }
69
+ }));
29
70
  this.io.on('connection', (socket) => {
30
71
  const socketId = socket.id;
31
- logger_1.default.info(`[SocketServer] New connection attempt: ${socketId}`);
32
- // 1. Mandatory Handshake for Protocol Sync
72
+ const principal = socket.data.principal;
73
+ logger_1.default.info(`[SocketServer] New connection: ${socketId} (principal=${principal})`);
74
+ // Protocol version handshake — still runs after auth so a logged-in
75
+ // client on the wrong protocol version still gets a clean disconnect.
33
76
  socket.on(SocketEvents_1.SocketEvents.HANDSHAKE, (data) => {
34
77
  const { version, host } = data;
35
78
  if (version !== SocketEvents_1.XENON_PROTOCOL_VERSION) {
@@ -40,6 +83,11 @@ let SocketServer = class SocketServer {
40
83
  logger_1.default.info(`[SocketServer] Handshake successful with client ${host || socketId} (v${version})`);
41
84
  });
42
85
  socket.on(SocketEvents_1.SocketEvents.REGISTER_NODE, (data) => {
86
+ if (!SocketServer_1.canRegisterAs(principal, 'node')) {
87
+ logger_1.default.warn(`[SocketServer] Rejected REGISTER_NODE from principal=${principal} on ${socketId}`);
88
+ socket.disconnect();
89
+ return;
90
+ }
43
91
  const { host } = data;
44
92
  this.nodes.set(socketId, host);
45
93
  logger_1.default.info(`[SocketServer] Node registered: ${host} (Socket: ${socketId})`);
@@ -48,6 +96,11 @@ let SocketServer = class SocketServer {
48
96
  this.emitToDashboard(SocketEvents_1.SocketEvents.NODE_CONNECTED, { host });
49
97
  });
50
98
  socket.on(SocketEvents_1.SocketEvents.REGISTER_DASHBOARD, () => {
99
+ if (!SocketServer_1.canRegisterAs(principal, 'dashboard')) {
100
+ logger_1.default.warn(`[SocketServer] Rejected REGISTER_DASHBOARD from principal=${principal} on ${socketId}`);
101
+ socket.disconnect();
102
+ return;
103
+ }
51
104
  logger_1.default.info(`[SocketServer] Dashboard client registered (Socket: ${socketId})`);
52
105
  socket.join('dashboard');
53
106
  });
@@ -63,7 +116,57 @@ let SocketServer = class SocketServer {
63
116
  }
64
117
  });
65
118
  });
66
- logger_1.default.info('[SocketServer] WebSocket server initialized');
119
+ logger_1.default.info('[SocketServer] WebSocket server initialized (auth enabled)');
120
+ }
121
+ // Role-based registration guard. A dashboard-authed socket can't join the
122
+ // 'nodes' broadcast room and a node-authed socket can't subscribe to
123
+ // dashboard events — keeps a stolen credential of one class from acting
124
+ // as the other. auth-disabled mode trusts the caller for parity with REST.
125
+ static canRegisterAs(principal, role) {
126
+ if (principal === 'auth-disabled')
127
+ return true;
128
+ return principal === role;
129
+ }
130
+ authenticate(socket) {
131
+ return __awaiter(this, void 0, void 0, function* () {
132
+ var _a, _b;
133
+ if (config_1.config.authDisabled === true)
134
+ return 'auth-disabled';
135
+ const auth = (socket.handshake.auth || {});
136
+ const headers = socket.handshake.headers || {};
137
+ // Node path: shared secret. Checked first because nodes never have a
138
+ // dashboard cookie, so we avoid a pointless ApiKeyService lookup. Accepts
139
+ // either the current or (during rotation overlap) the previous secret —
140
+ // validateNodeSecret logs a warn for the previous-match case.
141
+ const nodeSecret = (typeof auth.nodeSecret === 'string' && auth.nodeSecret) ||
142
+ ((_a = headers['x-xenon-node-secret']) !== null && _a !== void 0 ? _a : '');
143
+ if (nodeSecret) {
144
+ if (!config_1.config.nodeSecret && !config_1.config.nodeSecretPrevious) {
145
+ throw new Error('node secret presented but server has none configured');
146
+ }
147
+ const outcome = (0, nodeSecret_1.validateNodeSecret)(nodeSecret, {
148
+ current: config_1.config.nodeSecret,
149
+ previous: config_1.config.nodeSecretPrevious,
150
+ });
151
+ if (outcome === 'reject') {
152
+ throw new Error('invalid node secret');
153
+ }
154
+ return 'node';
155
+ }
156
+ // Dashboard path: API key header, or session cookie set by /auth/login.
157
+ const apiKeyRaw = (typeof auth.apiKey === 'string' && auth.apiKey) ||
158
+ ((_b = headers['x-xenon-api-key']) !== null && _b !== void 0 ? _b : '') ||
159
+ readCookie(headers.cookie, SESSION_COOKIE) ||
160
+ '';
161
+ if (!apiKeyRaw) {
162
+ throw new Error('missing credentials (need nodeSecret, apiKey, or dashboard cookie)');
163
+ }
164
+ const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verify(apiKeyRaw);
165
+ if (!row) {
166
+ throw new Error('invalid or revoked API key');
167
+ }
168
+ return 'dashboard';
169
+ });
67
170
  }
68
171
  emitToDashboard(event, data) {
69
172
  if (this.io) {
@@ -82,6 +185,6 @@ let SocketServer = class SocketServer {
82
185
  }
83
186
  };
84
187
  exports.SocketServer = SocketServer;
85
- exports.SocketServer = SocketServer = __decorate([
188
+ exports.SocketServer = SocketServer = SocketServer_1 = __decorate([
86
189
  (0, typedi_1.Service)()
87
190
  ], SocketServer);
@@ -56,6 +56,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.VideoPipelineService = void 0;
58
58
  const typedi_1 = require("typedi");
59
+ const ProcessRegistry_1 = require("./ProcessRegistry");
59
60
  const child_process_1 = require("child_process");
60
61
  const logger_1 = __importDefault(require("../logger"));
61
62
  const path_1 = __importDefault(require("path"));
@@ -154,6 +155,7 @@ let VideoPipelineService = class VideoPipelineService {
154
155
  const ffmpegProc = (0, child_process_1.spawn)(command, wrappedArgs, {
155
156
  stdio: ['ignore', 'ignore', 'pipe'], // Only capture stderr for errors
156
157
  });
158
+ typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'ffmpeg', sessionId, udid, process: ffmpegProc });
157
159
  (_a = ffmpegProc.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (data) => {
158
160
  const msg = data.toString();
159
161
  if (msg.toLowerCase().includes('error')) {
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ // In-process counters for healing tier outcomes. Deliberately in-memory:
3
+ // existing DB-backed counters (MetricsService.incrementHealing*) cover
4
+ // session-lifetime totals, but tier-level data ticks on every findElement
5
+ // failure — writing that through SQLite would thrash the DB. Prom scrapes
6
+ // pull the current values; restarts reset counters, which matches Prom's
7
+ // standard reset-semantics anyway.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.HEALING_METRICS = void 0;
10
+ class HealingMetricsRegistry {
11
+ constructor() {
12
+ // Key: `${tier}:${name}` so the same tier number with a renamed provider
13
+ // lands in a separate bucket instead of silently merging.
14
+ this.buckets = new Map();
15
+ this.skipBuckets = new Map();
16
+ this.allTiersFailedCount = 0;
17
+ }
18
+ record(tier, name, outcome, durationMs) {
19
+ const key = `${tier}:${name}`;
20
+ let bucket = this.buckets.get(key);
21
+ if (!bucket) {
22
+ bucket = { attempts: 0, successes: 0, failures: 0, durationMsSum: 0 };
23
+ this.buckets.set(key, bucket);
24
+ }
25
+ bucket.attempts++;
26
+ bucket.durationMsSum += durationMs;
27
+ if (outcome === 'success')
28
+ bucket.successes++;
29
+ else
30
+ bucket.failures++;
31
+ }
32
+ recordAllTiersFailed() {
33
+ this.allTiersFailedCount++;
34
+ }
35
+ recordSkippedRemaining(tier, name) {
36
+ const key = `${tier}:${name}`;
37
+ let bucket = this.skipBuckets.get(key);
38
+ if (!bucket) {
39
+ bucket = { tier, name, count: 0 };
40
+ this.skipBuckets.set(key, bucket);
41
+ }
42
+ bucket.count++;
43
+ }
44
+ skipSnapshot() {
45
+ return Array.from(this.skipBuckets.values()).sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
46
+ }
47
+ snapshot() {
48
+ const out = [];
49
+ for (const [key, bucket] of this.buckets) {
50
+ const sep = key.indexOf(':');
51
+ const tier = Number(key.slice(0, sep));
52
+ const name = key.slice(sep + 1);
53
+ out.push(Object.assign({ tier, name }, bucket));
54
+ }
55
+ // Stable order by tier then name — makes the /metrics output deterministic
56
+ // so alert rules and grafana variables don't see label reshuffling.
57
+ return out.sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
58
+ }
59
+ getAllTiersFailedCount() {
60
+ return this.allTiersFailedCount;
61
+ }
62
+ }
63
+ exports.HEALING_METRICS = new HealingMetricsRegistry();
@@ -63,6 +63,7 @@ const VisualAiHealingProvider_1 = require("./VisualAiHealingProvider");
63
63
  const LlmHealingProvider_1 = require("./LlmHealingProvider");
64
64
  const HealEtalonService_1 = require("./HealEtalonService");
65
65
  const ResilioTreeHealingProvider_1 = require("./ResilioTreeHealingProvider");
66
+ const HealingMetrics_1 = require("./HealingMetrics");
66
67
  let HealingOrchestrator = class HealingOrchestrator {
67
68
  constructor(etalonService) {
68
69
  this.etalonService = etalonService;
@@ -78,6 +79,7 @@ let HealingOrchestrator = class HealingOrchestrator {
78
79
  }
79
80
  attemptHealing(sessionId, driver, strategy, selector) {
80
81
  return __awaiter(this, void 0, void 0, function* () {
82
+ var _a;
81
83
  this.logger.info(`🚨 Self-Healing triggered for session ${sessionId}. Broken locator: ${strategy}=${selector}`);
82
84
  // Preparation: Collect data required for healing
83
85
  // Note: We do this once to avoid multiple expensive round-trips
@@ -94,14 +96,19 @@ let HealingOrchestrator = class HealingOrchestrator {
94
96
  }
95
97
  // Tiered Execution: Try providers in order of cost/complexity
96
98
  for (const provider of this.providers) {
99
+ const tierStart = Date.now();
97
100
  try {
98
101
  this.logger.info(`Attempting Tier ${provider.tier}: ${provider.name}...`);
99
102
  const result = yield provider.heal(context);
103
+ HealingMetrics_1.HEALING_METRICS.record(provider.tier, provider.name, result ? 'success' : 'failure', Date.now() - tierStart);
100
104
  if (result) {
101
105
  this.logger.info(`✨ Provider ${provider.name} found a match! Confidence: ${(result.confidence * 100).toFixed(0)}%`);
102
106
  // Tier 1/2 Optimization: Stability Verification Loop
103
107
  // We try all candidates to see which one is the most stable (semantic vs absolute)
108
+ let stabilityAttempted = false;
109
+ let stabilityVerified = false;
104
110
  if (result.candidateSelectors && result.candidateSelectors.length > 0) {
111
+ stabilityAttempted = true;
105
112
  this.logger.debug(`Verifying ${result.candidateSelectors.length} candidate locators for stability...`);
106
113
  for (const candidate of result.candidateSelectors) {
107
114
  try {
@@ -109,6 +116,7 @@ let HealingOrchestrator = class HealingOrchestrator {
109
116
  if (elements.length === 1) {
110
117
  this.logger.info(`🎯 Verified stable & unique locator: ${candidate}`);
111
118
  result.recommendedSelector = candidate;
119
+ stabilityVerified = true;
112
120
  break; // Found a unique stable locator
113
121
  }
114
122
  else if (elements.length > 1) {
@@ -120,9 +128,16 @@ let HealingOrchestrator = class HealingOrchestrator {
120
128
  }
121
129
  }
122
130
  }
123
- // Principal Learning: Autonomously update the etalon to prevent future failures
124
- // We only do this if confidence is over 70% to avoid pollution with false positives
125
- if (result.confidence > 0.7 && result.node) {
131
+ // Principal Learning: Autonomously update the etalon to prevent future failures.
132
+ // Gate on stability: if the provider emitted candidate selectors but none of them
133
+ // resolved to a unique element, the structural guess was wrong and we must not
134
+ // persist it — that's how lucky LLM/fuzzy-XML guesses poison future sessions.
135
+ // Providers that emit no candidates (OCR/Visual) can't be verified this way, so
136
+ // they still learn on confidence alone.
137
+ if (stabilityAttempted && !stabilityVerified) {
138
+ this.logger.warn(`Skipping etalon save for ${selector}: ${result.candidateSelectors.length} candidate locator(s) attempted, none verified unique (provider=${provider.name}, confidence=${result.confidence})`);
139
+ }
140
+ else if (result.confidence > 0.7 && result.node) {
126
141
  try {
127
142
  this.logger.info(`🧠 Learning from healing success: updating etalon for ${selector}`);
128
143
  // Recalculate path for ResilioTree if node is available
@@ -143,16 +158,29 @@ let HealingOrchestrator = class HealingOrchestrator {
143
158
  yield this.etalonService.saveSignature(strategy, selector, result.node, learnedPath);
144
159
  }
145
160
  catch (learnErr) {
146
- this.logger.debug(`Failed to update etalon after healing: ${learnErr.message}`);
161
+ // Warn (not debug) so the loss of learning shows up in default logs —
162
+ // a silent failure here means the same selector keeps needing the LLM tier.
163
+ this.logger.warn(`Etalon save failed after healing [strategy=${strategy}, selector=${selector}, confidence=${result.confidence}]: ${learnErr.message}`);
147
164
  }
148
165
  }
149
166
  return result;
150
167
  }
151
168
  }
152
169
  catch (err) {
170
+ HealingMetrics_1.HEALING_METRICS.record(provider.tier, provider.name, 'failure', Date.now() - tierStart);
153
171
  this.logger.error(`Provider ${provider.name} failed: ${err.message}`);
154
172
  }
173
+ // Provider-driven short-circuit: a tier can advise that no downstream
174
+ // tier can plausibly succeed (e.g. missing prerequisites that all
175
+ // share). Saves an expensive LLM round-trip when upstream context
176
+ // collection failed.
177
+ if ((_a = provider.shouldSkipRemaining) === null || _a === void 0 ? void 0 : _a.call(provider, context)) {
178
+ this.logger.warn(`Tier ${provider.tier} (${provider.name}) advised skipping remaining tiers for selector=${selector}`);
179
+ HealingMetrics_1.HEALING_METRICS.recordSkippedRemaining(provider.tier, provider.name);
180
+ break;
181
+ }
155
182
  }
183
+ HealingMetrics_1.HEALING_METRICS.recordAllTiersFailed();
156
184
  this.logger.warn(`❌ All healing tiers failed for selector: ${selector}`);
157
185
  return null;
158
186
  });
@@ -22,6 +22,13 @@ class OcrHealingProvider {
22
22
  this.tier = types_1.HealingTier.TIER_3_LOCAL_OCR;
23
23
  this.logger = logger_1.default.scope('OcrHealing');
24
24
  }
25
+ // Visual AI (tier 4) also needs a screenshot, and LLM (tier 5) needs a
26
+ // pageSource. If the context has neither, the remaining tiers can't do
27
+ // better than we did, so tell the orchestrator to give up — saves an
28
+ // LLM round-trip when context collection actually failed upstream.
29
+ shouldSkipRemaining(context) {
30
+ return !context.screenshotBase64 && !context.pageSource;
31
+ }
25
32
  heal(context) {
26
33
  return __awaiter(this, void 0, void 0, function* () {
27
34
  if (!context.screenshotBase64) {
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ const chai_1 = require("chai");
49
+ const sinon_1 = __importDefault(require("sinon"));
50
+ const ApiKeyService_1 = require("../../src/services/ApiKeyService");
51
+ const prisma_1 = require("../../src/prisma");
52
+ const fs_1 = __importDefault(require("fs"));
53
+ describe('ApiKeyService', () => {
54
+ afterEach(() => sinon_1.default.restore());
55
+ it('creates a bootstrap key when the table is empty', () => __awaiter(void 0, void 0, void 0, function* () {
56
+ sinon_1.default.stub(prisma_1.prisma.apiKey, 'count').resolves(0);
57
+ const create = sinon_1.default.stub(prisma_1.prisma.apiKey, 'create').resolves({});
58
+ const writeStub = sinon_1.default.stub(fs_1.default, 'writeFileSync');
59
+ sinon_1.default.stub(fs_1.default, 'mkdirSync');
60
+ const svc = new ApiKeyService_1.ApiKeyService();
61
+ const key = yield svc.bootstrapIfEmpty('/tmp/test-bootstrap.txt');
62
+ (0, chai_1.expect)(key).to.be.a('string').with.lengthOf(64);
63
+ (0, chai_1.expect)(create.calledOnce).to.be.true;
64
+ (0, chai_1.expect)(create.firstCall.args[0].data.scopes).to.equal('admin');
65
+ (0, chai_1.expect)(writeStub.calledOnce).to.be.true;
66
+ (0, chai_1.expect)(writeStub.firstCall.args[0]).to.equal('/tmp/test-bootstrap.txt');
67
+ }));
68
+ it('returns null when keys already exist', () => __awaiter(void 0, void 0, void 0, function* () {
69
+ sinon_1.default.stub(prisma_1.prisma.apiKey, 'count').resolves(1);
70
+ const svc = new ApiKeyService_1.ApiKeyService();
71
+ const key = yield svc.bootstrapIfEmpty('/tmp/test-bootstrap.txt');
72
+ (0, chai_1.expect)(key).to.be.null;
73
+ }));
74
+ it('verify returns the key row for a valid raw key', () => __awaiter(void 0, void 0, void 0, function* () {
75
+ const raw = 'a'.repeat(64);
76
+ const hash = (yield Promise.resolve().then(() => __importStar(require('crypto'))))
77
+ .createHash('sha256')
78
+ .update(raw)
79
+ .digest('hex');
80
+ sinon_1.default
81
+ .stub(prisma_1.prisma.apiKey, 'findUnique')
82
+ .resolves({ id: 'k1', keyHash: hash, scopes: 'read', rateLimit: 300, revokedAt: null });
83
+ sinon_1.default.stub(prisma_1.prisma.apiKey, 'update').resolves({});
84
+ const svc = new ApiKeyService_1.ApiKeyService();
85
+ const row = yield svc.verify(raw);
86
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.id).to.equal('k1');
87
+ }));
88
+ it('verify rejects revoked keys', () => __awaiter(void 0, void 0, void 0, function* () {
89
+ const raw = 'a'.repeat(64);
90
+ const hash = (yield Promise.resolve().then(() => __importStar(require('crypto'))))
91
+ .createHash('sha256')
92
+ .update(raw)
93
+ .digest('hex');
94
+ sinon_1.default
95
+ .stub(prisma_1.prisma.apiKey, 'findUnique')
96
+ .resolves({ id: 'k1', keyHash: hash, scopes: 'read', rateLimit: 300, revokedAt: new Date() });
97
+ const svc = new ApiKeyService_1.ApiKeyService();
98
+ const row = yield svc.verify(raw);
99
+ (0, chai_1.expect)(row).to.be.null;
100
+ }));
101
+ });
@@ -24,13 +24,17 @@ function makeAllocator(overrides) {
24
24
  describe('PortAllocator', () => {
25
25
  let createStub;
26
26
  let findManyStub;
27
+ let findFirstStub;
27
28
  let deleteManyStub;
28
29
  let deleteStub;
30
+ let updateStub;
29
31
  beforeEach(() => {
30
32
  createStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'create');
31
33
  findManyStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'findMany').resolves([]);
34
+ findFirstStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'findFirst').resolves(null);
32
35
  deleteManyStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'deleteMany').resolves({ count: 0 });
33
36
  deleteStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'delete').resolves({});
37
+ updateStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'update').resolves({});
34
38
  });
35
39
  afterEach(() => sinon_1.default.restore());
36
40
  it('allocates first free port in the configured range', () => __awaiter(void 0, void 0, void 0, function* () {
@@ -66,6 +70,16 @@ describe('PortAllocator', () => {
66
70
  (0, chai_1.expect)(err.message).to.match(/wda/);
67
71
  }
68
72
  }));
73
+ it('reuses an existing lease when the same (purpose, udid) acquires again', () => __awaiter(void 0, void 0, void 0, function* () {
74
+ findFirstStub.resolves({ port: 8101 });
75
+ const allocator = makeAllocator({ wda: [8100, 8102] });
76
+ allocator.isOsFree = () => __awaiter(void 0, void 0, void 0, function* () { return true; });
77
+ const port = yield allocator.acquire('wda', 'udid-1');
78
+ (0, chai_1.expect)(port).to.equal(8101);
79
+ (0, chai_1.expect)(createStub.called, 'should not create a new lease').to.be.false;
80
+ (0, chai_1.expect)(updateStub.calledOnce, 'should refresh the existing lease').to.be.true;
81
+ (0, chai_1.expect)(updateStub.firstCall.args[0].where.port).to.equal(8101);
82
+ }));
69
83
  it('releaseForUdid deletes all leases for that UDID', () => __awaiter(void 0, void 0, void 0, function* () {
70
84
  deleteManyStub.resolves({ count: 2 });
71
85
  const allocator = makeAllocator({ wda: [8100, 8102] });