@xenon-device-management/xenon 1.7.10 → 1.8.1
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/schema.json +2 -2
- package/lib/src/XenonCapabilityManager.js +15 -0
- package/lib/src/app/index.js +4 -0
- package/lib/src/app/routers/audit.js +83 -0
- package/lib/src/app/routers/auth.js +37 -4
- package/lib/src/app/routers/capabilities.js +3 -0
- package/lib/src/app/routers/dashboard.js +68 -1
- package/lib/src/app/routers/recordings.js +11 -6
- package/lib/src/app/routers/sdk-leases.js +8 -3
- package/lib/src/dashboard/event-manager.js +32 -0
- package/lib/src/data-service/device-service.js +11 -2
- package/lib/src/interceptors/CommandInterceptor.js +12 -1
- package/lib/src/interfaces/IPluginArgs.js +7 -1
- package/lib/src/middleware/authMiddleware.js +34 -6
- package/lib/src/services/SessionLifecycleService.js +76 -27
- package/lib/src/services/SocketServer.js +21 -0
- package/lib/src/services/healing/HealingOrchestrator.js +36 -3
- package/lib/src/services/lease/LeaseService.js +68 -3
- package/lib/src/services/recording/manualLock.js +29 -0
- package/lib/src/services/sessionTokenGate.js +49 -0
- package/lib/src/services/token/mcpScopes.js +83 -0
- package/lib/test/integration/audit-ingest.spec.js +166 -0
- package/lib/test/integration/healing-events-sessionfilter.spec.js +100 -0
- package/lib/test/integration/recordings-router.spec.js +44 -0
- package/lib/test/integration/sdk-leases-router.spec.js +50 -41
- package/lib/test/integration/selector-health-endpoint.spec.js +233 -0
- package/lib/test/unit/CapabilitiesRouter.spec.js +16 -0
- package/lib/test/unit/McpScopes.spec.js +82 -0
- package/lib/test/unit/SessionLifecycleCaps.spec.js +58 -0
- package/lib/test/unit/SessionTokenGate.spec.js +78 -0
- package/lib/test/unit/TokenEndpoint.spec.js +112 -0
- package/lib/test/unit/authMiddleware.bearer.spec.js +9 -3
- package/lib/test/unit/authMiddleware.mcpAudience.spec.js +93 -0
- package/lib/test/unit/healing-tier-gate.spec.js +122 -0
- package/lib/test/unit/lease/nodePairAuth.spec.js +21 -0
- package/lib/test/unit/manualLock.test.js +45 -0
- package/lib/test/unit/socketServer.bearer.spec.js +113 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/schema.json +2 -2
- package/scripts/generate-types-from-schema.js +7 -1
package/lib/package.json
CHANGED
package/lib/schema.json
CHANGED
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
},
|
|
101
101
|
"deviceAvailabilityQueryIntervalMs": {
|
|
102
102
|
"type": "number",
|
|
103
|
-
"default":
|
|
104
|
-
"description": "How often (ms) the session queue polls for a free device while waiting."
|
|
103
|
+
"default": 1000,
|
|
104
|
+
"description": "How often (ms) the session queue polls for a free device while waiting. Runs only while a create is actively waiting for a busy/unsynced device, so a tighter interval cuts the wait without steady-state cost."
|
|
105
105
|
},
|
|
106
106
|
"sendNodeDevicesToHubIntervalMs": {
|
|
107
107
|
"type": "number",
|
|
@@ -51,6 +51,7 @@ exports.iOSCapabilities = iOSCapabilities;
|
|
|
51
51
|
exports.extractTeamCap = extractTeamCap;
|
|
52
52
|
exports.extractAccessKeyCap = extractAccessKeyCap;
|
|
53
53
|
exports.extractAccessKeyTokenPair = extractAccessKeyTokenPair;
|
|
54
|
+
exports.extractSessionToken = extractSessionToken;
|
|
54
55
|
exports.getXenonCapabilities = getXenonCapabilities;
|
|
55
56
|
const get_port_1 = __importDefault(require("get-port"));
|
|
56
57
|
const lodash_1 = __importDefault(require("lodash"));
|
|
@@ -227,6 +228,20 @@ function extractAccessKeyTokenPair(caps) {
|
|
|
227
228
|
return undefined;
|
|
228
229
|
return { accessKey, token };
|
|
229
230
|
}
|
|
231
|
+
// Returns the xenon:options.sessionToken JWT (hub-minted, aud 'xenon-session'),
|
|
232
|
+
// used by the opt-in session-token gate (spec §3 item 6, risk R9). Returns
|
|
233
|
+
// null if absent — callers combine this with the df:options access-key/token
|
|
234
|
+
// pair to decide whether createSession is authorized.
|
|
235
|
+
function extractSessionToken(caps) {
|
|
236
|
+
var _a;
|
|
237
|
+
const read = (obj) => {
|
|
238
|
+
const xo = obj === null || obj === void 0 ? void 0 : obj['xenon:options'];
|
|
239
|
+
const t = xo && typeof xo === 'object' ? xo.sessionToken : undefined;
|
|
240
|
+
return typeof t === 'string' && t.length > 0 ? t : null;
|
|
241
|
+
};
|
|
242
|
+
const fm = Array.isArray(caps === null || caps === void 0 ? void 0 : caps.firstMatch) ? caps.firstMatch[0] : undefined;
|
|
243
|
+
return (_a = read(caps === null || caps === void 0 ? void 0 : caps.alwaysMatch)) !== null && _a !== void 0 ? _a : read(fm);
|
|
244
|
+
}
|
|
230
245
|
function getXenonCapabilities(caps) {
|
|
231
246
|
var _a, _b;
|
|
232
247
|
const mergedCapabilites = Object.assign({}, (_b = (_a = caps.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : {}, caps.alwaysMatch);
|
package/lib/src/app/index.js
CHANGED
|
@@ -79,6 +79,7 @@ const profile_1 = require("./routers/profile");
|
|
|
79
79
|
const users_1 = require("./routers/users");
|
|
80
80
|
const capabilities_1 = require("./routers/capabilities");
|
|
81
81
|
const projects_1 = require("./routers/projects");
|
|
82
|
+
const audit_1 = require("./routers/audit");
|
|
82
83
|
const authMiddleware_1 = require("../middleware/authMiddleware");
|
|
83
84
|
const rateLimitMiddleware_1 = require("../middleware/rateLimitMiddleware");
|
|
84
85
|
const csrfMiddleware_1 = require("../middleware/csrfMiddleware");
|
|
@@ -249,6 +250,9 @@ function createRouter(pluginArgs) {
|
|
|
249
250
|
apiRouter.use('/capabilities', (0, capabilities_1.capabilitiesRouter)());
|
|
250
251
|
// Project entity skeleton — container for later runs/flows/secrets (ARB guard #4)
|
|
251
252
|
apiRouter.use('/projects', (0, projects_1.projectsRouter)());
|
|
253
|
+
// MCP audit ingest: the 2b gateway ships batched tool-call audit JSONL here,
|
|
254
|
+
// authenticated as a service identity (roleGuard(MEMBER) + admin scope).
|
|
255
|
+
apiRouter.use('/audit', (0, audit_1.auditRouter)());
|
|
252
256
|
// Exposes plugin CLI args (may include host, hub URL, etc.) — auth-gated.
|
|
253
257
|
apiRouter.get('/cliArgs', (_req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
254
258
|
res.json(yield (0, pluginArgs_1.getCLIArgs)());
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeRouter = makeRouter;
|
|
4
|
+
exports.auditRouter = auditRouter;
|
|
5
|
+
const express_1 = require("express");
|
|
6
|
+
const typedi_1 = require("typedi");
|
|
7
|
+
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
8
|
+
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
9
|
+
const EventLogService_1 = require("../../services/EventLogService");
|
|
10
|
+
// The 2b gateway ships batched MCP tool-call audit records here. It
|
|
11
|
+
// authenticates as a service identity (an API key scoped `admin`), not a
|
|
12
|
+
// human MEMBER — but the repo's guard primitives compose on role+scope, so
|
|
13
|
+
// we reuse them: roleGuard('MEMBER') is the floor every authenticated
|
|
14
|
+
// caller clears, and mutationScopeGuard(['admin']) is the actual gate,
|
|
15
|
+
// requiring the caller's key to carry the `admin` flat scope. This avoids
|
|
16
|
+
// inventing a parallel guard mechanism for one route while still keeping
|
|
17
|
+
// audit writes restricted to a privileged, deliberately-provisioned key
|
|
18
|
+
// (documented here per the task brief instead of a bespoke scope).
|
|
19
|
+
const MAX_BATCH_SIZE = 1000;
|
|
20
|
+
function validateEvent(event) {
|
|
21
|
+
if (!event || typeof event !== 'object')
|
|
22
|
+
return 'event must be an object';
|
|
23
|
+
const e = event;
|
|
24
|
+
if (typeof e.subject !== 'string' || !e.subject)
|
|
25
|
+
return 'subject is required';
|
|
26
|
+
if (typeof e.tool !== 'string' || !e.tool)
|
|
27
|
+
return 'tool is required';
|
|
28
|
+
if (typeof e.decision !== 'string' || !e.decision)
|
|
29
|
+
return 'decision is required';
|
|
30
|
+
if (typeof e.latencyMs !== 'number' || !Number.isFinite(e.latencyMs))
|
|
31
|
+
return 'latencyMs is required (number)';
|
|
32
|
+
if (e.correlationId !== undefined && typeof e.correlationId !== 'string') {
|
|
33
|
+
return 'correlationId must be a string';
|
|
34
|
+
}
|
|
35
|
+
if (e.sessionId !== undefined && typeof e.sessionId !== 'string') {
|
|
36
|
+
return 'sessionId must be a string';
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function makeRouter(opts = {}) {
|
|
41
|
+
var _a;
|
|
42
|
+
const router = (0, express_1.Router)();
|
|
43
|
+
const svc = (_a = opts.eventLogService) !== null && _a !== void 0 ? _a : typedi_1.Container.get(EventLogService_1.EventLogService);
|
|
44
|
+
router.post('/events', (req, res) => {
|
|
45
|
+
var _a, _b, _c;
|
|
46
|
+
const { events } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
47
|
+
if (!Array.isArray(events)) {
|
|
48
|
+
return res.status(400).json({ error: 'bad_request', details: 'events must be an array' });
|
|
49
|
+
}
|
|
50
|
+
if (events.length > MAX_BATCH_SIZE) {
|
|
51
|
+
return res.status(400).json({
|
|
52
|
+
error: 'bad_request',
|
|
53
|
+
details: `events batch too large (max ${MAX_BATCH_SIZE})`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
for (let i = 0; i < events.length; i++) {
|
|
57
|
+
const err = validateEvent(events[i]);
|
|
58
|
+
if (err) {
|
|
59
|
+
return res.status(400).json({ error: 'bad_request', details: `event[${i}]: ${err}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const auth = req.auth;
|
|
63
|
+
const apiKey = req.apiKey;
|
|
64
|
+
const teamId = (_c = (_b = auth === null || auth === void 0 ? void 0 : auth.teamId) !== null && _b !== void 0 ? _b : apiKey === null || apiKey === void 0 ? void 0 : apiKey.teamId) !== null && _c !== void 0 ? _c : undefined;
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
svc.appendSafe({
|
|
67
|
+
type: 'mcp_audit',
|
|
68
|
+
payload: event,
|
|
69
|
+
correlationId: event.correlationId,
|
|
70
|
+
teamId,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return res.status(202).json({ ingested: events.length });
|
|
74
|
+
});
|
|
75
|
+
return router;
|
|
76
|
+
}
|
|
77
|
+
function auditRouter(opts = {}) {
|
|
78
|
+
const router = (0, express_1.Router)();
|
|
79
|
+
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
80
|
+
router.use((0, scopeGuard_1.mutationScopeGuard)(['admin']));
|
|
81
|
+
router.use(makeRouter(opts));
|
|
82
|
+
return router;
|
|
83
|
+
}
|
|
@@ -25,6 +25,7 @@ const loginRateLimiter_1 = require("../../middleware/loginRateLimiter");
|
|
|
25
25
|
const config_1 = require("../../config");
|
|
26
26
|
const prisma_1 = require("../../prisma");
|
|
27
27
|
const JwtKeyService_1 = require("../../services/token/JwtKeyService");
|
|
28
|
+
const mcpScopes_1 = require("../../services/token/mcpScopes");
|
|
28
29
|
const SESSION_COOKIE = 'xenon_dashboard_session';
|
|
29
30
|
const isSecureFromReq = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
30
31
|
const MCP_TTL_SEC = Number(process.env.XENON_MCP_TOKEN_TTL_SEC || 86400);
|
|
@@ -32,13 +33,41 @@ const REST_TTL_SEC = 3600;
|
|
|
32
33
|
const MINTABLE_AUDIENCES = ['xenon-rest', 'xenon-mcp'];
|
|
33
34
|
function issueToken(auth, body) {
|
|
34
35
|
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
-
var _a, _b;
|
|
36
|
+
var _a, _b, _c, _d;
|
|
36
37
|
const audience = (_a = body.audience) !== null && _a !== void 0 ? _a : 'xenon-rest';
|
|
37
38
|
if (!MINTABLE_AUDIENCES.includes(audience)) {
|
|
38
39
|
throw new Error(`unsupported audience: ${audience}`);
|
|
39
40
|
}
|
|
40
41
|
const expiresIn = audience === 'xenon-mcp' ? MCP_TTL_SEC : REST_TTL_SEC;
|
|
41
|
-
const
|
|
42
|
+
const svc = typedi_1.Container.get(JwtKeyService_1.JwtKeyService);
|
|
43
|
+
if (audience === 'xenon-mcp') {
|
|
44
|
+
// Validate the untrusted `scopes` body field before it reaches the mapper:
|
|
45
|
+
// a non-array truthy value (e.g. `"appium:use"`) would otherwise throw a
|
|
46
|
+
// raw TypeError inside resolveMcpGrant, leaking an internal message as the
|
|
47
|
+
// 400 detail. A typed McpScopeError gives a clean 400 instead.
|
|
48
|
+
if (body.scopes !== undefined && !Array.isArray(body.scopes)) {
|
|
49
|
+
throw new mcpScopes_1.McpScopeError('unknown_scope', 'scopes must be an array of scope strings');
|
|
50
|
+
}
|
|
51
|
+
// Granular MCP claims (spec §4.2): `scope` (space-joined, the gateway's
|
|
52
|
+
// scopeClaim) + `roles` for the gateway's admin bypass, and a DOWN-MAPPED
|
|
53
|
+
// flat `scopes` claim so this token's REST power matches its MCP grant
|
|
54
|
+
// (least privilege — an mcp token no longer carries the key's full flat set).
|
|
55
|
+
const grant = (0, mcpScopes_1.resolveMcpGrant)(auth.scopes, body.scopes);
|
|
56
|
+
const token = yield svc.sign({
|
|
57
|
+
sub: auth.userId,
|
|
58
|
+
role: auth.role,
|
|
59
|
+
scopes: grant.flat.join(','),
|
|
60
|
+
teamId: (_b = auth.teamId) !== null && _b !== void 0 ? _b : null,
|
|
61
|
+
scope: grant.granular.join(' '),
|
|
62
|
+
roles: grant.roles,
|
|
63
|
+
}, { audience, ttlSeconds: expiresIn });
|
|
64
|
+
// Session-token capability (spec §3 item 6 / R9): a sibling credential the
|
|
65
|
+
// client injects as `xenon:options.sessionToken` so the Appium createSession
|
|
66
|
+
// interceptor can refuse tokenless direct-connect sessions when the gate is on.
|
|
67
|
+
const sessionToken = yield svc.sign({ sub: auth.userId, teamId: (_c = auth.teamId) !== null && _c !== void 0 ? _c : null }, { audience: 'xenon-session', ttlSeconds: expiresIn });
|
|
68
|
+
return { token, expiresIn, audience, scopes: grant.granular, sessionToken };
|
|
69
|
+
}
|
|
70
|
+
const token = yield svc.sign({ sub: auth.userId, role: auth.role, scopes: auth.scopes, teamId: (_d = auth.teamId) !== null && _d !== void 0 ? _d : null }, { audience, ttlSeconds: expiresIn });
|
|
42
71
|
return { token, expiresIn, audience };
|
|
43
72
|
});
|
|
44
73
|
}
|
|
@@ -239,8 +268,12 @@ function authAuthedRouter() {
|
|
|
239
268
|
const out = yield issueToken(req.auth, (_a = req.body) !== null && _a !== void 0 ? _a : {});
|
|
240
269
|
res.json(out);
|
|
241
270
|
}
|
|
242
|
-
catch (
|
|
243
|
-
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (err instanceof mcpScopes_1.McpScopeError) {
|
|
273
|
+
const status = err.code === 'scope_exceeds_key' ? 403 : 400;
|
|
274
|
+
return res.status(status).json({ error: err.code, details: err.message });
|
|
275
|
+
}
|
|
276
|
+
res.status(400).json({ error: 'bad_request', details: err.message });
|
|
244
277
|
}
|
|
245
278
|
}));
|
|
246
279
|
r.post('/dashboard-session', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildCapabilities = buildCapabilities;
|
|
4
4
|
exports.capabilitiesRouter = capabilitiesRouter;
|
|
5
5
|
const express_1 = require("express");
|
|
6
|
+
const sessionTokenGate_1 = require("../../services/sessionTokenGate");
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
7
8
|
const pkg = require('../../../package.json');
|
|
8
9
|
function buildCapabilities() {
|
|
@@ -15,6 +16,8 @@ function buildCapabilities() {
|
|
|
15
16
|
leases: true, // pre-existing: /sdk/leases
|
|
16
17
|
eventLog: true, // Task 6
|
|
17
18
|
projects: true, // Task 8
|
|
19
|
+
mcpScopedTokens: true, // Tasks 1-3: granular-claim minting available
|
|
20
|
+
sessionTokenGate: (0, sessionTokenGate_1.sessionTokenGateEnabled)(), // Task 4: live XENON_REQUIRE_SESSION_TOKEN value
|
|
18
21
|
},
|
|
19
22
|
};
|
|
20
23
|
}
|
|
@@ -45,7 +45,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
45
45
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
46
|
};
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.getRecentHealingEvents = getRecentHealingEvents;
|
|
48
49
|
exports.aggregateHotspots = aggregateHotspots;
|
|
50
|
+
exports.getSelectorHealth = getSelectorHealth;
|
|
49
51
|
exports.postSelectorStateAction = postSelectorStateAction;
|
|
50
52
|
exports.getMutedSelectors = getMutedSelectors;
|
|
51
53
|
exports.getSelectorStateByTuple = getSelectorStateByTuple;
|
|
@@ -60,6 +62,7 @@ const build_export_1 = __importDefault(require("./build-export"));
|
|
|
60
62
|
const NotificationService_1 = require("../../services/NotificationService");
|
|
61
63
|
const SelectorStateService_1 = require("../../services/SelectorStateService");
|
|
62
64
|
const device_service_1 = require("../../data-service/device-service");
|
|
65
|
+
const HealEtalonService_1 = require("../../services/healing/HealEtalonService");
|
|
63
66
|
const logger_1 = __importDefault(require("../../logger"));
|
|
64
67
|
const MJPEG_PROXY_CACHE = new Map();
|
|
65
68
|
//session guard
|
|
@@ -229,9 +232,10 @@ function getRecentHealingEvents(request, response) {
|
|
|
229
232
|
return __awaiter(this, void 0, void 0, function* () {
|
|
230
233
|
const limitRaw = parseInt(request.query.limit || '50', 10);
|
|
231
234
|
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
|
|
235
|
+
const sessionId = request.query.sessionId || undefined;
|
|
232
236
|
const [rows, todayCount] = yield Promise.all([
|
|
233
237
|
prisma_1.prisma.sessionLog.findMany({
|
|
234
|
-
where: { is_healed: true },
|
|
238
|
+
where: Object.assign({ is_healed: true }, (sessionId ? { session_id: sessionId } : {})),
|
|
235
239
|
orderBy: { createdAt: 'desc' },
|
|
236
240
|
take: limit,
|
|
237
241
|
include: {
|
|
@@ -740,6 +744,68 @@ function getHealingSelectorDetail(request, response) {
|
|
|
740
744
|
});
|
|
741
745
|
});
|
|
742
746
|
}
|
|
747
|
+
// Selector health feed for external/plugin consumers (the xenon-studio MCP
|
|
748
|
+
// plugin's `xenon_selector_health` tool). Deliberately a thin data-shaping
|
|
749
|
+
// layer: it reuses `aggregateHotspots` for the base rows (no new aggregation
|
|
750
|
+
// SQL) and `HealEtalonService` for the "how stale is this locator's known-good
|
|
751
|
+
// signature" enrichment (no new etalon lookup).
|
|
752
|
+
//
|
|
753
|
+
// `failRate` is intentionally NOT included — the underlying rows have no
|
|
754
|
+
// attempt/failure denominator to compute it from (documented deferral).
|
|
755
|
+
//
|
|
756
|
+
// `appId` is accepted for forward-compatibility with the tool's filter
|
|
757
|
+
// surface but ignored: `HotspotRow` (and the `SessionLog` rows it's built
|
|
758
|
+
// from) carry no app id to filter on today.
|
|
759
|
+
//
|
|
760
|
+
// Bound: results come from the top-1000 hotspot buckets within the last 365 days, so a selector with very low heal volume or heals older than a year may return empty (not an error).
|
|
761
|
+
function getSelectorHealth(request, response) {
|
|
762
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
763
|
+
const selectorFilter = typeof request.query.selector === 'string' && request.query.selector
|
|
764
|
+
? request.query.selector
|
|
765
|
+
: null;
|
|
766
|
+
const limitRaw = parseInt(request.query.limit || '50', 10);
|
|
767
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
|
|
768
|
+
// Pull a wide window/limit from the shared aggregation so an exact
|
|
769
|
+
// `?selector=` match isn't starved by hotspots' own top-N cutoff, and so
|
|
770
|
+
// this "health" view (unlike the "active work queue" hotspots view)
|
|
771
|
+
// surfaces muted/pending/resolved selectors too — then apply the
|
|
772
|
+
// requested `limit` ourselves after filtering.
|
|
773
|
+
const agg = yield aggregateHotspots({
|
|
774
|
+
windowDays: 365,
|
|
775
|
+
limit: 1000,
|
|
776
|
+
status: 'all',
|
|
777
|
+
});
|
|
778
|
+
let rows = agg.hotspots;
|
|
779
|
+
if (selectorFilter) {
|
|
780
|
+
rows = rows.filter((h) => h.originalSelector === selectorFilter);
|
|
781
|
+
}
|
|
782
|
+
rows = rows.slice(0, limit);
|
|
783
|
+
const etalonService = typedi_1.Container.get(HealEtalonService_1.HealEtalonService);
|
|
784
|
+
const now = Date.now();
|
|
785
|
+
const results = yield Promise.all(rows.map((h) => __awaiter(this, void 0, void 0, function* () {
|
|
786
|
+
var _a;
|
|
787
|
+
const out = {
|
|
788
|
+
selector: h.originalSelector,
|
|
789
|
+
strategy: h.originalStrategy,
|
|
790
|
+
healCount: h.healCount,
|
|
791
|
+
topTier: h.topTier,
|
|
792
|
+
suggestedRewrite: h.suggestedRewrite,
|
|
793
|
+
state: (_a = h.state) !== null && _a !== void 0 ? _a : null,
|
|
794
|
+
lastHealedAt: h.lastHealedAt,
|
|
795
|
+
};
|
|
796
|
+
// LocatorEtalon.selector is unique, so a lookup by selector alone
|
|
797
|
+
// already pins the row; the strategy check guards against treating a
|
|
798
|
+
// stale/unrelated etalon (selector reused under a different strategy)
|
|
799
|
+
// as a match for this hotspot's (selector, strategy) tuple.
|
|
800
|
+
const etalon = yield etalonService.getSignature(h.originalSelector);
|
|
801
|
+
if (etalon && etalon.strategy === h.originalStrategy) {
|
|
802
|
+
out.etalonAge = now - etalon.lastSeen;
|
|
803
|
+
}
|
|
804
|
+
return out;
|
|
805
|
+
})));
|
|
806
|
+
return response.status(200).json(results);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
743
809
|
// SelectorState lifecycle action endpoint — mark fixed / mute / unmute /
|
|
744
810
|
// cancel verification. The action vocabulary is closed (any value not in
|
|
745
811
|
// VALID_ACTIONS rejects with 400). A SelectorStateConflictError surfaces as
|
|
@@ -976,6 +1042,7 @@ function register(router) {
|
|
|
976
1042
|
router.get('/healing/hotspots', getHealingHotspots);
|
|
977
1043
|
router.get('/healing/hotspots/violations', getHealingViolations);
|
|
978
1044
|
router.get('/healing/selector', getHealingSelectorDetail);
|
|
1045
|
+
router.get('/healing/selector-health', getSelectorHealth);
|
|
979
1046
|
// Outbound notification — admin only since it can fan out to every
|
|
980
1047
|
// configured webhook (Slack channels, etc.).
|
|
981
1048
|
router.post('/healing/digest/send', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), sendHealingDigest);
|
|
@@ -79,7 +79,7 @@ const recLog = logger_1.default.scope('RecordingsRouter');
|
|
|
79
79
|
const router = (0, express_1.Router)();
|
|
80
80
|
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
81
81
|
router.post('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
-
var _a, _b, _c;
|
|
82
|
+
var _a, _b, _c, _d, _e;
|
|
83
83
|
const { udids, sessionId, note } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
84
84
|
if (!Array.isArray(udids) || udids.length === 0) {
|
|
85
85
|
return res.status(400).json({ error: 'udids must be a non-empty array' });
|
|
@@ -89,7 +89,11 @@ router.post('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, funct
|
|
|
89
89
|
return res.status(400).json({ error: 'every udid must be a non-empty string' });
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
// CRITICAL 1 (Phase 2a review): the bearer-auth path sets req.auth (with
|
|
93
|
+
// req.auth.userId) but never req.apiKey, so req.apiKey?.id alone 401s every
|
|
94
|
+
// bearer-authed caller (e.g. xenon_start_recording via the MCP plugin).
|
|
95
|
+
// Fall back to req.auth.userId so bearer principals resolve an actorId too.
|
|
96
|
+
const actorId = (_c = (_b = req.apiKey) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : (_d = req.auth) === null || _d === void 0 ? void 0 : _d.userId;
|
|
93
97
|
if (!actorId)
|
|
94
98
|
return res.status(401).json({ error: 'unauthenticated' });
|
|
95
99
|
try {
|
|
@@ -111,7 +115,7 @@ router.post('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, funct
|
|
|
111
115
|
message: `Server-wide recording cap reached (${e.active}/${e.limit}).`,
|
|
112
116
|
});
|
|
113
117
|
}
|
|
114
|
-
const busyDevices = (
|
|
118
|
+
const busyDevices = (_e = e.busyDevices) !== null && _e !== void 0 ? _e : [];
|
|
115
119
|
return res.status(409).json({
|
|
116
120
|
error: 'device_busy',
|
|
117
121
|
busyDevices,
|
|
@@ -123,12 +127,13 @@ router.post('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, funct
|
|
|
123
127
|
}
|
|
124
128
|
}));
|
|
125
129
|
router.post('/recordings/:groupId/add-device', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
|
-
var _a, _b, _c;
|
|
130
|
+
var _a, _b, _c, _d, _e;
|
|
127
131
|
const { udid } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
128
132
|
if (typeof udid !== 'string' || udid.length === 0) {
|
|
129
133
|
return res.status(400).json({ error: 'udid must be a non-empty string' });
|
|
130
134
|
}
|
|
131
|
-
|
|
135
|
+
// CRITICAL 1 (Phase 2a review): same bearer-actor fallback as POST /recordings.
|
|
136
|
+
const actorId = (_c = (_b = req.apiKey) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : (_d = req.auth) === null || _d === void 0 ? void 0 : _d.userId;
|
|
132
137
|
if (!actorId)
|
|
133
138
|
return res.status(401).json({ error: 'unauthenticated' });
|
|
134
139
|
try {
|
|
@@ -145,7 +150,7 @@ router.post('/recordings/:groupId/add-device', (req, res) => __awaiter(void 0, v
|
|
|
145
150
|
message: `Server-wide recording cap reached (${e.active}/${e.limit}).`,
|
|
146
151
|
});
|
|
147
152
|
}
|
|
148
|
-
const busyDevices = (
|
|
153
|
+
const busyDevices = (_e = e.busyDevices) !== null && _e !== void 0 ? _e : [];
|
|
149
154
|
return res.status(409).json({
|
|
150
155
|
error: 'device_busy',
|
|
151
156
|
busyDevices,
|
|
@@ -27,19 +27,24 @@ function makeRouter(opts = {}) {
|
|
|
27
27
|
const svc = (_a = opts.leaseService) !== null && _a !== void 0 ? _a : typedi_1.Container.get(LeaseService_1.LeaseService);
|
|
28
28
|
const logger = logger_1.default.scope('sdk-leases-router');
|
|
29
29
|
router.post('/', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
30
|
-
var _a, _b, _c;
|
|
30
|
+
var _a, _b, _c, _d, _e;
|
|
31
31
|
const { filters, durationMs, heartbeatSeconds, reason, buildId } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
32
32
|
if (!filters || typeof filters.platform !== 'string') {
|
|
33
33
|
return res.status(400).json({ error: 'bad_request', details: 'filters.platform required' });
|
|
34
34
|
}
|
|
35
35
|
const apiKey = req.apiKey;
|
|
36
|
+
// IMPORTANT 4 (Phase 2a review): the bearer-auth path sets req.auth (with
|
|
37
|
+
// userId/teamId) but never req.apiKey. Without the req.auth fallback a
|
|
38
|
+
// bearer-authed lease create (e.g. xenon_acquire_device) was recorded as
|
|
39
|
+
// actorId:'anonymous', teamId:null — losing attribution.
|
|
40
|
+
const auth = req.auth;
|
|
36
41
|
try {
|
|
37
42
|
const out = yield svc.create({
|
|
38
43
|
filters,
|
|
39
44
|
durationMs: typeof durationMs === 'number' ? durationMs : 30 * 60 * 1000,
|
|
40
45
|
heartbeatSeconds: typeof heartbeatSeconds === 'number' ? heartbeatSeconds : 30,
|
|
41
|
-
actorId: (_b = apiKey === null || apiKey === void 0 ? void 0 : apiKey.id) !== null && _b !== void 0 ? _b : 'anonymous',
|
|
42
|
-
teamId: (
|
|
46
|
+
actorId: (_c = (_b = apiKey === null || apiKey === void 0 ? void 0 : apiKey.id) !== null && _b !== void 0 ? _b : auth === null || auth === void 0 ? void 0 : auth.userId) !== null && _c !== void 0 ? _c : 'anonymous',
|
|
47
|
+
teamId: (_e = (_d = apiKey === null || apiKey === void 0 ? void 0 : apiKey.teamId) !== null && _d !== void 0 ? _d : auth === null || auth === void 0 ? void 0 : auth.teamId) !== null && _e !== void 0 ? _e : null,
|
|
43
48
|
buildId,
|
|
44
49
|
reason,
|
|
45
50
|
});
|
|
@@ -70,6 +70,8 @@ const helpers_2 = require("../helpers");
|
|
|
70
70
|
const typedi_1 = require("typedi");
|
|
71
71
|
const device_managers_1 = require("../device-managers");
|
|
72
72
|
const AndroidDeviceManager_1 = __importDefault(require("../device-managers/AndroidDeviceManager"));
|
|
73
|
+
const IOSStreamService_1 = __importDefault(require("../device-managers/ios/IOSStreamService"));
|
|
74
|
+
const AndroidStreamService_1 = __importDefault(require("../device-managers/android/AndroidStreamService"));
|
|
73
75
|
const SocketServer_1 = require("../services/SocketServer");
|
|
74
76
|
const TracingService_1 = require("../services/TracingService");
|
|
75
77
|
const MetricsService_1 = require("../services/MetricsService");
|
|
@@ -158,6 +160,33 @@ let DashboardEventManager = class DashboardEventManager {
|
|
|
158
160
|
typedi_1.Container.get(MetricsService_1.MetricsService).incrementSessionStart();
|
|
159
161
|
});
|
|
160
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* #150: on session teardown, stop a stream the session left running (e.g. the
|
|
165
|
+
* WDA/MJPEG stream started during iOS provisioning) so it doesn't linger after
|
|
166
|
+
* the device is released. Guarded by `viewerCount === 0`: if a developer is
|
|
167
|
+
* actively watching the same physical device via the Device Panel (shared-WDA
|
|
168
|
+
* coexistence), the stream is left alone — the device is already released here,
|
|
169
|
+
* and once the last viewer leaves the stream watchdog reclaims it. Best-effort:
|
|
170
|
+
* any failure is logged and swallowed so it never blocks session teardown.
|
|
171
|
+
*/
|
|
172
|
+
stopIdleStreamForDevice(device) {
|
|
173
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
174
|
+
try {
|
|
175
|
+
const isApple = device.platform === 'ios' || device.platform === 'tvos';
|
|
176
|
+
const svc = isApple
|
|
177
|
+
? typedi_1.Container.get(IOSStreamService_1.default)
|
|
178
|
+
: typedi_1.Container.get(AndroidStreamService_1.default);
|
|
179
|
+
const status = svc.getStreamStatus(device.udid);
|
|
180
|
+
if (status && status.viewerCount === 0) {
|
|
181
|
+
yield svc.stopStream(device.udid);
|
|
182
|
+
logger_1.default.info(`🎥 [${device.udid}] Stopped idle session stream on teardown.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger_1.default.debug(`Non-fatal: idle-stream stop for ${device.udid} failed: ${err}`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
161
190
|
onSessionStopped(sessionId, status, failureReason) {
|
|
162
191
|
return __awaiter(this, void 0, void 0, function* () {
|
|
163
192
|
var _a, _b, _c, _d;
|
|
@@ -206,6 +235,9 @@ let DashboardEventManager = class DashboardEventManager {
|
|
|
206
235
|
const msg = (_a = unblockErr === null || unblockErr === void 0 ? void 0 : unblockErr.message) !== null && _a !== void 0 ? _a : String(unblockErr);
|
|
207
236
|
logger_1.default.error(`⚠️ Failed to unblock device ${device.udid} for session ${sessionId}: ${msg}`, unblockErr);
|
|
208
237
|
}
|
|
238
|
+
// #150: reclaim a stream the session left running so it doesn't linger
|
|
239
|
+
// after the device is released (only if nobody is watching — see method).
|
|
240
|
+
yield this.stopIdleStreamForDevice(device);
|
|
209
241
|
}
|
|
210
242
|
else {
|
|
211
243
|
const { unblockDeviceMatchingFilter } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
|
|
@@ -42,6 +42,7 @@ const CircuitBreaker_1 = require("./CircuitBreaker");
|
|
|
42
42
|
const NotificationService_1 = require("../services/NotificationService");
|
|
43
43
|
const SocketServer_1 = require("../services/SocketServer");
|
|
44
44
|
const prisma_1 = require("../prisma");
|
|
45
|
+
const manualLock_1 = require("../services/recording/manualLock");
|
|
45
46
|
// Use a Proxy to ensure we're always using the latest store from the factory,
|
|
46
47
|
// which is critical for test isolation when the factory cache is cleared.
|
|
47
48
|
const store = new Proxy({}, {
|
|
@@ -209,16 +210,24 @@ function userUnblockDevice(udid, host) {
|
|
|
209
210
|
*/
|
|
210
211
|
function blockDevice(udid, host, sessionId) {
|
|
211
212
|
return __awaiter(this, void 0, void 0, function* () {
|
|
213
|
+
let effectiveSessionId = sessionId !== null && sessionId !== void 0 ? sessionId : null;
|
|
214
|
+
// #149: a manual-stream lock must not overwrite a live Appium session's
|
|
215
|
+
// session_id (shared-WDA coexistence). Only read-before-write on the manual
|
|
216
|
+
// path — the normal session-block path keeps its single write.
|
|
217
|
+
if ((0, manualLock_1.isManualLock)(sessionId)) {
|
|
218
|
+
const existing = yield getDevice({ udid }); // udid uniquely identifies the device
|
|
219
|
+
effectiveSessionId = (0, manualLock_1.resolveBlockSessionId)(sessionId, existing === null || existing === void 0 ? void 0 : existing.session_id);
|
|
220
|
+
}
|
|
212
221
|
yield store.updateDevice(udid, host, {
|
|
213
222
|
busy: true,
|
|
214
223
|
lastCmdExecutedAt: undefined,
|
|
215
224
|
sessionProgress: '',
|
|
216
|
-
session_id:
|
|
225
|
+
session_id: effectiveSessionId !== null && effectiveSessionId !== void 0 ? effectiveSessionId : null,
|
|
217
226
|
});
|
|
218
227
|
typedi_1.Container.get(SocketServer_1.SocketServer).emitToDashboard('device_blocked', {
|
|
219
228
|
udid,
|
|
220
229
|
host,
|
|
221
|
-
session_id:
|
|
230
|
+
session_id: effectiveSessionId !== null && effectiveSessionId !== void 0 ? effectiveSessionId : undefined,
|
|
222
231
|
});
|
|
223
232
|
});
|
|
224
233
|
}
|
|
@@ -110,6 +110,7 @@ let CommandInterceptor = class CommandInterceptor {
|
|
|
110
110
|
}
|
|
111
111
|
handleInContext(next, driver, commandName, args, pluginArgs, isHub, sessionId, span, tracingService) {
|
|
112
112
|
return __awaiter(this, void 0, void 0, function* () {
|
|
113
|
+
var _a, _b, _c;
|
|
113
114
|
if (commandName === 'createSession' || commandName === 'deleteSession') {
|
|
114
115
|
try {
|
|
115
116
|
return yield next();
|
|
@@ -288,7 +289,17 @@ let CommandInterceptor = class CommandInterceptor {
|
|
|
288
289
|
if (this.isNoSuchElementError(error) &&
|
|
289
290
|
['findElement', 'findElements'].includes(commandName) &&
|
|
290
291
|
pluginArgs.enableSelfHealing !== false) {
|
|
291
|
-
|
|
292
|
+
// §2.7 healing-tier capability gate: a session created with
|
|
293
|
+
// xenon:options.healingTiers restricts self-healing to those tier
|
|
294
|
+
// indices (1=Resilio, 2=Fuzzy XML, 3=OCR, 4=Visual AI, 5=LLM).
|
|
295
|
+
// Every optional hop is guarded — a missing/unrecoverable session,
|
|
296
|
+
// capability, or malformed value all fall back to "run all tiers"
|
|
297
|
+
// (fail open). coerceHealingTiersCap enforces that: an all-non-numeric
|
|
298
|
+
// array (e.g. ["1","2"]) or an empty [] coerces to undefined instead
|
|
299
|
+
// of [] — pre-fix, [] silently disabled healing entirely.
|
|
300
|
+
const rawHealingTiers = (_c = (_b = (_a = SessionManager_1.SESSION_MANAGER.getSession(sessionId)) === null || _a === void 0 ? void 0 : _a.getCapabilities()) === null || _b === void 0 ? void 0 : _b['xenon:options']) === null || _c === void 0 ? void 0 : _c.healingTiers;
|
|
301
|
+
const allowedHealingTiers = (0, HealingOrchestrator_1.coerceHealingTiersCap)(rawHealingTiers);
|
|
302
|
+
const healed = yield typedi_1.Container.get(HealingOrchestrator_1.HealingOrchestrator).attemptHealing(sessionId, driver, args[0], args[1], allowedHealingTiers);
|
|
292
303
|
if (healed) {
|
|
293
304
|
yield this.logHealingEvent(sessionId, commandName, driver, args, healed);
|
|
294
305
|
let finalId = healed.id;
|
|
@@ -25,7 +25,13 @@ exports.DefaultPluginArgs = {
|
|
|
25
25
|
emulators: [],
|
|
26
26
|
simulators: [],
|
|
27
27
|
deviceAvailabilityTimeoutMs: 300000,
|
|
28
|
-
|
|
28
|
+
// Poll interval while a session-create waits for a busy/unsynced target device.
|
|
29
|
+
// Only runs during an active wait (the loop exits immediately when the device
|
|
30
|
+
// is available), so a tight interval costs nothing in steady state but avoids
|
|
31
|
+
// quantizing the wait into ~10s chunks — the misdiagnosed "~65s cold create"
|
|
32
|
+
// was mostly ~5 such 10s cycles, not uia2 APK install. Override per-request
|
|
33
|
+
// with appium:deviceRetryInterval.
|
|
34
|
+
deviceAvailabilityQueryIntervalMs: 1000,
|
|
29
35
|
sendNodeDevicesToHubIntervalMs: 30000,
|
|
30
36
|
checkStaleDevicesIntervalMs: 30000,
|
|
31
37
|
checkBlockedDevicesIntervalMs: 30000,
|
|
@@ -116,15 +116,43 @@ function authMiddleware(req, res, next) {
|
|
|
116
116
|
req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_b = row.teamId) !== null && _b !== void 0 ? _b : null };
|
|
117
117
|
return next();
|
|
118
118
|
}
|
|
119
|
-
// Path 1.5: Authorization: Bearer <hub-issued JWT> (audience xenon-rest
|
|
119
|
+
// Path 1.5: Authorization: Bearer <hub-issued JWT> (audience xenon-rest or
|
|
120
|
+
// xenon-mcp). xenon-mcp tokens are the gateway-injected `authToken` the MCP
|
|
121
|
+
// plugin's tools present when calling this same REST surface, so both
|
|
122
|
+
// audiences must verify here.
|
|
123
|
+
//
|
|
124
|
+
// JwtKeyService.verify()'s current signature is `{ audience: string }`
|
|
125
|
+
// (single audience) — it does not accept an audience array, so we try each
|
|
126
|
+
// accepted audience in turn rather than widening that shared service's
|
|
127
|
+
// signature. jose's own jwtVerify does accept `string | string[]`; if
|
|
128
|
+
// JwtKeyService.verify is ever widened to expose that, this loop can
|
|
129
|
+
// collapse into a single call with `{ audience: ACCEPTED_BEARER_AUDIENCES }`.
|
|
130
|
+
//
|
|
120
131
|
// Live user lookup on every request → revocation is instant on the REST
|
|
121
132
|
// surface even though the token itself is stateless (spec §7.1).
|
|
133
|
+
// Accepting the xenon-mcp audience here (not just xenon-rest) lets the MCP plugin's tools
|
|
134
|
+
// call REST with the gateway-injected authToken. Tradeoff (spec §7.1): mcp tokens carry a
|
|
135
|
+
// 12-24h TTL vs xenon-rest's 1h, so a stolen active-user mcp token has REST access for its
|
|
136
|
+
// full TTL. Mitigated — not eliminated — by the per-request live-user lookup below (a
|
|
137
|
+
// disabled/revoked account is rejected on the next call regardless of the token's remaining life).
|
|
138
|
+
const ACCEPTED_BEARER_AUDIENCES = ['xenon-rest', 'xenon-mcp'];
|
|
122
139
|
const authHeader = req.headers['authorization'];
|
|
123
140
|
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
124
141
|
try {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
142
|
+
const bearer = authHeader.slice(7);
|
|
143
|
+
let payload;
|
|
144
|
+
for (const audience of ACCEPTED_BEARER_AUDIENCES) {
|
|
145
|
+
try {
|
|
146
|
+
payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(bearer, { audience });
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
catch (_j) {
|
|
150
|
+
// try next accepted audience
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!payload) {
|
|
154
|
+
return res.status(401).json({ error: 'invalid token' });
|
|
155
|
+
}
|
|
128
156
|
const user = yield userSvc.findById(String(payload.sub));
|
|
129
157
|
if (!user || user.status !== 'ACTIVE') {
|
|
130
158
|
return res.status(401).json({ error: 'invalid token' });
|
|
@@ -145,7 +173,7 @@ function authMiddleware(req, res, next) {
|
|
|
145
173
|
};
|
|
146
174
|
return next();
|
|
147
175
|
}
|
|
148
|
-
catch (
|
|
176
|
+
catch (_k) {
|
|
149
177
|
return res.status(401).json({ error: 'invalid token' });
|
|
150
178
|
}
|
|
151
179
|
}
|
|
@@ -240,7 +268,7 @@ function authMiddleware(req, res, next) {
|
|
|
240
268
|
};
|
|
241
269
|
return next();
|
|
242
270
|
}
|
|
243
|
-
catch (
|
|
271
|
+
catch (_l) {
|
|
244
272
|
return res.status(401).json({ error: 'invalid ticket' });
|
|
245
273
|
}
|
|
246
274
|
}
|