@livedesk/hub 0.1.32 → 0.1.34

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/src/remote-hub.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import net from 'net';
2
2
  import os from 'os';
3
- import crypto from 'crypto';
4
- import { isSecureHandshakeStart } from '@livedesk/runtime-core';
5
- import { createDeviceCredentialAuthority } from './security/device-credential-authority.js';
6
- import { createHubRelayControl, createSecureHubRelayControl } from './transport/relay-hub-control.js';
7
- import { createSecureDirectAcceptor } from './transport/secure-direct-acceptor.js';
3
+ import crypto from 'crypto';
4
+ import { isSecureHandshakeStart } from '@livedesk/runtime-core';
5
+ import { createDeviceCredentialAuthority } from './security/device-credential-authority.js';
6
+ import { createHubRelayControl, createSecureHubRelayControl } from './transport/relay-hub-control.js';
7
+ import { createSecureDirectAcceptor } from './transport/secure-direct-acceptor.js';
8
8
  import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
9
9
  import {
10
10
  BoundedSegmentedBuffer,
@@ -35,10 +35,10 @@ const MAX_AGENT_TCP_BINARY_PACKET_BYTES = TCP_BINARY_FRAME_HEADER_BYTES
35
35
  const MAX_AGENT_WEBSOCKET_MESSAGE_BYTES = 4
36
36
  + MAX_AGENT_BINARY_META_BYTES
37
37
  + MAX_AGENT_BINARY_PAYLOAD_BYTES;
38
- const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
39
- + MAX_AGENT_TCP_BINARY_PACKET_BYTES
40
- + (256 * 1024);
41
- const MAX_AGENT_MESSAGES_PER_SECOND = 240;
38
+ const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
39
+ + MAX_AGENT_TCP_BINARY_PACKET_BYTES
40
+ + (256 * 1024);
41
+ const MAX_AGENT_MESSAGES_PER_SECOND = 240;
42
42
  const HTTP_HEADER_TERMINATOR = Buffer.from('\r\n\r\n', 'ascii');
43
43
  const MAX_AGENT_TASK_CHARS = 4000;
44
44
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
@@ -47,7 +47,7 @@ const RECENT_TASK_LIMIT = 12;
47
47
  const RECENT_TASK_BATCH_LIMIT = 16;
48
48
  const RECENT_FRAME_CACHE_TTL_MS = 4000;
49
49
  const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 2;
50
- const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
50
+ const RECENT_LIVE_FRAME_CACHE_LIMIT = 2;
51
51
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
52
52
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
53
53
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
@@ -110,7 +110,7 @@ const MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS = Object.freeze([
110
110
  'handoffOldestFrameAgeMs'
111
111
  ]);
112
112
 
113
- export function hasCompleteMacNativeResourceTelemetry(frame) {
113
+ export function hasCompleteMacNativeResourceTelemetry(frame) {
114
114
  return Number(frame?.nativeCaptureTelemetryVersion) >= MAC_NATIVE_RESOURCE_TELEMETRY_VERSION
115
115
  && MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS.every(field =>
116
116
  frame?.[field] !== null
@@ -828,61 +828,61 @@ function isPrivateIPv4(value) {
828
828
  || (parts[0] === 192 && parts[1] === 168);
829
829
  }
830
830
 
831
- function isPrivateNetworkAddress(value) {
831
+ function isPrivateNetworkAddress(value) {
832
832
  const address = String(value || '').replace(/^::ffff:/i, '').trim().toLowerCase();
833
833
  return isLoopbackHost(address)
834
834
  || isPrivateIPv4(address)
835
835
  || address.startsWith('fe80:')
836
836
  || address.startsWith('fc')
837
- || address.startsWith('fd');
838
- }
839
-
840
- export function createRemoteMessageRateGate({
841
- limit = MAX_AGENT_MESSAGES_PER_SECOND,
842
- windowMs = 1_000,
843
- now = () => Date.now()
844
- } = {}) {
845
- const boundedLimit = Math.max(1, Math.min(10_000, Math.floor(Number(limit) || MAX_AGENT_MESSAGES_PER_SECOND)));
846
- const boundedWindowMs = Math.max(100, Math.min(60_000, Math.floor(Number(windowMs) || 1_000)));
847
- let windowStartedAt = Number(now());
848
- let count = 0;
849
- return Object.freeze({
850
- consume() {
851
- const current = Number(now());
852
- if (!Number.isFinite(current) || current - windowStartedAt >= boundedWindowMs || current < windowStartedAt) {
853
- windowStartedAt = current;
854
- count = 0;
855
- }
856
- count += 1;
857
- return count <= boundedLimit;
858
- },
859
- getStatus: () => ({ count, limit: boundedLimit, windowMs: boundedWindowMs, windowStartedAt })
860
- });
861
- }
862
-
863
- export function evaluateRemoteConnectionPolicy({
864
- remoteAddress = '',
865
- relayConnection = false,
866
- encrypted = false,
867
- allowLegacyPlaintextForTests = false,
868
- policy = {}
869
- } = {}) {
870
- const address = String(remoteAddress || '').replace(/^::ffff:/i, '').trim().toLowerCase();
871
- if (policy.requireEncryptedConnections === true
872
- && encrypted !== true
873
- && allowLegacyPlaintextForTests !== true) {
874
- return 'encrypted-connection-required';
875
- }
876
- if (relayConnection === true || !isPrivateNetworkAddress(address)) {
877
- return policy.allowInternetConnections === false
878
- ? 'internet-connections-blocked-by-settings'
879
- : '';
880
- }
881
- if (!isLoopbackHost(address) && policy.allowLanConnections === false) {
882
- return 'lan-connections-blocked-by-settings';
883
- }
884
- return '';
885
- }
837
+ || address.startsWith('fd');
838
+ }
839
+
840
+ export function createRemoteMessageRateGate({
841
+ limit = MAX_AGENT_MESSAGES_PER_SECOND,
842
+ windowMs = 1_000,
843
+ now = () => Date.now()
844
+ } = {}) {
845
+ const boundedLimit = Math.max(1, Math.min(10_000, Math.floor(Number(limit) || MAX_AGENT_MESSAGES_PER_SECOND)));
846
+ const boundedWindowMs = Math.max(100, Math.min(60_000, Math.floor(Number(windowMs) || 1_000)));
847
+ let windowStartedAt = Number(now());
848
+ let count = 0;
849
+ return Object.freeze({
850
+ consume() {
851
+ const current = Number(now());
852
+ if (!Number.isFinite(current) || current - windowStartedAt >= boundedWindowMs || current < windowStartedAt) {
853
+ windowStartedAt = current;
854
+ count = 0;
855
+ }
856
+ count += 1;
857
+ return count <= boundedLimit;
858
+ },
859
+ getStatus: () => ({ count, limit: boundedLimit, windowMs: boundedWindowMs, windowStartedAt })
860
+ });
861
+ }
862
+
863
+ export function evaluateRemoteConnectionPolicy({
864
+ remoteAddress = '',
865
+ relayConnection = false,
866
+ encrypted = false,
867
+ allowLegacyPlaintextForTests = false,
868
+ policy = {}
869
+ } = {}) {
870
+ const address = String(remoteAddress || '').replace(/^::ffff:/i, '').trim().toLowerCase();
871
+ if (policy.requireEncryptedConnections === true
872
+ && encrypted !== true
873
+ && allowLegacyPlaintextForTests !== true) {
874
+ return 'encrypted-connection-required';
875
+ }
876
+ if (relayConnection === true || !isPrivateNetworkAddress(address)) {
877
+ return policy.allowInternetConnections === false
878
+ ? 'internet-connections-blocked-by-settings'
879
+ : '';
880
+ }
881
+ if (!isLoopbackHost(address) && policy.allowLanConnections === false) {
882
+ return 'lan-connections-blocked-by-settings';
883
+ }
884
+ return '';
885
+ }
886
886
 
887
887
  function readCpuTimes() {
888
888
  let idle = 0;
@@ -1206,37 +1206,37 @@ function getRetiredAgentTaskError(options = {}) {
1206
1206
  : '';
1207
1207
  }
1208
1208
 
1209
- const SUPPORTED_AGENT_OPERATIONS = new Set([
1210
- 'system.health',
1211
- 'gpu.status',
1212
- 'disk.status',
1213
- 'process.list',
1214
- 'service.status',
1215
- 'diagnostics.collect',
1216
- 'file.read',
1217
- 'file.list',
1218
- 'network.status',
1219
- 'logs.collect'
1220
- ]);
1221
-
1222
- const RETIRED_AGENT_MUTATING_OPERATIONS = new Set([
1223
- 'process.control',
1224
- 'service.control',
1225
- 'application.launch',
1226
- 'application.close',
1227
- 'file.write',
1228
- 'file.delete',
1229
- 'command.run',
1230
- 'script.run',
1231
- 'software.install',
1232
- 'system.power',
1233
- 'system.configure'
1234
- ]);
1235
-
1236
- function normalizeAgentOperation(value) {
1237
- const operation = safeString(value, 80).toLowerCase();
1238
- return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1239
- }
1209
+ const SUPPORTED_AGENT_OPERATIONS = new Set([
1210
+ 'system.health',
1211
+ 'gpu.status',
1212
+ 'disk.status',
1213
+ 'process.list',
1214
+ 'service.status',
1215
+ 'diagnostics.collect',
1216
+ 'file.read',
1217
+ 'file.list',
1218
+ 'network.status',
1219
+ 'logs.collect'
1220
+ ]);
1221
+
1222
+ const RETIRED_AGENT_MUTATING_OPERATIONS = new Set([
1223
+ 'process.control',
1224
+ 'service.control',
1225
+ 'application.launch',
1226
+ 'application.close',
1227
+ 'file.write',
1228
+ 'file.delete',
1229
+ 'command.run',
1230
+ 'script.run',
1231
+ 'software.install',
1232
+ 'system.power',
1233
+ 'system.configure'
1234
+ ]);
1235
+
1236
+ function normalizeAgentOperation(value) {
1237
+ const operation = safeString(value, 80).toLowerCase();
1238
+ return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1239
+ }
1240
1240
 
1241
1241
  const REMOTE_INPUT_MONITOR_KEYS = Object.freeze([
1242
1242
  'monitorIndex',
@@ -1402,7 +1402,7 @@ function buildRemoteFramePath(deviceId, frameKind, frame) {
1402
1402
  return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
1403
1403
  }
1404
1404
 
1405
- function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1405
+ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1406
1406
  if (!frame) {
1407
1407
  return null;
1408
1408
  }
@@ -1424,27 +1424,27 @@ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1424
1424
  serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
1425
1425
  }
1426
1426
 
1427
- return serialized;
1428
- }
1429
-
1430
- function serializeActiveLiveStream(activeLiveStream, deviceId) {
1431
- if (!activeLiveStream) {
1432
- return null;
1433
- }
1434
-
1435
- return {
1436
- ...activeLiveStream,
1437
- // The active descriptor is status metadata. Keep its internal frame
1438
- // owner intact for immediate delivery, but never duplicate encoded
1439
- // bytes or its bearer token into React/status snapshots.
1440
- latestFrame: serializeRemoteFrame(
1441
- activeLiveStream.latestFrame,
1442
- deviceId,
1443
- 'live',
1444
- { includeDataUrl: false }
1445
- )
1446
- };
1447
- }
1427
+ return serialized;
1428
+ }
1429
+
1430
+ function serializeActiveLiveStream(activeLiveStream, deviceId) {
1431
+ if (!activeLiveStream) {
1432
+ return null;
1433
+ }
1434
+
1435
+ return {
1436
+ ...activeLiveStream,
1437
+ // The active descriptor is status metadata. Keep its internal frame
1438
+ // owner intact for immediate delivery, but never duplicate encoded
1439
+ // bytes or its bearer token into React/status snapshots.
1440
+ latestFrame: serializeRemoteFrame(
1441
+ activeLiveStream.latestFrame,
1442
+ deviceId,
1443
+ 'live',
1444
+ { includeDataUrl: false }
1445
+ )
1446
+ };
1447
+ }
1448
1448
 
1449
1449
  function getRecentFrameCache(device, frameKind) {
1450
1450
  if (!device) {
@@ -1478,7 +1478,7 @@ function getRecentFrameCacheMaxBytes(frameKind) {
1478
1478
  : RECENT_LIVE_FRAME_CACHE_MAX_BYTES;
1479
1479
  }
1480
1480
 
1481
- function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1481
+ export function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1482
1482
  if (!Array.isArray(cache)) {
1483
1483
  return;
1484
1484
  }
@@ -1499,6 +1499,20 @@ function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1499
1499
  }
1500
1500
  }
1501
1501
 
1502
+ if (frameKind !== 'thumbnail' && cache.length > 1) {
1503
+ // Keep the newest frame plus the newest independently decodable key
1504
+ // frame. A browser can subscribe after capture has already started;
1505
+ // retaining only the latest 30 fps delta makes that browser wait for a
1506
+ // future GOP even though the Hub accepted a current key frame moments
1507
+ // earlier. Exact stream-owner matching below prevents stale replay.
1508
+ const latest = cache[0];
1509
+ const keyFrame = cache.find(entry =>
1510
+ entry !== latest
1511
+ && (entry?.frame?.isKeyFrame === true
1512
+ || safeString(entry?.frame?.chunkType, 20).toLowerCase() === 'key'));
1513
+ cache.splice(0, cache.length, ...[latest, keyFrame].filter(Boolean));
1514
+ }
1515
+
1502
1516
  for (let index = 0; index < cache.length; index += 1) {
1503
1517
  const entry = cache[index];
1504
1518
  const byteLength = Number(entry?.byteLength || entry?.frame?.payload?.length || 0) || 0;
@@ -1510,7 +1524,7 @@ function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1510
1524
  }
1511
1525
  }
1512
1526
 
1513
- function rememberRecentFramePayload(device, frameKind, frame) {
1527
+ export function rememberRecentFramePayload(device, frameKind, frame) {
1514
1528
  if (!device || !frame || !Buffer.isBuffer(frame.payload)) {
1515
1529
  return;
1516
1530
  }
@@ -1546,7 +1560,7 @@ function rememberRecentFramePayload(device, frameKind, frame) {
1546
1560
  pruneRecentFrameCache(cache, kind, nowMs);
1547
1561
  }
1548
1562
 
1549
- function isFramePayloadRequestMatch(frame, options = {}) {
1563
+ export function isFramePayloadRequestMatch(frame, options = {}) {
1550
1564
  if (!frame || !Buffer.isBuffer(frame.payload)) {
1551
1565
  return false;
1552
1566
  }
@@ -1565,10 +1579,37 @@ function isFramePayloadRequestMatch(frame, options = {}) {
1565
1579
  return false;
1566
1580
  }
1567
1581
 
1582
+ if (options.requireKeyFrame === true
1583
+ && frame.isKeyFrame !== true
1584
+ && safeString(frame.chunkType, 20).toLowerCase() !== 'key') {
1585
+ return false;
1586
+ }
1587
+
1588
+ for (const key of ['streamId', 'sessionId', 'commandId', 'streamPurpose']) {
1589
+ const expected = safeString(options[key], 160);
1590
+ if (expected && safeString(frame[key], 160) !== expected) {
1591
+ return false;
1592
+ }
1593
+ }
1594
+
1595
+ const requestedCaptureGeneration = Number(options.captureGeneration);
1596
+ if (Number.isFinite(requestedCaptureGeneration)
1597
+ && requestedCaptureGeneration > 0
1598
+ && Number(frame.captureGeneration || 0) !== requestedCaptureGeneration) {
1599
+ return false;
1600
+ }
1601
+
1602
+ const requestedMonitorIndex = Number(options.monitorIndex);
1603
+ if (Number.isFinite(requestedMonitorIndex)
1604
+ && requestedMonitorIndex >= 0
1605
+ && Number(frame.monitorIndex ?? -1) !== requestedMonitorIndex) {
1606
+ return false;
1607
+ }
1608
+
1568
1609
  return true;
1569
1610
  }
1570
1611
 
1571
- function findRecentFramePayload(device, frameKind, options = {}) {
1612
+ export function findRecentFramePayload(device, frameKind, options = {}) {
1572
1613
  const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
1573
1614
  const latest = kind === 'thumbnail'
1574
1615
  ? device?.latestThumbnail
@@ -1579,7 +1620,14 @@ function findRecentFramePayload(device, frameKind, options = {}) {
1579
1620
 
1580
1621
  const token = safeString(options.token, 128);
1581
1622
  const requestedSeq = Number(options.frameSeq);
1582
- if (!token && !Number.isFinite(requestedSeq)) {
1623
+ const ownerQuery = options.requireKeyFrame === true
1624
+ || ['streamId', 'sessionId', 'commandId', 'streamPurpose']
1625
+ .some(key => safeString(options[key], 160))
1626
+ || (Number.isFinite(Number(options.captureGeneration))
1627
+ && Number(options.captureGeneration) > 0)
1628
+ || (Number.isFinite(Number(options.monitorIndex))
1629
+ && Number(options.monitorIndex) >= 0);
1630
+ if (!token && !Number.isFinite(requestedSeq) && !ownerQuery) {
1583
1631
  return null;
1584
1632
  }
1585
1633
 
@@ -1635,7 +1683,7 @@ function serializeDevice(device, options = {}) {
1635
1683
  },
1636
1684
  latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1637
1685
  latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1638
- activeLiveStream: serializeActiveLiveStream(device.activeLiveStream, device.deviceId),
1686
+ activeLiveStream: serializeActiveLiveStream(device.activeLiveStream, device.deviceId),
1639
1687
  liveCapturePause: device.liveCapturePause
1640
1688
  ? {
1641
1689
  token: device.liveCapturePause.token,
@@ -2242,7 +2290,7 @@ export function createRemoteHub(options = {}) {
2242
2290
  : null;
2243
2291
  const sampleHubRuntimeDiagnostics = createHubRuntimeDiagnosticsSampler();
2244
2292
 
2245
- function getDevicePolicy(device) {
2293
+ function getDevicePolicy(device) {
2246
2294
  try {
2247
2295
  return getEffectiveDevicePolicy({
2248
2296
  deviceId: device?.deviceId || '',
@@ -2250,18 +2298,18 @@ export function createRemoteHub(options = {}) {
2250
2298
  }) || {};
2251
2299
  } catch {
2252
2300
  return {};
2253
- }
2254
- }
2255
-
2256
- function connectionPolicyError(socket, policy = {}) {
2257
- return evaluateRemoteConnectionPolicy({
2258
- remoteAddress: socket?.remoteAddress || '',
2259
- relayConnection: socket?.__liveDeskRelayControl === true,
2260
- encrypted: socket?.__liveDeskSecurityContext?.encrypted === true,
2261
- allowLegacyPlaintextForTests,
2262
- policy
2263
- });
2264
- }
2301
+ }
2302
+ }
2303
+
2304
+ function connectionPolicyError(socket, policy = {}) {
2305
+ return evaluateRemoteConnectionPolicy({
2306
+ remoteAddress: socket?.remoteAddress || '',
2307
+ relayConnection: socket?.__liveDeskRelayControl === true,
2308
+ encrypted: socket?.__liveDeskSecurityContext?.encrypted === true,
2309
+ allowLegacyPlaintextForTests,
2310
+ policy
2311
+ });
2312
+ }
2265
2313
 
2266
2314
  function policyError(device, permission = '', command = '') {
2267
2315
  const policy = getDevicePolicy(device);
@@ -2311,83 +2359,83 @@ export function createRemoteHub(options = {}) {
2311
2359
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
2312
2360
  const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
2313
2361
  const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
2314
- const configuredEnrollmentToken = safeString(
2315
- options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN,
2316
- 256);
2317
- const allowLegacyPlaintextForTests = options.allowLegacyPlaintextForTests === true
2318
- || isEnabledValue(env.LIVEDESK_TEST_ALLOW_LEGACY_PLAINTEXT, false)
2319
- || env.LIVEDESK_TEST_MODE === '1';
2320
- const canonicalEnrollmentToken = /^[A-Za-z0-9_-]{43}$/.test(configuredEnrollmentToken)
2321
- && Buffer.from(configuredEnrollmentToken, 'base64url').length === 32
2322
- && Buffer.from(configuredEnrollmentToken, 'base64url').toString('base64url') === configuredEnrollmentToken
2323
- ? configuredEnrollmentToken
2324
- : '';
2325
- let pairToken = allowLegacyPlaintextForTests && configuredEnrollmentToken
2326
- ? configuredEnrollmentToken
2327
- : canonicalEnrollmentToken || crypto.randomBytes(32).toString('base64url');
2328
- const deviceCredentialAuthority = options.deviceCredentialAuthority || createDeviceCredentialAuthority({
2329
- dataDir: options.dataDir || env.LIVEDESK_DATA_DIR || undefined
2330
- });
2331
- const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
2332
- ? options.getSecurityIdentity
2333
- : () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
2334
- udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, role, ttlMs }) => {
2335
- const identity = getSecurityIdentity() || {};
2336
- return deviceCredentialAuthority.issueRendezvousProof({
2337
- roomId,
2338
- deviceId,
2339
- role,
2340
- ttlMs,
2341
- accountId: safeString(identity.accountId, 128)
2342
- });
2343
- });
2344
- let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
2362
+ const configuredEnrollmentToken = safeString(
2363
+ options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN,
2364
+ 256);
2365
+ const allowLegacyPlaintextForTests = options.allowLegacyPlaintextForTests === true
2366
+ || isEnabledValue(env.LIVEDESK_TEST_ALLOW_LEGACY_PLAINTEXT, false)
2367
+ || env.LIVEDESK_TEST_MODE === '1';
2368
+ const canonicalEnrollmentToken = /^[A-Za-z0-9_-]{43}$/.test(configuredEnrollmentToken)
2369
+ && Buffer.from(configuredEnrollmentToken, 'base64url').length === 32
2370
+ && Buffer.from(configuredEnrollmentToken, 'base64url').toString('base64url') === configuredEnrollmentToken
2371
+ ? configuredEnrollmentToken
2372
+ : '';
2373
+ let pairToken = allowLegacyPlaintextForTests && configuredEnrollmentToken
2374
+ ? configuredEnrollmentToken
2375
+ : canonicalEnrollmentToken || crypto.randomBytes(32).toString('base64url');
2376
+ const deviceCredentialAuthority = options.deviceCredentialAuthority || createDeviceCredentialAuthority({
2377
+ dataDir: options.dataDir || env.LIVEDESK_DATA_DIR || undefined
2378
+ });
2379
+ const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
2380
+ ? options.getSecurityIdentity
2381
+ : () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
2382
+ udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, role, ttlMs }) => {
2383
+ const identity = getSecurityIdentity() || {};
2384
+ return deviceCredentialAuthority.issueRendezvousProof({
2385
+ roomId,
2386
+ deviceId,
2387
+ role,
2388
+ ttlMs,
2389
+ accountId: safeString(identity.accountId, 128)
2390
+ });
2391
+ });
2392
+ let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
2345
2393
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2346
2394
  : generatePairingPin();
2347
- let pairingPinUpdatedAt = new Date().toISOString();
2348
- const consumeEnrollmentToken = expected => {
2349
- if (!timingSafeStringEqual(expected, pairToken)) return false;
2350
- pairToken = crypto.randomBytes(32).toString('base64url');
2351
- pairingPin = generatePairingPin();
2352
- pairingPinUpdatedAt = new Date().toISOString();
2353
- emitRemoteEvent('RemoteEnrollmentTokenRotated', null, {
2354
- pairingPinUpdatedAt,
2355
- hubId: deviceCredentialAuthority.hubId
2356
- });
2357
- return true;
2358
- };
2359
- const relayControl = options.relayControl && typeof options.relayControl === 'object'
2360
- ? options.relayControl
2361
- : allowLegacyPlaintextForTests
2362
- ? createHubRelayControl({
2363
- env,
2364
- pairToken,
2365
- logEvent,
2366
- logWarn,
2367
- onPeerSocket: socket => handleTcpAgentSocket(socket)
2368
- })
2369
- : createSecureHubRelayControl({
2370
- env,
2371
- authority: deviceCredentialAuthority,
2372
- getIdentity: () => getSecurityIdentity() || {},
2373
- getEnrollmentToken: () => pairToken,
2374
- consumeEnrollmentToken,
2375
- logEvent,
2376
- logWarn,
2377
- onPeerSocket: socket => handleTcpAgentSocket(socket)
2378
- });
2379
- const secureDirectAcceptor = createSecureDirectAcceptor({
2380
- authority: deviceCredentialAuthority,
2381
- getIdentity: () => getSecurityIdentity() || {},
2382
- getEnrollmentToken: () => pairToken,
2383
- consumeEnrollmentToken,
2384
- onSecureSocket: socket => handleTcpAgentSocket(socket),
2385
- onAudit: audit => emitEvent('RemoteSecurityHandshakeAudit', {
2386
- ...audit,
2387
- remoteHub: getStatus({ includeSecrets: false })
2388
- }),
2389
- logWarn
2390
- });
2395
+ let pairingPinUpdatedAt = new Date().toISOString();
2396
+ const consumeEnrollmentToken = expected => {
2397
+ if (!timingSafeStringEqual(expected, pairToken)) return false;
2398
+ pairToken = crypto.randomBytes(32).toString('base64url');
2399
+ pairingPin = generatePairingPin();
2400
+ pairingPinUpdatedAt = new Date().toISOString();
2401
+ emitRemoteEvent('RemoteEnrollmentTokenRotated', null, {
2402
+ pairingPinUpdatedAt,
2403
+ hubId: deviceCredentialAuthority.hubId
2404
+ });
2405
+ return true;
2406
+ };
2407
+ const relayControl = options.relayControl && typeof options.relayControl === 'object'
2408
+ ? options.relayControl
2409
+ : allowLegacyPlaintextForTests
2410
+ ? createHubRelayControl({
2411
+ env,
2412
+ pairToken,
2413
+ logEvent,
2414
+ logWarn,
2415
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2416
+ })
2417
+ : createSecureHubRelayControl({
2418
+ env,
2419
+ authority: deviceCredentialAuthority,
2420
+ getIdentity: () => getSecurityIdentity() || {},
2421
+ getEnrollmentToken: () => pairToken,
2422
+ consumeEnrollmentToken,
2423
+ logEvent,
2424
+ logWarn,
2425
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2426
+ });
2427
+ const secureDirectAcceptor = createSecureDirectAcceptor({
2428
+ authority: deviceCredentialAuthority,
2429
+ getIdentity: () => getSecurityIdentity() || {},
2430
+ getEnrollmentToken: () => pairToken,
2431
+ consumeEnrollmentToken,
2432
+ onSecureSocket: socket => handleTcpAgentSocket(socket),
2433
+ onAudit: audit => emitEvent('RemoteSecurityHandshakeAudit', {
2434
+ ...audit,
2435
+ remoteHub: getStatus({ includeSecrets: false })
2436
+ }),
2437
+ logWarn
2438
+ });
2391
2439
  const duplicateDeviceLogThrottleMs = clampNumber(
2392
2440
  env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
2393
2441
  5000,
@@ -2412,8 +2460,8 @@ export function createRemoteHub(options = {}) {
2412
2460
  const transportDiagnostics = new Map();
2413
2461
  const transportDiagnosticStartingDevices = new Set();
2414
2462
  const duplicateDeviceLogAt = new Map();
2415
- let server = null;
2416
- let controlIdleSweepTimer = null;
2463
+ let server = null;
2464
+ let controlIdleSweepTimer = null;
2417
2465
  let started = false;
2418
2466
  let boundPort = requestedPort;
2419
2467
  let lastError = '';
@@ -2735,8 +2783,8 @@ export function createRemoteHub(options = {}) {
2735
2783
  host,
2736
2784
  agentHost: routeInfo.host || getAnnouncedHost(),
2737
2785
  port: boundPort || requestedPort,
2738
- protocol: 'secure-record-v1',
2739
- protocolVersion: REMOTE_PROTOCOL_VERSION,
2786
+ protocol: 'secure-record-v1',
2787
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2740
2788
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2741
2789
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2742
2790
  frameModes: getSupportedRemoteFrameModeProfiles(),
@@ -2752,18 +2800,18 @@ export function createRemoteHub(options = {}) {
2752
2800
  agentEndpointCandidates: routeInfo.candidates,
2753
2801
  agentEndpointCandidateDetails: routeInfo.candidateDetails,
2754
2802
  pairToken: includeSecrets ? pairToken : undefined,
2755
- pairTokenPreview: maskToken(pairToken),
2756
- pairingPin: includeSecrets ? pairingPin : '',
2757
- pairingPinUpdatedAt,
2758
- security: {
2759
- direct: secureDirectAcceptor.getStatus(),
2760
- hubId: deviceCredentialAuthority.hubId,
2761
- hubPublicKeyFingerprint: crypto.createHash('sha256')
2762
- .update(Buffer.from(deviceCredentialAuthority.hubPublicKey, 'base64url'))
2763
- .digest('hex'),
2764
- enrolledDeviceCount: deviceCredentialAuthority.listDevices().filter(item => !item.revokedAt).length,
2765
- legacyPlaintextForTests: allowLegacyPlaintextForTests
2766
- },
2803
+ pairTokenPreview: maskToken(pairToken),
2804
+ pairingPin: includeSecrets ? pairingPin : '',
2805
+ pairingPinUpdatedAt,
2806
+ security: {
2807
+ direct: secureDirectAcceptor.getStatus(),
2808
+ hubId: deviceCredentialAuthority.hubId,
2809
+ hubPublicKeyFingerprint: crypto.createHash('sha256')
2810
+ .update(Buffer.from(deviceCredentialAuthority.hubPublicKey, 'base64url'))
2811
+ .digest('hex'),
2812
+ enrolledDeviceCount: deviceCredentialAuthority.listDevices().filter(item => !item.revokedAt).length,
2813
+ legacyPlaintextForTests: allowLegacyPlaintextForTests
2814
+ },
2767
2815
  deviceCount: devices.size,
2768
2816
  connectedDeviceCount: connectedDevices,
2769
2817
  canvasDeviceListMode: 'all-devices',
@@ -4023,22 +4071,22 @@ export function createRemoteHub(options = {}) {
4023
4071
  }
4024
4072
  }
4025
4073
 
4026
- function closeInputSocket(device, reason = 'input-socket-closed') {
4074
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4027
4075
  const socket = device?.inputSocket;
4028
4076
  if (!socket) {
4029
4077
  return;
4030
4078
  }
4031
-
4032
- const writeOwner = device.inputWriteOwner;
4033
- if (device.controlOwnerConnectionId) {
4034
- endControlSession(
4035
- device,
4036
- device.controlOwnerConnectionId,
4037
- reason,
4038
- writeOwner,
4039
- writeOwner?.getBindingKey?.() || '');
4040
- }
4041
- device.inputSocket = null;
4079
+
4080
+ const writeOwner = device.inputWriteOwner;
4081
+ if (device.controlOwnerConnectionId) {
4082
+ endControlSession(
4083
+ device,
4084
+ device.controlOwnerConnectionId,
4085
+ reason,
4086
+ writeOwner,
4087
+ writeOwner?.getBindingKey?.() || '');
4088
+ }
4089
+ device.inputSocket = null;
4042
4090
  device.inputWriteOwner = null;
4043
4091
  device.inputSocketConnectionId = '';
4044
4092
  device.inputOwnerConnectionId = '';
@@ -4068,17 +4116,17 @@ export function createRemoteHub(options = {}) {
4068
4116
  if (!device || device.inputSocket !== socket) {
4069
4117
  return;
4070
4118
  }
4071
-
4072
- const writeOwner = device.inputWriteOwner;
4073
- if (device.controlOwnerConnectionId) {
4074
- endControlSession(
4075
- device,
4076
- device.controlOwnerConnectionId,
4077
- reason,
4078
- null,
4079
- '');
4080
- }
4081
- device.inputSocket = null;
4119
+
4120
+ const writeOwner = device.inputWriteOwner;
4121
+ if (device.controlOwnerConnectionId) {
4122
+ endControlSession(
4123
+ device,
4124
+ device.controlOwnerConnectionId,
4125
+ reason,
4126
+ null,
4127
+ '');
4128
+ }
4129
+ device.inputSocket = null;
4082
4130
  device.inputWriteOwner = null;
4083
4131
  device.inputSocketConnectionId = '';
4084
4132
  device.inputOwnerConnectionId = '';
@@ -4510,11 +4558,11 @@ export function createRemoteHub(options = {}) {
4510
4558
  inputWriteOwner: null,
4511
4559
  inputSocketConnectionId: '',
4512
4560
  inputOwnerConnectionId: '',
4513
- inputOwnerBindingKey: '',
4514
- controlOwnerSessionId: '',
4515
- controlOwnerConnectionId: '',
4516
- controlOwnerStartedAtMs: 0,
4517
- controlOwnerLastInputAtMs: 0,
4561
+ inputOwnerBindingKey: '',
4562
+ controlOwnerSessionId: '',
4563
+ controlOwnerConnectionId: '',
4564
+ controlOwnerStartedAtMs: 0,
4565
+ controlOwnerLastInputAtMs: 0,
4518
4566
  frameSocket: null,
4519
4567
  audioSocket: null,
4520
4568
  fileSocket: null,
@@ -7435,14 +7483,14 @@ export function createRemoteHub(options = {}) {
7435
7483
  return;
7436
7484
  }
7437
7485
 
7438
- if (!state.authenticated) {
7439
- const securityContext = socket.__liveDeskSecurityContext;
7440
- const secureDeviceAuthenticated = securityContext?.authenticated === true
7441
- && securityContext?.encrypted === true
7442
- && safeString(securityContext.accountId, 128)
7443
- && safeString(securityContext.hubId, 128)
7444
- && safeString(securityContext.deviceId, 128);
7445
- if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
7486
+ if (!state.authenticated) {
7487
+ const securityContext = socket.__liveDeskSecurityContext;
7488
+ const secureDeviceAuthenticated = securityContext?.authenticated === true
7489
+ && securityContext?.encrypted === true
7490
+ && safeString(securityContext.accountId, 128)
7491
+ && safeString(securityContext.hubId, 128)
7492
+ && safeString(securityContext.deviceId, 128);
7493
+ if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
7446
7494
  message = {
7447
7495
  ...message,
7448
7496
  capabilities: {
@@ -7452,14 +7500,14 @@ export function createRemoteHub(options = {}) {
7452
7500
  dedicatedInputChannel: false
7453
7501
  }
7454
7502
  };
7455
- }
7456
- if (message.type === 'slot.assign') {
7457
- if (!secureDeviceAuthenticated
7458
- || safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7459
- writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'authenticated-device-credential-required' });
7460
- socket.end();
7461
- return;
7462
- }
7503
+ }
7504
+ if (message.type === 'slot.assign') {
7505
+ if (!secureDeviceAuthenticated
7506
+ || safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7507
+ writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'authenticated-device-credential-required' });
7508
+ socket.end();
7509
+ return;
7510
+ }
7463
7511
  const result = assignDeviceSlot(
7464
7512
  message.deviceId || message.DeviceId,
7465
7513
  message.slotNumber ?? message.slot ?? message.SlotNumber);
@@ -7473,54 +7521,57 @@ export function createRemoteHub(options = {}) {
7473
7521
  return;
7474
7522
  }
7475
7523
 
7476
- if (secureDeviceAuthenticated) {
7477
- if (safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7478
- writeJsonLine(socket, { type: 'error', error: 'device-credential-binding-invalid' });
7479
- socket.destroy();
7480
- return;
7481
- }
7482
- const helloChannel = safeString(message.channel || message.Channel || 'control', 40).toLowerCase() || 'control';
7483
- if (helloChannel !== securityContext.channel) {
7484
- writeJsonLine(socket, { type: 'error', error: 'secure-channel-binding-invalid' });
7485
- socket.destroy();
7486
- return;
7487
- }
7488
- } else {
7489
- const legacyAllowed = socket.__liveDeskRelayControl === true
7490
- || socket.__liveDeskLegacyPlaintext === true && allowLegacyPlaintextForTests;
7491
- if (!legacyAllowed || !timingSafeStringEqual(message.pairToken, pairToken)) {
7492
- writeJsonLine(socket, { type: 'error', error: 'authenticated-device-credential-required' });
7493
- socket.destroy();
7494
- return;
7495
- }
7496
- }
7497
-
7498
- const incomingDeviceId = safeString(message.deviceId || message.DeviceId, 128);
7499
- const incomingCapabilities = message.capabilities && typeof message.capabilities === 'object'
7500
- ? message.capabilities
7501
- : {};
7502
- const incomingPolicy = getWelcomeDevicePolicy({
7503
- deviceId: incomingDeviceId,
7504
- capabilities: incomingCapabilities
7505
- }) || {};
7506
- const connectionDenied = connectionPolicyError(socket, incomingPolicy);
7507
- if (connectionDenied) {
7508
- writeJsonLine(socket, { type: 'error', error: connectionDenied });
7509
- emitEvent('RemoteSecurityPolicyAudit', {
7510
- result: 'rejected',
7511
- reason: connectionDenied,
7512
- deviceId: incomingDeviceId,
7513
- sessionId: safeString(socket.__liveDeskSecurityContext?.sessionId, 160),
7514
- accountId: safeString(socket.__liveDeskSecurityContext?.accountId, 128),
7515
- hubId: safeString(socket.__liveDeskSecurityContext?.hubId, 128),
7516
- transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7517
- remoteAddress: safeString(socket.remoteAddress, 256)
7518
- });
7519
- socket.destroy();
7520
- return;
7521
- }
7522
-
7523
- const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
7524
+ if (secureDeviceAuthenticated) {
7525
+ if (safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7526
+ writeJsonLine(socket, { type: 'error', error: 'device-credential-binding-invalid' });
7527
+ socket.destroy();
7528
+ return;
7529
+ }
7530
+ const helloChannel = safeString(message.channel || message.Channel || 'control', 40).toLowerCase() || 'control';
7531
+ if (helloChannel !== securityContext.channel) {
7532
+ writeJsonLine(socket, { type: 'error', error: 'secure-channel-binding-invalid' });
7533
+ socket.destroy();
7534
+ return;
7535
+ }
7536
+ } else {
7537
+ const legacyAllowed = socket.__liveDeskRelayControl === true
7538
+ || socket.__liveDeskLegacyPlaintext === true && allowLegacyPlaintextForTests;
7539
+ if (!legacyAllowed || !timingSafeStringEqual(message.pairToken, pairToken)) {
7540
+ writeJsonLine(socket, { type: 'error', error: 'authenticated-device-credential-required' });
7541
+ socket.destroy();
7542
+ return;
7543
+ }
7544
+ }
7545
+
7546
+ const incomingDeviceId = safeString(message.deviceId || message.DeviceId, 128);
7547
+ const incomingCapabilities = message.capabilities && typeof message.capabilities === 'object'
7548
+ ? message.capabilities
7549
+ : {};
7550
+ const incomingPolicy = getWelcomeDevicePolicy({
7551
+ deviceId: incomingDeviceId,
7552
+ capabilities: incomingCapabilities
7553
+ }) || {};
7554
+ const connectionDenied = connectionPolicyError(socket, incomingPolicy);
7555
+ if (connectionDenied) {
7556
+ const deniedTransport = socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp';
7557
+ const deniedRemoteAddress = safeString(socket.remoteAddress, 256) || 'unknown';
7558
+ logWarn('security', 'Remote connection rejected reason=' + connectionDenied + ' transport=' + deniedTransport + ' remote=' + deniedRemoteAddress);
7559
+ writeJsonLine(socket, { type: 'error', error: connectionDenied });
7560
+ emitEvent('RemoteSecurityPolicyAudit', {
7561
+ result: 'rejected',
7562
+ reason: connectionDenied,
7563
+ deviceId: incomingDeviceId,
7564
+ sessionId: safeString(socket.__liveDeskSecurityContext?.sessionId, 160),
7565
+ accountId: safeString(socket.__liveDeskSecurityContext?.accountId, 128),
7566
+ hubId: safeString(socket.__liveDeskSecurityContext?.hubId, 128),
7567
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7568
+ remoteAddress: safeString(socket.remoteAddress, 256)
7569
+ });
7570
+ socket.destroy();
7571
+ return;
7572
+ }
7573
+
7574
+ const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
7524
7575
  if (channel === 'input') {
7525
7576
  const device = attachInputSocket(socket, message);
7526
7577
  if (!device) {
@@ -7687,27 +7738,27 @@ export function createRemoteHub(options = {}) {
7687
7738
  }
7688
7739
  }
7689
7740
 
7690
- function enforceAgentMessageRate(socket, state, channel = 'control') {
7691
- if (state.messageRateGate.consume()) return true;
7692
- const device = state.device;
7693
- emitEvent('RemoteAbuseDefense', {
7694
- result: 'rejected',
7695
- reason: 'authenticated-message-rate-exceeded',
7696
- deviceId: device?.deviceId || '',
7697
- sessionId: device?.sessionId || '',
7698
- accountId: socket.__liveDeskSecurityContext?.accountId || '',
7699
- hubId: socket.__liveDeskSecurityContext?.hubId || '',
7700
- transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7701
- channel,
7702
- remoteAddress: safeString(socket.remoteAddress, 256),
7703
- rate: state.messageRateGate.getStatus()
7704
- });
7705
- writeJsonLine(socket, { type: 'error', error: 'message-rate-exceeded' });
7706
- socket.destroy();
7707
- return false;
7708
- }
7709
-
7710
- function handleWebSocketAgentSocket(socket) {
7741
+ function enforceAgentMessageRate(socket, state, channel = 'control') {
7742
+ if (state.messageRateGate.consume()) return true;
7743
+ const device = state.device;
7744
+ emitEvent('RemoteAbuseDefense', {
7745
+ result: 'rejected',
7746
+ reason: 'authenticated-message-rate-exceeded',
7747
+ deviceId: device?.deviceId || '',
7748
+ sessionId: device?.sessionId || '',
7749
+ accountId: socket.__liveDeskSecurityContext?.accountId || '',
7750
+ hubId: socket.__liveDeskSecurityContext?.hubId || '',
7751
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7752
+ channel,
7753
+ remoteAddress: safeString(socket.remoteAddress, 256),
7754
+ rate: state.messageRateGate.getStatus()
7755
+ });
7756
+ writeJsonLine(socket, { type: 'error', error: 'message-rate-exceeded' });
7757
+ socket.destroy();
7758
+ return false;
7759
+ }
7760
+
7761
+ function handleWebSocketAgentSocket(socket) {
7711
7762
  allSockets.add(socket);
7712
7763
  socket.setNoDelay(true);
7713
7764
  socket.setKeepAlive(true, heartbeatMs);
@@ -7721,11 +7772,11 @@ export function createRemoteHub(options = {}) {
7721
7772
  audioOnly: false,
7722
7773
  fileOnly: false,
7723
7774
  parserBuffer: socket.buffer,
7724
- binaryIngress: null,
7725
- binaryQueueDrops: 0,
7726
- binaryQueueEpoch: crypto.randomUUID(),
7727
- messageRateGate: createRemoteMessageRateGate(),
7728
- closed: false
7775
+ binaryIngress: null,
7776
+ binaryQueueDrops: 0,
7777
+ binaryQueueEpoch: crypto.randomUUID(),
7778
+ messageRateGate: createRemoteMessageRateGate(),
7779
+ closed: false
7729
7780
  };
7730
7781
  attachAgentBinaryIngress(socket, state);
7731
7782
 
@@ -7736,9 +7787,9 @@ export function createRemoteHub(options = {}) {
7736
7787
  }
7737
7788
  }, 10000);
7738
7789
 
7739
- socket.onTextMessage = text => {
7740
- if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7741
- try {
7790
+ socket.onTextMessage = text => {
7791
+ if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7792
+ try {
7742
7793
  handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
7743
7794
  } catch (err) {
7744
7795
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
@@ -7746,9 +7797,9 @@ export function createRemoteHub(options = {}) {
7746
7797
  }
7747
7798
  };
7748
7799
 
7749
- socket.onBinaryMessage = payload => {
7750
- if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7751
- try {
7800
+ socket.onBinaryMessage = payload => {
7801
+ if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7802
+ try {
7752
7803
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
7753
7804
  if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
7754
7805
  writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
@@ -7903,11 +7954,11 @@ export function createRemoteHub(options = {}) {
7903
7954
  buffer: new BoundedSegmentedBuffer(MAX_AGENT_SOCKET_ACCUMULATOR_BYTES),
7904
7955
  lineScanOffset: 0,
7905
7956
  pendingBinaryFrame: null,
7906
- binaryIngress: null,
7907
- binaryQueueDrops: 0,
7908
- binaryQueueEpoch: crypto.randomUUID(),
7909
- messageRateGate: createRemoteMessageRateGate(),
7910
- closed: false
7957
+ binaryIngress: null,
7958
+ binaryQueueDrops: 0,
7959
+ binaryQueueEpoch: crypto.randomUUID(),
7960
+ messageRateGate: createRemoteMessageRateGate(),
7961
+ closed: false
7911
7962
  };
7912
7963
  attachAgentBinaryIngress(socket, state);
7913
7964
 
@@ -8022,18 +8073,18 @@ export function createRemoteHub(options = {}) {
8022
8073
  return;
8023
8074
  }
8024
8075
 
8025
- try {
8026
- const message = parseJsonLine(lineBuffer.toString('utf8'));
8027
- if (!enforceAgentMessageRate(socket, state, state.frameOnly
8028
- ? 'frame'
8029
- : state.audioOnly
8030
- ? 'audio'
8031
- : state.inputOnly
8032
- ? 'input'
8033
- : state.fileOnly ? 'file' : 'control')) {
8034
- return;
8035
- }
8036
- if (message?.type === 'frame.binary') {
8076
+ try {
8077
+ const message = parseJsonLine(lineBuffer.toString('utf8'));
8078
+ if (!enforceAgentMessageRate(socket, state, state.frameOnly
8079
+ ? 'frame'
8080
+ : state.audioOnly
8081
+ ? 'audio'
8082
+ : state.inputOnly
8083
+ ? 'input'
8084
+ : state.fileOnly ? 'file' : 'control')) {
8085
+ return;
8086
+ }
8087
+ if (message?.type === 'frame.binary') {
8037
8088
  const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
8038
8089
  const maxBytes = frameKind === 'thumbnail'
8039
8090
  ? MAX_THUMBNAIL_BINARY_BYTES
@@ -8087,35 +8138,35 @@ export function createRemoteHub(options = {}) {
8087
8138
  }
8088
8139
  }
8089
8140
 
8090
- function handleSocket(socket) {
8091
- if (!allowLegacyPlaintextForTests) {
8092
- secureDirectAcceptor.accept(socket);
8093
- return;
8094
- }
8095
- socket.once('data', chunk => {
8096
- const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8097
- if (isSecureHandshakeStart(firstChunk)) {
8098
- secureDirectAcceptor.accept(socket, firstChunk);
8099
- return;
8100
- }
8101
- if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
8102
- if (!allowLegacyPlaintextForTests) {
8103
- socket.write('HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n');
8104
- socket.destroy();
8105
- return;
8106
- }
8107
- socket.__liveDeskLegacyPlaintext = true;
8108
- handleWebSocketUpgradeSocket(socket, firstChunk);
8109
- return;
8110
- }
8111
- if (!allowLegacyPlaintextForTests) {
8112
- writeJsonLine(socket, { type: 'error', error: 'secure-direct-required' });
8113
- socket.destroy();
8114
- return;
8115
- }
8116
- socket.__liveDeskLegacyPlaintext = true;
8117
- handleTcpAgentSocket(socket, firstChunk);
8118
- });
8141
+ function handleSocket(socket) {
8142
+ if (!allowLegacyPlaintextForTests) {
8143
+ secureDirectAcceptor.accept(socket);
8144
+ return;
8145
+ }
8146
+ socket.once('data', chunk => {
8147
+ const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8148
+ if (isSecureHandshakeStart(firstChunk)) {
8149
+ secureDirectAcceptor.accept(socket, firstChunk);
8150
+ return;
8151
+ }
8152
+ if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
8153
+ if (!allowLegacyPlaintextForTests) {
8154
+ socket.write('HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n');
8155
+ socket.destroy();
8156
+ return;
8157
+ }
8158
+ socket.__liveDeskLegacyPlaintext = true;
8159
+ handleWebSocketUpgradeSocket(socket, firstChunk);
8160
+ return;
8161
+ }
8162
+ if (!allowLegacyPlaintextForTests) {
8163
+ writeJsonLine(socket, { type: 'error', error: 'secure-direct-required' });
8164
+ socket.destroy();
8165
+ return;
8166
+ }
8167
+ socket.__liveDeskLegacyPlaintext = true;
8168
+ handleTcpAgentSocket(socket, firstChunk);
8169
+ });
8119
8170
 
8120
8171
  socket.once('error', () => {
8121
8172
  // The transport-specific handler owns logging after the first byte.
@@ -8137,10 +8188,10 @@ export function createRemoteHub(options = {}) {
8137
8188
  started = true;
8138
8189
  boundPort = candidateServer.address()?.port || port;
8139
8190
  lastError = '';
8140
- logEvent('remote', `LiveDesk Hub client endpoint listening with authenticated encryption on tcp://${host}:${boundPort}`, 'success');
8141
- if (host === '0.0.0.0' || host === '::') {
8142
- logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable; Secure Direct device credentials are required.');
8143
- }
8191
+ logEvent('remote', `LiveDesk Hub client endpoint listening with authenticated encryption on tcp://${host}:${boundPort}`, 'success');
8192
+ if (host === '0.0.0.0' || host === '::') {
8193
+ logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable; Secure Direct device credentials are required.');
8194
+ }
8144
8195
  emitRemoteEvent('RemoteHubStarted', null);
8145
8196
  resolve();
8146
8197
  });
@@ -8152,14 +8203,14 @@ export function createRemoteHub(options = {}) {
8152
8203
  return getStatus({ includeSecrets: false });
8153
8204
  }
8154
8205
 
8155
- try {
8156
- await udpTransport?.start?.();
8157
- await listenOnPort(requestedPort);
8158
- if (!controlIdleSweepTimer) {
8159
- controlIdleSweepTimer = setInterval(sweepIdleControlSessions, 1_000);
8160
- controlIdleSweepTimer.unref?.();
8161
- }
8162
- try {
8206
+ try {
8207
+ await udpTransport?.start?.();
8208
+ await listenOnPort(requestedPort);
8209
+ if (!controlIdleSweepTimer) {
8210
+ controlIdleSweepTimer = setInterval(sweepIdleControlSessions, 1_000);
8211
+ controlIdleSweepTimer.unref?.();
8212
+ }
8213
+ try {
8163
8214
  await relayControl?.start?.();
8164
8215
  } catch {
8165
8216
  logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
@@ -8180,12 +8231,12 @@ export function createRemoteHub(options = {}) {
8180
8231
  return getStatus({ includeSecrets: false });
8181
8232
  }
8182
8233
 
8183
- async function close() {
8184
- if (controlIdleSweepTimer) {
8185
- clearInterval(controlIdleSweepTimer);
8186
- controlIdleSweepTimer = null;
8187
- }
8188
- for (const state of [...agentBinaryIngressStates]) {
8234
+ async function close() {
8235
+ if (controlIdleSweepTimer) {
8236
+ clearInterval(controlIdleSweepTimer);
8237
+ controlIdleSweepTimer = null;
8238
+ }
8239
+ for (const state of [...agentBinaryIngressStates]) {
8189
8240
  closeAgentBinaryIngress(state, 'hub-shutdown');
8190
8241
  }
8191
8242
 
@@ -8322,7 +8373,7 @@ export function createRemoteHub(options = {}) {
8322
8373
  };
8323
8374
  }
8324
8375
 
8325
- function sendCommand(deviceId, command) {
8376
+ function sendCommand(deviceId, command) {
8326
8377
  const device = devices.get(String(deviceId || ''));
8327
8378
  const commandName = safeString(command?.command || 'ping', 80);
8328
8379
  const requiredPermission = commandName === 'input.control'
@@ -8388,64 +8439,64 @@ export function createRemoteHub(options = {}) {
8388
8439
  command: payload.command,
8389
8440
  channel: dedicatedFileSocket ? 'file' : 'control'
8390
8441
  });
8391
- return { ok: true, commandId };
8392
- }
8393
-
8394
- async function sendCommandAwaitResult(deviceId, command, options = {}) {
8395
- const normalizedDeviceId = safeString(deviceId, 160);
8396
- const device = devices.get(normalizedDeviceId);
8397
- const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8398
- const commandWithId = { ...(command || {}), commandId };
8399
-
8400
- if (device?.synthetic === true && device.connected) {
8401
- const sent = sendCommand(normalizedDeviceId, commandWithId);
8402
- return {
8403
- ...sent,
8404
- queued: sent.ok === true,
8405
- acknowledged: sent.ok === true,
8406
- acknowledgement: sent.ok === true
8407
- ? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
8408
- : { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
8409
- };
8410
- }
8411
-
8412
- if (!device?.socket || device.socket.destroyed || !device.connected) {
8413
- const sent = sendCommand(normalizedDeviceId, commandWithId);
8414
- return {
8415
- ...sent,
8416
- queued: false,
8417
- acknowledged: false,
8418
- acknowledgement: {
8419
- ok: false,
8420
- commandId,
8421
- result: null,
8422
- error: sent.error || 'device-not-connected'
8423
- }
8424
- };
8425
- }
8426
-
8427
- const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
8428
- // Register before writing: a loopback Agent can return command.result in
8429
- // the same event-loop turn as the command write.
8430
- const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
8431
- const sent = sendCommand(normalizedDeviceId, commandWithId);
8432
- if (!sent.ok) {
8433
- failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
8434
- }
8435
- const acknowledgement = await acknowledgementPromise;
8436
- return {
8437
- ...sent,
8438
- ok: sent.ok === true && acknowledgement.ok === true,
8439
- queued: sent.ok === true,
8440
- acknowledged: acknowledgement.ok === true,
8441
- acknowledgement,
8442
- error: acknowledgement.ok === true
8443
- ? undefined
8444
- : acknowledgement.error || sent.error || 'command-result-failed'
8445
- };
8446
- }
8447
-
8448
- function refreshDevicePolicies(deviceIds = undefined) {
8442
+ return { ok: true, commandId };
8443
+ }
8444
+
8445
+ async function sendCommandAwaitResult(deviceId, command, options = {}) {
8446
+ const normalizedDeviceId = safeString(deviceId, 160);
8447
+ const device = devices.get(normalizedDeviceId);
8448
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8449
+ const commandWithId = { ...(command || {}), commandId };
8450
+
8451
+ if (device?.synthetic === true && device.connected) {
8452
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8453
+ return {
8454
+ ...sent,
8455
+ queued: sent.ok === true,
8456
+ acknowledged: sent.ok === true,
8457
+ acknowledgement: sent.ok === true
8458
+ ? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
8459
+ : { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
8460
+ };
8461
+ }
8462
+
8463
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8464
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8465
+ return {
8466
+ ...sent,
8467
+ queued: false,
8468
+ acknowledged: false,
8469
+ acknowledgement: {
8470
+ ok: false,
8471
+ commandId,
8472
+ result: null,
8473
+ error: sent.error || 'device-not-connected'
8474
+ }
8475
+ };
8476
+ }
8477
+
8478
+ const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
8479
+ // Register before writing: a loopback Agent can return command.result in
8480
+ // the same event-loop turn as the command write.
8481
+ const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
8482
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8483
+ if (!sent.ok) {
8484
+ failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
8485
+ }
8486
+ const acknowledgement = await acknowledgementPromise;
8487
+ return {
8488
+ ...sent,
8489
+ ok: sent.ok === true && acknowledgement.ok === true,
8490
+ queued: sent.ok === true,
8491
+ acknowledged: acknowledgement.ok === true,
8492
+ acknowledgement,
8493
+ error: acknowledgement.ok === true
8494
+ ? undefined
8495
+ : acknowledgement.error || sent.error || 'command-result-failed'
8496
+ };
8497
+ }
8498
+
8499
+ function refreshDevicePolicies(deviceIds = undefined) {
8449
8500
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8450
8501
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8451
8502
  : null;
@@ -8454,31 +8505,34 @@ export function createRemoteHub(options = {}) {
8454
8505
  for (const device of devices.values()) {
8455
8506
  if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8456
8507
  if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8457
- total += 1;
8458
- const effectivePolicy = getDevicePolicy(device);
8459
- const connectionDenied = connectionPolicyError(device.socket, effectivePolicy);
8460
- if (connectionDenied) {
8461
- emitEvent('RemoteSecurityPolicyAudit', {
8462
- result: 'rejected',
8463
- reason: connectionDenied,
8464
- deviceId: device.deviceId,
8465
- sessionId: device.sessionId,
8466
- transport: device.controlTransport,
8467
- remoteAddress: device.remoteAddress
8468
- });
8469
- disconnectDevice(device.deviceId, connectionDenied);
8470
- continue;
8471
- }
8472
- if (effectivePolicy.allowControl !== true && device.controlOwnerConnectionId) {
8473
- releaseInputOwner(
8474
- device.deviceId,
8475
- device.controlOwnerConnectionId,
8476
- 'control-disabled-by-policy');
8477
- }
8478
- if (effectivePolicy.allowRemoteAudio !== true && device.activeAudioStream) {
8479
- stopAudioStream(device.deviceId, { reason: 'remote-audio-disabled-by-policy' });
8480
- }
8481
- if (writeJsonLine(device.socket, {
8508
+ total += 1;
8509
+ const effectivePolicy = getDevicePolicy(device);
8510
+ const connectionDenied = connectionPolicyError(device.socket, effectivePolicy);
8511
+ if (connectionDenied) {
8512
+ const deniedTransport = device.controlTransport || 'direct-tcp';
8513
+ const deniedRemoteAddress = safeString(device.remoteAddress, 256) || 'unknown';
8514
+ logWarn('security', 'Connected device rejected by refreshed policy reason=' + connectionDenied + ' transport=' + deniedTransport + ' remote=' + deniedRemoteAddress + ' device=' + device.deviceId);
8515
+ emitEvent('RemoteSecurityPolicyAudit', {
8516
+ result: 'rejected',
8517
+ reason: connectionDenied,
8518
+ deviceId: device.deviceId,
8519
+ sessionId: device.sessionId,
8520
+ transport: device.controlTransport,
8521
+ remoteAddress: device.remoteAddress
8522
+ });
8523
+ disconnectDevice(device.deviceId, connectionDenied);
8524
+ continue;
8525
+ }
8526
+ if (effectivePolicy.allowControl !== true && device.controlOwnerConnectionId) {
8527
+ releaseInputOwner(
8528
+ device.deviceId,
8529
+ device.controlOwnerConnectionId,
8530
+ 'control-disabled-by-policy');
8531
+ }
8532
+ if (effectivePolicy.allowRemoteAudio !== true && device.activeAudioStream) {
8533
+ stopAudioStream(device.deviceId, { reason: 'remote-audio-disabled-by-policy' });
8534
+ }
8535
+ if (writeJsonLine(device.socket, {
8482
8536
  type: 'policy.update',
8483
8537
  effectivePolicy,
8484
8538
  updatedAt: new Date().toISOString()
@@ -8557,7 +8611,7 @@ export function createRemoteHub(options = {}) {
8557
8611
  ]);
8558
8612
  }
8559
8613
 
8560
- function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8614
+ function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8561
8615
  return {
8562
8616
  type: 'input.control',
8563
8617
  payload: {
@@ -8579,114 +8633,114 @@ export function createRemoteHub(options = {}) {
8579
8633
  hubForwardedAtEpochMs: Date.now(),
8580
8634
  issuedAt: new Date().toISOString()
8581
8635
  }
8582
- };
8583
- }
8584
-
8585
- function controlSessionMessage(device, ownerConnectionId, type, reason = '') {
8586
- const policy = getDevicePolicy(device);
8587
- return {
8588
- type,
8589
- payload: {
8590
- controlOwnerSessionId: safeString(device?.controlOwnerSessionId, 160),
8591
- hubConnectionId: safeString(ownerConnectionId, 128),
8592
- deviceId: safeString(device?.deviceId, 160),
8593
- visibleIndicator: true,
8594
- lockOnEnd: type === 'control.session.stop' && policy.lockOnControlEnd === true,
8595
- idleControlMinutes: clampNumber(policy.idleControlMinutes, 5, 1440, 30),
8596
- reason: safeString(reason, 160),
8597
- issuedAt: new Date().toISOString()
8598
- }
8599
- };
8600
- }
8601
-
8602
- function sendControlSessionMessage(device, ownerConnectionId, type, reason, inputWriteOwner, bindingKey) {
8603
- const message = controlSessionMessage(device, ownerConnectionId, type, reason);
8604
- if (inputWriteOwner && bindingKey) {
8605
- return inputWriteOwner.enqueue(message, bindingKey);
8606
- }
8607
- if (!device?.socket || device.socket.destroyed) {
8608
- return { ok: false, error: 'device-not-connected' };
8609
- }
8610
- const sent = writeJsonLine(device.socket, {
8611
- type: 'command',
8612
- commandId: crypto.randomUUID(),
8613
- command: type,
8614
- payload: message.payload,
8615
- issuedAt: new Date().toISOString()
8616
- });
8617
- return sent ? { ok: true, inputSocket: false } : { ok: false, error: 'device-not-connected' };
8618
- }
8619
-
8620
- function beginControlSession(device, ownerConnectionId, inputWriteOwner, bindingKey) {
8621
- const owner = safeString(ownerConnectionId, 128);
8622
- if (!device || !owner) return { ok: false, error: 'control-owner-required' };
8623
- if (device.controlOwnerConnectionId === owner && device.controlOwnerSessionId) {
8624
- return { ok: true, alreadyActive: true };
8625
- }
8626
- device.controlOwnerSessionId = crypto.randomUUID();
8627
- device.controlOwnerConnectionId = owner;
8628
- device.controlOwnerStartedAtMs = Date.now();
8629
- device.controlOwnerLastInputAtMs = Date.now();
8630
- const sent = sendControlSessionMessage(
8631
- device,
8632
- owner,
8633
- 'control.session.start',
8634
- 'browser-control-owner-active',
8635
- inputWriteOwner,
8636
- bindingKey);
8637
- if (!sent.ok) {
8638
- device.controlOwnerSessionId = '';
8639
- device.controlOwnerConnectionId = '';
8640
- device.controlOwnerStartedAtMs = 0;
8641
- device.controlOwnerLastInputAtMs = 0;
8642
- return sent;
8643
- }
8644
- emitRemoteEvent('RemoteControlSessionStarted', device, {
8645
- controlOwnerSessionId: device.controlOwnerSessionId,
8646
- hubConnectionId: owner
8647
- });
8648
- return sent;
8649
- }
8650
-
8651
- function endControlSession(device, ownerConnectionId, reason, inputWriteOwner, bindingKey) {
8652
- const owner = safeString(ownerConnectionId, 128);
8653
- if (!device?.controlOwnerSessionId
8654
- || !owner
8655
- || device.controlOwnerConnectionId !== owner) {
8656
- return { ok: false, error: 'control-owner-not-current' };
8657
- }
8658
- const controlOwnerSessionId = device.controlOwnerSessionId;
8659
- const sent = sendControlSessionMessage(
8660
- device,
8661
- owner,
8662
- 'control.session.stop',
8663
- reason,
8664
- inputWriteOwner,
8665
- bindingKey);
8666
- device.controlOwnerSessionId = '';
8667
- device.controlOwnerConnectionId = '';
8668
- device.controlOwnerStartedAtMs = 0;
8669
- device.controlOwnerLastInputAtMs = 0;
8670
- emitRemoteEvent('RemoteControlSessionEnded', device, {
8671
- controlOwnerSessionId,
8672
- hubConnectionId: owner,
8673
- reason,
8674
- delivered: sent.ok === true
8675
- });
8676
- return sent;
8677
- }
8678
-
8679
- function sweepIdleControlSessions() {
8680
- const nowMs = Date.now();
8681
- for (const device of devices.values()) {
8682
- if (!device?.connected || !device.controlOwnerConnectionId) continue;
8683
- const policy = getDevicePolicy(device);
8684
- if (policy.disconnectIdleControlSessions !== true) continue;
8685
- const idleMs = clampNumber(policy.idleControlMinutes, 5, 1440, 30) * 60 * 1000;
8686
- if (nowMs - Number(device.controlOwnerLastInputAtMs || 0) < idleMs) continue;
8687
- releaseInputOwner(device.deviceId, device.controlOwnerConnectionId, 'control-idle-timeout');
8688
- }
8689
- }
8636
+ };
8637
+ }
8638
+
8639
+ function controlSessionMessage(device, ownerConnectionId, type, reason = '') {
8640
+ const policy = getDevicePolicy(device);
8641
+ return {
8642
+ type,
8643
+ payload: {
8644
+ controlOwnerSessionId: safeString(device?.controlOwnerSessionId, 160),
8645
+ hubConnectionId: safeString(ownerConnectionId, 128),
8646
+ deviceId: safeString(device?.deviceId, 160),
8647
+ visibleIndicator: true,
8648
+ lockOnEnd: type === 'control.session.stop' && policy.lockOnControlEnd === true,
8649
+ idleControlMinutes: clampNumber(policy.idleControlMinutes, 5, 1440, 30),
8650
+ reason: safeString(reason, 160),
8651
+ issuedAt: new Date().toISOString()
8652
+ }
8653
+ };
8654
+ }
8655
+
8656
+ function sendControlSessionMessage(device, ownerConnectionId, type, reason, inputWriteOwner, bindingKey) {
8657
+ const message = controlSessionMessage(device, ownerConnectionId, type, reason);
8658
+ if (inputWriteOwner && bindingKey) {
8659
+ return inputWriteOwner.enqueue(message, bindingKey);
8660
+ }
8661
+ if (!device?.socket || device.socket.destroyed) {
8662
+ return { ok: false, error: 'device-not-connected' };
8663
+ }
8664
+ const sent = writeJsonLine(device.socket, {
8665
+ type: 'command',
8666
+ commandId: crypto.randomUUID(),
8667
+ command: type,
8668
+ payload: message.payload,
8669
+ issuedAt: new Date().toISOString()
8670
+ });
8671
+ return sent ? { ok: true, inputSocket: false } : { ok: false, error: 'device-not-connected' };
8672
+ }
8673
+
8674
+ function beginControlSession(device, ownerConnectionId, inputWriteOwner, bindingKey) {
8675
+ const owner = safeString(ownerConnectionId, 128);
8676
+ if (!device || !owner) return { ok: false, error: 'control-owner-required' };
8677
+ if (device.controlOwnerConnectionId === owner && device.controlOwnerSessionId) {
8678
+ return { ok: true, alreadyActive: true };
8679
+ }
8680
+ device.controlOwnerSessionId = crypto.randomUUID();
8681
+ device.controlOwnerConnectionId = owner;
8682
+ device.controlOwnerStartedAtMs = Date.now();
8683
+ device.controlOwnerLastInputAtMs = Date.now();
8684
+ const sent = sendControlSessionMessage(
8685
+ device,
8686
+ owner,
8687
+ 'control.session.start',
8688
+ 'browser-control-owner-active',
8689
+ inputWriteOwner,
8690
+ bindingKey);
8691
+ if (!sent.ok) {
8692
+ device.controlOwnerSessionId = '';
8693
+ device.controlOwnerConnectionId = '';
8694
+ device.controlOwnerStartedAtMs = 0;
8695
+ device.controlOwnerLastInputAtMs = 0;
8696
+ return sent;
8697
+ }
8698
+ emitRemoteEvent('RemoteControlSessionStarted', device, {
8699
+ controlOwnerSessionId: device.controlOwnerSessionId,
8700
+ hubConnectionId: owner
8701
+ });
8702
+ return sent;
8703
+ }
8704
+
8705
+ function endControlSession(device, ownerConnectionId, reason, inputWriteOwner, bindingKey) {
8706
+ const owner = safeString(ownerConnectionId, 128);
8707
+ if (!device?.controlOwnerSessionId
8708
+ || !owner
8709
+ || device.controlOwnerConnectionId !== owner) {
8710
+ return { ok: false, error: 'control-owner-not-current' };
8711
+ }
8712
+ const controlOwnerSessionId = device.controlOwnerSessionId;
8713
+ const sent = sendControlSessionMessage(
8714
+ device,
8715
+ owner,
8716
+ 'control.session.stop',
8717
+ reason,
8718
+ inputWriteOwner,
8719
+ bindingKey);
8720
+ device.controlOwnerSessionId = '';
8721
+ device.controlOwnerConnectionId = '';
8722
+ device.controlOwnerStartedAtMs = 0;
8723
+ device.controlOwnerLastInputAtMs = 0;
8724
+ emitRemoteEvent('RemoteControlSessionEnded', device, {
8725
+ controlOwnerSessionId,
8726
+ hubConnectionId: owner,
8727
+ reason,
8728
+ delivered: sent.ok === true
8729
+ });
8730
+ return sent;
8731
+ }
8732
+
8733
+ function sweepIdleControlSessions() {
8734
+ const nowMs = Date.now();
8735
+ for (const device of devices.values()) {
8736
+ if (!device?.connected || !device.controlOwnerConnectionId) continue;
8737
+ const policy = getDevicePolicy(device);
8738
+ if (policy.disconnectIdleControlSessions !== true) continue;
8739
+ const idleMs = clampNumber(policy.idleControlMinutes, 5, 1440, 30) * 60 * 1000;
8740
+ if (nowMs - Number(device.controlOwnerLastInputAtMs || 0) < idleMs) continue;
8741
+ releaseInputOwner(device.deviceId, device.controlOwnerConnectionId, 'control-idle-timeout');
8742
+ }
8743
+ }
8690
8744
 
8691
8745
  function getCurrentInputWriteOwner(device) {
8692
8746
  if (!device?.inputSocket
@@ -8829,16 +8883,16 @@ export function createRemoteHub(options = {}) {
8829
8883
  normalized,
8830
8884
  activeMonitorIndex);
8831
8885
  const previousInputBindingKey = String(device.inputOwnerBindingKey || '');
8832
- const ownerChanged = normalized.hubConnectionId
8886
+ const ownerChanged = normalized.hubConnectionId
8833
8887
  && previousOwnerConnectionId
8834
8888
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8835
8889
  const bindingChanged = previousInputBindingKey
8836
8890
  && previousInputBindingKey !== activeInputBindingKey;
8837
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8838
- const startingControlOwner = !!normalized.hubConnectionId
8839
- && (!device.controlOwnerConnectionId
8840
- || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8841
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8891
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8892
+ const startingControlOwner = !!normalized.hubConnectionId
8893
+ && (!device.controlOwnerConnectionId
8894
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8895
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
8842
8896
  const resetSent = inputWriteOwner.replaceBinding(
8843
8897
  activeInputBindingKey,
8844
8898
  buildRemoteInputResetMessage(
@@ -8853,28 +8907,28 @@ export function createRemoteHub(options = {}) {
8853
8907
  activeMonitorIndex));
8854
8908
  if (!resetSent.ok) {
8855
8909
  closeInputSocket(device, resetSent.error || 'input-owner-reset-write-failed');
8856
- return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8857
- }
8858
- if (startingControlOwner) {
8859
- if (device.controlOwnerConnectionId) {
8860
- endControlSession(
8861
- device,
8862
- device.controlOwnerConnectionId,
8863
- 'browser-input-owner-changed',
8864
- inputWriteOwner,
8865
- activeInputBindingKey);
8866
- }
8867
- const started = beginControlSession(
8868
- device,
8869
- normalized.hubConnectionId,
8870
- inputWriteOwner,
8871
- activeInputBindingKey);
8872
- if (!started.ok) {
8873
- closeInputSocket(device, started.error || 'control-session-indicator-start-failed');
8874
- return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8875
- }
8876
- }
8877
- }
8910
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8911
+ }
8912
+ if (startingControlOwner) {
8913
+ if (device.controlOwnerConnectionId) {
8914
+ endControlSession(
8915
+ device,
8916
+ device.controlOwnerConnectionId,
8917
+ 'browser-input-owner-changed',
8918
+ inputWriteOwner,
8919
+ activeInputBindingKey);
8920
+ }
8921
+ const started = beginControlSession(
8922
+ device,
8923
+ normalized.hubConnectionId,
8924
+ inputWriteOwner,
8925
+ activeInputBindingKey);
8926
+ if (!started.ok) {
8927
+ closeInputSocket(device, started.error || 'control-session-indicator-start-failed');
8928
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8929
+ }
8930
+ }
8931
+ }
8878
8932
  const sent = inputWriteOwner.enqueue({
8879
8933
  type: 'input.control',
8880
8934
  payload: {
@@ -8887,8 +8941,8 @@ export function createRemoteHub(options = {}) {
8887
8941
  if (normalized.hubConnectionId) {
8888
8942
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8889
8943
  }
8890
- device.inputOwnerBindingKey = activeInputBindingKey;
8891
- device.controlOwnerLastInputAtMs = Date.now();
8944
+ device.inputOwnerBindingKey = activeInputBindingKey;
8945
+ device.controlOwnerLastInputAtMs = Date.now();
8892
8946
  device.counters.commandsSent += 1;
8893
8947
  device.inputLastSeenAt = new Date().toISOString();
8894
8948
  return {
@@ -8917,27 +8971,27 @@ export function createRemoteHub(options = {}) {
8917
8971
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8918
8972
  }
8919
8973
 
8920
- const hubForwardedAtEpochMs = Date.now();
8921
- const startingFallbackOwner = !!normalized.hubConnectionId
8922
- && (!device.controlOwnerConnectionId
8923
- || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8924
- if (startingFallbackOwner) {
8925
- if (device.controlOwnerConnectionId) {
8926
- endControlSession(
8927
- device,
8928
- device.controlOwnerConnectionId,
8929
- 'browser-input-owner-changed',
8930
- null,
8931
- '');
8932
- }
8933
- const started = beginControlSession(
8934
- device,
8935
- normalized.hubConnectionId,
8936
- null,
8937
- '');
8938
- if (!started.ok) return started;
8939
- }
8940
- const fallback = sendCommand(deviceId, {
8974
+ const hubForwardedAtEpochMs = Date.now();
8975
+ const startingFallbackOwner = !!normalized.hubConnectionId
8976
+ && (!device.controlOwnerConnectionId
8977
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8978
+ if (startingFallbackOwner) {
8979
+ if (device.controlOwnerConnectionId) {
8980
+ endControlSession(
8981
+ device,
8982
+ device.controlOwnerConnectionId,
8983
+ 'browser-input-owner-changed',
8984
+ null,
8985
+ '');
8986
+ }
8987
+ const started = beginControlSession(
8988
+ device,
8989
+ normalized.hubConnectionId,
8990
+ null,
8991
+ '');
8992
+ if (!started.ok) return started;
8993
+ }
8994
+ const fallback = sendCommand(deviceId, {
8941
8995
  command: 'input.control',
8942
8996
  payload: {
8943
8997
  ...normalized,
@@ -8945,7 +8999,7 @@ export function createRemoteHub(options = {}) {
8945
8999
  issuedAt: normalized.issuedAt || new Date().toISOString()
8946
9000
  }
8947
9001
  });
8948
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9002
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8949
9003
  schedulePendingInputFallback(device, {
8950
9004
  commandId: fallback.commandId,
8951
9005
  inputSeq: normalized.inputSeq,
@@ -8957,26 +9011,26 @@ export function createRemoteHub(options = {}) {
8957
9011
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8958
9012
  hubForwardedAtEpochMs,
8959
9013
  fallback: true,
8960
- timeoutTimer: null
8961
- });
8962
- }
8963
- if (fallback.ok) {
8964
- device.inputOwnerConnectionId = normalized.hubConnectionId;
8965
- device.inputOwnerBindingKey = buildRemoteInputBindingKey(
8966
- device,
8967
- controlStream,
8968
- normalized,
8969
- activeMonitorIndex);
8970
- device.controlOwnerLastInputAtMs = Date.now();
8971
- } else if (startingFallbackOwner) {
8972
- endControlSession(
8973
- device,
8974
- normalized.hubConnectionId,
8975
- 'input-control-delivery-failed',
8976
- null,
8977
- '');
8978
- }
8979
- return fallback.ok
9014
+ timeoutTimer: null
9015
+ });
9016
+ }
9017
+ if (fallback.ok) {
9018
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
9019
+ device.inputOwnerBindingKey = buildRemoteInputBindingKey(
9020
+ device,
9021
+ controlStream,
9022
+ normalized,
9023
+ activeMonitorIndex);
9024
+ device.controlOwnerLastInputAtMs = Date.now();
9025
+ } else if (startingFallbackOwner) {
9026
+ endControlSession(
9027
+ device,
9028
+ normalized.hubConnectionId,
9029
+ 'input-control-delivery-failed',
9030
+ null,
9031
+ '');
9032
+ }
9033
+ return fallback.ok
8980
9034
  ? {
8981
9035
  ...fallback,
8982
9036
  inputSocket: false,
@@ -9002,20 +9056,20 @@ export function createRemoteHub(options = {}) {
9002
9056
  return { ok: false, error: 'input-owner-not-current' };
9003
9057
  }
9004
9058
 
9005
- const inputSocket = device.inputSocket;
9006
- const inputWriteOwner = getCurrentInputWriteOwner(device);
9007
- if (!inputSocket || !inputWriteOwner) {
9008
- const ended = endControlSession(device, owner, reason, null, '');
9009
- device.inputOwnerConnectionId = '';
9010
- device.inputOwnerBindingKey = '';
9011
- return {
9012
- ok: ended.ok !== false,
9013
- queued: false,
9014
- controlSessionEnded: ended.ok === true
9015
- };
9016
- }
9017
- const controlStream = getActiveControlStream(device);
9018
- const releaseBindingKey = buildRemoteInputReleaseBindingKey(device, owner);
9059
+ const inputSocket = device.inputSocket;
9060
+ const inputWriteOwner = getCurrentInputWriteOwner(device);
9061
+ if (!inputSocket || !inputWriteOwner) {
9062
+ const ended = endControlSession(device, owner, reason, null, '');
9063
+ device.inputOwnerConnectionId = '';
9064
+ device.inputOwnerBindingKey = '';
9065
+ return {
9066
+ ok: ended.ok !== false,
9067
+ queued: false,
9068
+ controlSessionEnded: ended.ok === true
9069
+ };
9070
+ }
9071
+ const controlStream = getActiveControlStream(device);
9072
+ const releaseBindingKey = buildRemoteInputReleaseBindingKey(device, owner);
9019
9073
  const sent = inputWriteOwner.replaceBinding(
9020
9074
  releaseBindingKey,
9021
9075
  buildRemoteInputResetMessage(
@@ -9024,24 +9078,24 @@ export function createRemoteHub(options = {}) {
9024
9078
  owner,
9025
9079
  reason,
9026
9080
  normalizeMonitorIndex(controlStream?.monitorIndex)));
9027
- if (!sent.ok) {
9081
+ if (!sent.ok) {
9028
9082
  closeInputSocket(device, sent.error || 'input-owner-release-write-failed');
9029
9083
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
9030
- }
9031
- const ended = endControlSession(
9032
- device,
9033
- owner,
9034
- reason,
9035
- inputWriteOwner,
9036
- releaseBindingKey);
9037
- device.inputOwnerConnectionId = '';
9038
- device.inputOwnerBindingKey = '';
9039
- return {
9040
- ok: ended.ok !== false,
9041
- queued: true,
9042
- backpressured: sent.backpressured === true || ended.backpressured === true,
9043
- controlSessionEnded: ended.ok === true
9044
- };
9084
+ }
9085
+ const ended = endControlSession(
9086
+ device,
9087
+ owner,
9088
+ reason,
9089
+ inputWriteOwner,
9090
+ releaseBindingKey);
9091
+ device.inputOwnerConnectionId = '';
9092
+ device.inputOwnerBindingKey = '';
9093
+ return {
9094
+ ok: ended.ok !== false,
9095
+ queued: true,
9096
+ backpressured: sent.backpressured === true || ended.backpressured === true,
9097
+ controlSessionEnded: ended.ok === true
9098
+ };
9045
9099
  }
9046
9100
 
9047
9101
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -9081,12 +9135,12 @@ export function createRemoteHub(options = {}) {
9081
9135
  if (retiredRequestError) {
9082
9136
  return { ok: false, error: retiredRequestError };
9083
9137
  }
9084
- const device = devices.get(String(deviceId || ''));
9085
- const requestedOperation = safeString(options.operation, 80);
9086
- if (RETIRED_AGENT_MUTATING_OPERATIONS.has(requestedOperation.toLowerCase())) {
9087
- return { ok: false, error: 'agent-mutating-tool-disabled' };
9088
- }
9089
- const operation = normalizeAgentOperation(requestedOperation);
9138
+ const device = devices.get(String(deviceId || ''));
9139
+ const requestedOperation = safeString(options.operation, 80);
9140
+ if (RETIRED_AGENT_MUTATING_OPERATIONS.has(requestedOperation.toLowerCase())) {
9141
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
9142
+ }
9143
+ const operation = normalizeAgentOperation(requestedOperation);
9090
9144
  if (requestedOperation && !operation) {
9091
9145
  return { ok: false, error: 'unsupported-agent-operation' };
9092
9146
  }
@@ -11332,10 +11386,10 @@ export function createRemoteHub(options = {}) {
11332
11386
  retryTaskBatch,
11333
11387
  listDeviceFrames,
11334
11388
  disconnectDevice,
11335
- assignDeviceSlot,
11336
- sendCommand,
11337
- sendCommandAwaitResult,
11338
- refreshDevicePolicies,
11389
+ assignDeviceSlot,
11390
+ sendCommand,
11391
+ sendCommandAwaitResult,
11392
+ refreshDevicePolicies,
11339
11393
  sendLegacyClientUpdate,
11340
11394
  sendInputControl,
11341
11395
  releaseInputOwner,
@@ -11362,17 +11416,17 @@ export function createRemoteHub(options = {}) {
11362
11416
  handleUdpFrame,
11363
11417
  seedSyntheticFleet,
11364
11418
  clearSyntheticFleet,
11365
- getPairToken: () => pairToken,
11366
- getPairingPin: () => pairingPin,
11367
- getSecurityStatus: () => ({
11368
- hubId: deviceCredentialAuthority.hubId,
11369
- hubPublicKey: deviceCredentialAuthority.hubPublicKey,
11370
- hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
11371
- direct: secureDirectAcceptor.getStatus(),
11372
- devices: deviceCredentialAuthority.listDevices()
11373
- }),
11374
- revokeDeviceCredential: (deviceId, reason) => deviceCredentialAuthority.revokeDevice(deviceId, reason),
11375
- clearDeviceCredentials: () => deviceCredentialAuthority.clearDevices()
11419
+ getPairToken: () => pairToken,
11420
+ getPairingPin: () => pairingPin,
11421
+ getSecurityStatus: () => ({
11422
+ hubId: deviceCredentialAuthority.hubId,
11423
+ hubPublicKey: deviceCredentialAuthority.hubPublicKey,
11424
+ hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
11425
+ direct: secureDirectAcceptor.getStatus(),
11426
+ devices: deviceCredentialAuthority.listDevices()
11427
+ }),
11428
+ revokeDeviceCredential: (deviceId, reason) => deviceCredentialAuthority.revokeDevice(deviceId, reason),
11429
+ clearDeviceCredentials: () => deviceCredentialAuthority.clearDevices()
11376
11430
  };
11377
11431
  }
11378
11432