@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.
Files changed (42) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/schema.json +2 -2
  3. package/lib/src/XenonCapabilityManager.js +15 -0
  4. package/lib/src/app/index.js +4 -0
  5. package/lib/src/app/routers/audit.js +83 -0
  6. package/lib/src/app/routers/auth.js +37 -4
  7. package/lib/src/app/routers/capabilities.js +3 -0
  8. package/lib/src/app/routers/dashboard.js +68 -1
  9. package/lib/src/app/routers/recordings.js +11 -6
  10. package/lib/src/app/routers/sdk-leases.js +8 -3
  11. package/lib/src/dashboard/event-manager.js +32 -0
  12. package/lib/src/data-service/device-service.js +11 -2
  13. package/lib/src/interceptors/CommandInterceptor.js +12 -1
  14. package/lib/src/interfaces/IPluginArgs.js +7 -1
  15. package/lib/src/middleware/authMiddleware.js +34 -6
  16. package/lib/src/services/SessionLifecycleService.js +76 -27
  17. package/lib/src/services/SocketServer.js +21 -0
  18. package/lib/src/services/healing/HealingOrchestrator.js +36 -3
  19. package/lib/src/services/lease/LeaseService.js +68 -3
  20. package/lib/src/services/recording/manualLock.js +29 -0
  21. package/lib/src/services/sessionTokenGate.js +49 -0
  22. package/lib/src/services/token/mcpScopes.js +83 -0
  23. package/lib/test/integration/audit-ingest.spec.js +166 -0
  24. package/lib/test/integration/healing-events-sessionfilter.spec.js +100 -0
  25. package/lib/test/integration/recordings-router.spec.js +44 -0
  26. package/lib/test/integration/sdk-leases-router.spec.js +50 -41
  27. package/lib/test/integration/selector-health-endpoint.spec.js +233 -0
  28. package/lib/test/unit/CapabilitiesRouter.spec.js +16 -0
  29. package/lib/test/unit/McpScopes.spec.js +82 -0
  30. package/lib/test/unit/SessionLifecycleCaps.spec.js +58 -0
  31. package/lib/test/unit/SessionTokenGate.spec.js +78 -0
  32. package/lib/test/unit/TokenEndpoint.spec.js +112 -0
  33. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -3
  34. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +93 -0
  35. package/lib/test/unit/healing-tier-gate.spec.js +122 -0
  36. package/lib/test/unit/lease/nodePairAuth.spec.js +21 -0
  37. package/lib/test/unit/manualLock.test.js +45 -0
  38. package/lib/test/unit/socketServer.bearer.spec.js +113 -0
  39. package/lib/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +1 -1
  41. package/schema.json +2 -2
  42. package/scripts/generate-types-from-schema.js +7 -1
package/lib/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "1.7.10",
3
+ "version": "1.8.1",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {
package/lib/schema.json CHANGED
@@ -100,8 +100,8 @@
100
100
  },
101
101
  "deviceAvailabilityQueryIntervalMs": {
102
102
  "type": "number",
103
- "default": 10000,
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);
@@ -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 token = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ sub: auth.userId, role: auth.role, scopes: auth.scopes, teamId: (_b = auth.teamId) !== null && _b !== void 0 ? _b : null }, { audience, ttlSeconds: expiresIn });
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 (e) {
243
- res.status(400).json({ error: e.message });
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
- const actorId = (_b = req.apiKey) === null || _b === void 0 ? void 0 : _b.id;
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 = (_c = e.busyDevices) !== null && _c !== void 0 ? _c : [];
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
- const actorId = (_b = req.apiKey) === null || _b === void 0 ? void 0 : _b.id;
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 = (_c = e.busyDevices) !== null && _c !== void 0 ? _c : [];
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: (_c = apiKey === null || apiKey === void 0 ? void 0 : apiKey.teamId) !== null && _c !== void 0 ? _c : null,
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: sessionId || null,
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: sessionId,
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
- const healed = yield typedi_1.Container.get(HealingOrchestrator_1.HealingOrchestrator).attemptHealing(sessionId, driver, args[0], args[1]);
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
- deviceAvailabilityQueryIntervalMs: 10000,
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 payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(authHeader.slice(7), {
126
- audience: 'xenon-rest',
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 (_j) {
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 (_k) {
271
+ catch (_l) {
244
272
  return res.status(401).json({ error: 'invalid ticket' });
245
273
  }
246
274
  }