@livedesk/hub 0.1.30 → 0.1.32

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,7 +1,10 @@
1
1
  import net from 'net';
2
2
  import os from 'os';
3
- import crypto from 'crypto';
4
- import { createHubRelayControl } from './transport/relay-hub-control.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';
5
8
  import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
6
9
  import {
7
10
  BoundedSegmentedBuffer,
@@ -32,9 +35,10 @@ const MAX_AGENT_TCP_BINARY_PACKET_BYTES = TCP_BINARY_FRAME_HEADER_BYTES
32
35
  const MAX_AGENT_WEBSOCKET_MESSAGE_BYTES = 4
33
36
  + MAX_AGENT_BINARY_META_BYTES
34
37
  + MAX_AGENT_BINARY_PAYLOAD_BYTES;
35
- const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
36
- + MAX_AGENT_TCP_BINARY_PACKET_BYTES
37
- + (256 * 1024);
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
42
  const HTTP_HEADER_TERMINATOR = Buffer.from('\r\n\r\n', 'ascii');
39
43
  const MAX_AGENT_TASK_CHARS = 4000;
40
44
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
@@ -106,7 +110,7 @@ const MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS = Object.freeze([
106
110
  'handoffOldestFrameAgeMs'
107
111
  ]);
108
112
 
109
- export function hasCompleteMacNativeResourceTelemetry(frame) {
113
+ export function hasCompleteMacNativeResourceTelemetry(frame) {
110
114
  return Number(frame?.nativeCaptureTelemetryVersion) >= MAC_NATIVE_RESOURCE_TELEMETRY_VERSION
111
115
  && MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS.every(field =>
112
116
  frame?.[field] !== null
@@ -824,14 +828,61 @@ function isPrivateIPv4(value) {
824
828
  || (parts[0] === 192 && parts[1] === 168);
825
829
  }
826
830
 
827
- function isPrivateNetworkAddress(value) {
831
+ function isPrivateNetworkAddress(value) {
828
832
  const address = String(value || '').replace(/^::ffff:/i, '').trim().toLowerCase();
829
833
  return isLoopbackHost(address)
830
834
  || isPrivateIPv4(address)
831
835
  || address.startsWith('fe80:')
832
836
  || address.startsWith('fc')
833
- || address.startsWith('fd');
834
- }
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
+ }
835
886
 
836
887
  function readCpuTimes() {
837
888
  let idle = 0;
@@ -1155,34 +1206,37 @@ function getRetiredAgentTaskError(options = {}) {
1155
1206
  : '';
1156
1207
  }
1157
1208
 
1158
- const SUPPORTED_AGENT_OPERATIONS = new Set([
1159
- 'system.health',
1160
- 'gpu.status',
1161
- 'disk.status',
1162
- 'process.list',
1163
- 'service.status',
1164
- 'diagnostics.collect',
1165
- 'process.control',
1166
- 'service.control',
1167
- 'application.launch',
1168
- 'application.close',
1169
- 'file.read',
1170
- 'file.write',
1171
- 'file.delete',
1172
- 'file.list',
1173
- 'command.run',
1174
- 'script.run',
1175
- 'software.install',
1176
- 'network.status',
1177
- 'system.power',
1178
- 'system.configure',
1179
- 'logs.collect'
1180
- ]);
1181
-
1182
- function normalizeAgentOperation(value) {
1183
- const operation = safeString(value, 80).toLowerCase();
1184
- return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1185
- }
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
+ }
1186
1240
 
1187
1241
  const REMOTE_INPUT_MONITOR_KEYS = Object.freeze([
1188
1242
  'monitorIndex',
@@ -1348,7 +1402,7 @@ function buildRemoteFramePath(deviceId, frameKind, frame) {
1348
1402
  return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
1349
1403
  }
1350
1404
 
1351
- function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1405
+ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1352
1406
  if (!frame) {
1353
1407
  return null;
1354
1408
  }
@@ -1370,8 +1424,27 @@ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1370
1424
  serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
1371
1425
  }
1372
1426
 
1373
- return serialized;
1374
- }
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
+ }
1375
1448
 
1376
1449
  function getRecentFrameCache(device, frameKind) {
1377
1450
  if (!device) {
@@ -1562,7 +1635,7 @@ function serializeDevice(device, options = {}) {
1562
1635
  },
1563
1636
  latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1564
1637
  latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1565
- activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1638
+ activeLiveStream: serializeActiveLiveStream(device.activeLiveStream, device.deviceId),
1566
1639
  liveCapturePause: device.liveCapturePause
1567
1640
  ? {
1568
1641
  token: device.liveCapturePause.token,
@@ -2169,7 +2242,7 @@ export function createRemoteHub(options = {}) {
2169
2242
  : null;
2170
2243
  const sampleHubRuntimeDiagnostics = createHubRuntimeDiagnosticsSampler();
2171
2244
 
2172
- function getDevicePolicy(device) {
2245
+ function getDevicePolicy(device) {
2173
2246
  try {
2174
2247
  return getEffectiveDevicePolicy({
2175
2248
  deviceId: device?.deviceId || '',
@@ -2177,8 +2250,18 @@ export function createRemoteHub(options = {}) {
2177
2250
  }) || {};
2178
2251
  } catch {
2179
2252
  return {};
2180
- }
2181
- }
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
+ }
2182
2265
 
2183
2266
  function policyError(device, permission = '', command = '') {
2184
2267
  const policy = getDevicePolicy(device);
@@ -2228,22 +2311,83 @@ export function createRemoteHub(options = {}) {
2228
2311
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
2229
2312
  const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
2230
2313
  const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
2231
- const pairToken = safeString(
2232
- options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
2233
- 256);
2234
- const relayControl = options.relayControl && typeof options.relayControl === 'object'
2235
- ? options.relayControl
2236
- : createHubRelayControl({
2237
- env,
2238
- pairToken,
2239
- logEvent,
2240
- logWarn,
2241
- onPeerSocket: socket => handleTcpAgentSocket(socket)
2242
- });
2243
- let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
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())
2244
2345
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2245
2346
  : generatePairingPin();
2246
- let pairingPinUpdatedAt = new Date().toISOString();
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
+ });
2247
2391
  const duplicateDeviceLogThrottleMs = clampNumber(
2248
2392
  env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
2249
2393
  5000,
@@ -2268,7 +2412,8 @@ export function createRemoteHub(options = {}) {
2268
2412
  const transportDiagnostics = new Map();
2269
2413
  const transportDiagnosticStartingDevices = new Set();
2270
2414
  const duplicateDeviceLogAt = new Map();
2271
- let server = null;
2415
+ let server = null;
2416
+ let controlIdleSweepTimer = null;
2272
2417
  let started = false;
2273
2418
  let boundPort = requestedPort;
2274
2419
  let lastError = '';
@@ -2590,8 +2735,8 @@ export function createRemoteHub(options = {}) {
2590
2735
  host,
2591
2736
  agentHost: routeInfo.host || getAnnouncedHost(),
2592
2737
  port: boundPort || requestedPort,
2593
- protocol: 'tcp-jsonl',
2594
- protocolVersion: REMOTE_PROTOCOL_VERSION,
2738
+ protocol: 'secure-record-v1',
2739
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2595
2740
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2596
2741
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2597
2742
  frameModes: getSupportedRemoteFrameModeProfiles(),
@@ -2607,9 +2752,18 @@ export function createRemoteHub(options = {}) {
2607
2752
  agentEndpointCandidates: routeInfo.candidates,
2608
2753
  agentEndpointCandidateDetails: routeInfo.candidateDetails,
2609
2754
  pairToken: includeSecrets ? pairToken : undefined,
2610
- pairTokenPreview: maskToken(pairToken),
2611
- pairingPin: includeSecrets ? pairingPin : '',
2612
- pairingPinUpdatedAt,
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
+ },
2613
2767
  deviceCount: devices.size,
2614
2768
  connectedDeviceCount: connectedDevices,
2615
2769
  canvasDeviceListMode: 'all-devices',
@@ -3869,14 +4023,22 @@ export function createRemoteHub(options = {}) {
3869
4023
  }
3870
4024
  }
3871
4025
 
3872
- function closeInputSocket(device, reason = 'input-socket-closed') {
4026
+ function closeInputSocket(device, reason = 'input-socket-closed') {
3873
4027
  const socket = device?.inputSocket;
3874
4028
  if (!socket) {
3875
4029
  return;
3876
4030
  }
3877
-
3878
- const writeOwner = device.inputWriteOwner;
3879
- device.inputSocket = null;
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;
3880
4042
  device.inputWriteOwner = null;
3881
4043
  device.inputSocketConnectionId = '';
3882
4044
  device.inputOwnerConnectionId = '';
@@ -3906,9 +4068,17 @@ export function createRemoteHub(options = {}) {
3906
4068
  if (!device || device.inputSocket !== socket) {
3907
4069
  return;
3908
4070
  }
3909
-
3910
- const writeOwner = device.inputWriteOwner;
3911
- device.inputSocket = null;
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;
3912
4082
  device.inputWriteOwner = null;
3913
4083
  device.inputSocketConnectionId = '';
3914
4084
  device.inputOwnerConnectionId = '';
@@ -4340,7 +4510,11 @@ export function createRemoteHub(options = {}) {
4340
4510
  inputWriteOwner: null,
4341
4511
  inputSocketConnectionId: '',
4342
4512
  inputOwnerConnectionId: '',
4343
- inputOwnerBindingKey: '',
4513
+ inputOwnerBindingKey: '',
4514
+ controlOwnerSessionId: '',
4515
+ controlOwnerConnectionId: '',
4516
+ controlOwnerStartedAtMs: 0,
4517
+ controlOwnerLastInputAtMs: 0,
4344
4518
  frameSocket: null,
4345
4519
  audioSocket: null,
4346
4520
  fileSocket: null,
@@ -7261,8 +7435,14 @@ export function createRemoteHub(options = {}) {
7261
7435
  return;
7262
7436
  }
7263
7437
 
7264
- if (!state.authenticated) {
7265
- if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
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) {
7266
7446
  message = {
7267
7447
  ...message,
7268
7448
  capabilities: {
@@ -7272,13 +7452,14 @@ export function createRemoteHub(options = {}) {
7272
7452
  dedicatedInputChannel: false
7273
7453
  }
7274
7454
  };
7275
- }
7276
- if (message.type === 'slot.assign') {
7277
- if (!timingSafeStringEqual(message.pairToken, pairToken)) {
7278
- writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'invalid-pair-token' });
7279
- socket.end();
7280
- return;
7281
- }
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
+ }
7282
7463
  const result = assignDeviceSlot(
7283
7464
  message.deviceId || message.DeviceId,
7284
7465
  message.slotNumber ?? message.slot ?? message.SlotNumber);
@@ -7292,13 +7473,54 @@ export function createRemoteHub(options = {}) {
7292
7473
  return;
7293
7474
  }
7294
7475
 
7295
- if (!timingSafeStringEqual(message.pairToken, pairToken)) {
7296
- writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
7297
- socket.destroy();
7298
- return;
7299
- }
7300
-
7301
- const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
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();
7302
7524
  if (channel === 'input') {
7303
7525
  const device = attachInputSocket(socket, message);
7304
7526
  if (!device) {
@@ -7465,7 +7687,27 @@ export function createRemoteHub(options = {}) {
7465
7687
  }
7466
7688
  }
7467
7689
 
7468
- function handleWebSocketAgentSocket(socket) {
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) {
7469
7711
  allSockets.add(socket);
7470
7712
  socket.setNoDelay(true);
7471
7713
  socket.setKeepAlive(true, heartbeatMs);
@@ -7479,10 +7721,11 @@ export function createRemoteHub(options = {}) {
7479
7721
  audioOnly: false,
7480
7722
  fileOnly: false,
7481
7723
  parserBuffer: socket.buffer,
7482
- binaryIngress: null,
7483
- binaryQueueDrops: 0,
7484
- binaryQueueEpoch: crypto.randomUUID(),
7485
- closed: false
7724
+ binaryIngress: null,
7725
+ binaryQueueDrops: 0,
7726
+ binaryQueueEpoch: crypto.randomUUID(),
7727
+ messageRateGate: createRemoteMessageRateGate(),
7728
+ closed: false
7486
7729
  };
7487
7730
  attachAgentBinaryIngress(socket, state);
7488
7731
 
@@ -7493,8 +7736,9 @@ export function createRemoteHub(options = {}) {
7493
7736
  }
7494
7737
  }, 10000);
7495
7738
 
7496
- socket.onTextMessage = text => {
7497
- try {
7739
+ socket.onTextMessage = text => {
7740
+ if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7741
+ try {
7498
7742
  handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
7499
7743
  } catch (err) {
7500
7744
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
@@ -7502,8 +7746,9 @@ export function createRemoteHub(options = {}) {
7502
7746
  }
7503
7747
  };
7504
7748
 
7505
- socket.onBinaryMessage = payload => {
7506
- try {
7749
+ socket.onBinaryMessage = payload => {
7750
+ if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7751
+ try {
7507
7752
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
7508
7753
  if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
7509
7754
  writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
@@ -7658,10 +7903,11 @@ export function createRemoteHub(options = {}) {
7658
7903
  buffer: new BoundedSegmentedBuffer(MAX_AGENT_SOCKET_ACCUMULATOR_BYTES),
7659
7904
  lineScanOffset: 0,
7660
7905
  pendingBinaryFrame: null,
7661
- binaryIngress: null,
7662
- binaryQueueDrops: 0,
7663
- binaryQueueEpoch: crypto.randomUUID(),
7664
- closed: false
7906
+ binaryIngress: null,
7907
+ binaryQueueDrops: 0,
7908
+ binaryQueueEpoch: crypto.randomUUID(),
7909
+ messageRateGate: createRemoteMessageRateGate(),
7910
+ closed: false
7665
7911
  };
7666
7912
  attachAgentBinaryIngress(socket, state);
7667
7913
 
@@ -7776,9 +8022,18 @@ export function createRemoteHub(options = {}) {
7776
8022
  return;
7777
8023
  }
7778
8024
 
7779
- try {
7780
- const message = parseJsonLine(lineBuffer.toString('utf8'));
7781
- if (message?.type === 'frame.binary') {
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') {
7782
8037
  const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
7783
8038
  const maxBytes = frameKind === 'thumbnail'
7784
8039
  ? MAX_THUMBNAIL_BINARY_BYTES
@@ -7832,16 +8087,35 @@ export function createRemoteHub(options = {}) {
7832
8087
  }
7833
8088
  }
7834
8089
 
7835
- function handleSocket(socket) {
7836
- socket.once('data', chunk => {
7837
- const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
7838
- if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
7839
- handleWebSocketUpgradeSocket(socket, firstChunk);
7840
- return;
7841
- }
7842
-
7843
- handleTcpAgentSocket(socket, firstChunk);
7844
- });
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
+ });
7845
8119
 
7846
8120
  socket.once('error', () => {
7847
8121
  // The transport-specific handler owns logging after the first byte.
@@ -7863,10 +8137,10 @@ export function createRemoteHub(options = {}) {
7863
8137
  started = true;
7864
8138
  boundPort = candidateServer.address()?.port || port;
7865
8139
  lastError = '';
7866
- logEvent('remote', `LiveDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
7867
- if (host === '0.0.0.0' || host === '::') {
7868
- logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
7869
- }
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
+ }
7870
8144
  emitRemoteEvent('RemoteHubStarted', null);
7871
8145
  resolve();
7872
8146
  });
@@ -7878,10 +8152,14 @@ export function createRemoteHub(options = {}) {
7878
8152
  return getStatus({ includeSecrets: false });
7879
8153
  }
7880
8154
 
7881
- try {
7882
- await udpTransport?.start?.();
7883
- await listenOnPort(requestedPort);
7884
- try {
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 {
7885
8163
  await relayControl?.start?.();
7886
8164
  } catch {
7887
8165
  logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
@@ -7902,8 +8180,12 @@ export function createRemoteHub(options = {}) {
7902
8180
  return getStatus({ includeSecrets: false });
7903
8181
  }
7904
8182
 
7905
- async function close() {
7906
- for (const state of [...agentBinaryIngressStates]) {
8183
+ async function close() {
8184
+ if (controlIdleSweepTimer) {
8185
+ clearInterval(controlIdleSweepTimer);
8186
+ controlIdleSweepTimer = null;
8187
+ }
8188
+ for (const state of [...agentBinaryIngressStates]) {
7907
8189
  closeAgentBinaryIngress(state, 'hub-shutdown');
7908
8190
  }
7909
8191
 
@@ -8040,7 +8322,7 @@ export function createRemoteHub(options = {}) {
8040
8322
  };
8041
8323
  }
8042
8324
 
8043
- function sendCommand(deviceId, command) {
8325
+ function sendCommand(deviceId, command) {
8044
8326
  const device = devices.get(String(deviceId || ''));
8045
8327
  const commandName = safeString(command?.command || 'ping', 80);
8046
8328
  const requiredPermission = commandName === 'input.control'
@@ -8106,10 +8388,64 @@ export function createRemoteHub(options = {}) {
8106
8388
  command: payload.command,
8107
8389
  channel: dedicatedFileSocket ? 'file' : 'control'
8108
8390
  });
8109
- return { ok: true, commandId };
8110
- }
8111
-
8112
- function refreshDevicePolicies(deviceIds = undefined) {
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) {
8113
8449
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8114
8450
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8115
8451
  : null;
@@ -8118,9 +8454,31 @@ export function createRemoteHub(options = {}) {
8118
8454
  for (const device of devices.values()) {
8119
8455
  if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8120
8456
  if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8121
- total += 1;
8122
- const effectivePolicy = getDevicePolicy(device);
8123
- if (writeJsonLine(device.socket, {
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, {
8124
8482
  type: 'policy.update',
8125
8483
  effectivePolicy,
8126
8484
  updatedAt: new Date().toISOString()
@@ -8199,7 +8557,7 @@ export function createRemoteHub(options = {}) {
8199
8557
  ]);
8200
8558
  }
8201
8559
 
8202
- function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8560
+ function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8203
8561
  return {
8204
8562
  type: 'input.control',
8205
8563
  payload: {
@@ -8221,8 +8579,114 @@ export function createRemoteHub(options = {}) {
8221
8579
  hubForwardedAtEpochMs: Date.now(),
8222
8580
  issuedAt: new Date().toISOString()
8223
8581
  }
8224
- };
8225
- }
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
+ }
8226
8690
 
8227
8691
  function getCurrentInputWriteOwner(device) {
8228
8692
  if (!device?.inputSocket
@@ -8365,13 +8829,16 @@ export function createRemoteHub(options = {}) {
8365
8829
  normalized,
8366
8830
  activeMonitorIndex);
8367
8831
  const previousInputBindingKey = String(device.inputOwnerBindingKey || '');
8368
- const ownerChanged = normalized.hubConnectionId
8832
+ const ownerChanged = normalized.hubConnectionId
8369
8833
  && previousOwnerConnectionId
8370
8834
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8371
8835
  const bindingChanged = previousInputBindingKey
8372
8836
  && previousInputBindingKey !== activeInputBindingKey;
8373
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8374
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8837
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8838
+ const startingControlOwner = !!normalized.hubConnectionId
8839
+ && (!device.controlOwnerConnectionId
8840
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8841
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
8375
8842
  const resetSent = inputWriteOwner.replaceBinding(
8376
8843
  activeInputBindingKey,
8377
8844
  buildRemoteInputResetMessage(
@@ -8386,9 +8853,28 @@ export function createRemoteHub(options = {}) {
8386
8853
  activeMonitorIndex));
8387
8854
  if (!resetSent.ok) {
8388
8855
  closeInputSocket(device, resetSent.error || 'input-owner-reset-write-failed');
8389
- return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8390
- }
8391
- }
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
+ }
8392
8878
  const sent = inputWriteOwner.enqueue({
8393
8879
  type: 'input.control',
8394
8880
  payload: {
@@ -8401,7 +8887,8 @@ export function createRemoteHub(options = {}) {
8401
8887
  if (normalized.hubConnectionId) {
8402
8888
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8403
8889
  }
8404
- device.inputOwnerBindingKey = activeInputBindingKey;
8890
+ device.inputOwnerBindingKey = activeInputBindingKey;
8891
+ device.controlOwnerLastInputAtMs = Date.now();
8405
8892
  device.counters.commandsSent += 1;
8406
8893
  device.inputLastSeenAt = new Date().toISOString();
8407
8894
  return {
@@ -8430,8 +8917,27 @@ export function createRemoteHub(options = {}) {
8430
8917
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8431
8918
  }
8432
8919
 
8433
- const hubForwardedAtEpochMs = Date.now();
8434
- const fallback = sendCommand(deviceId, {
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, {
8435
8941
  command: 'input.control',
8436
8942
  payload: {
8437
8943
  ...normalized,
@@ -8439,7 +8945,7 @@ export function createRemoteHub(options = {}) {
8439
8945
  issuedAt: normalized.issuedAt || new Date().toISOString()
8440
8946
  }
8441
8947
  });
8442
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8948
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8443
8949
  schedulePendingInputFallback(device, {
8444
8950
  commandId: fallback.commandId,
8445
8951
  inputSeq: normalized.inputSeq,
@@ -8451,10 +8957,26 @@ export function createRemoteHub(options = {}) {
8451
8957
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8452
8958
  hubForwardedAtEpochMs,
8453
8959
  fallback: true,
8454
- timeoutTimer: null
8455
- });
8456
- }
8457
- return fallback.ok
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
8458
8980
  ? {
8459
8981
  ...fallback,
8460
8982
  inputSocket: false,
@@ -8480,15 +9002,20 @@ export function createRemoteHub(options = {}) {
8480
9002
  return { ok: false, error: 'input-owner-not-current' };
8481
9003
  }
8482
9004
 
8483
- device.inputOwnerConnectionId = '';
8484
- device.inputOwnerBindingKey = '';
8485
- const inputSocket = device.inputSocket;
8486
- const inputWriteOwner = getCurrentInputWriteOwner(device);
8487
- if (!inputSocket || !inputWriteOwner) {
8488
- return { ok: true, queued: false };
8489
- }
8490
- const controlStream = getActiveControlStream(device);
8491
- const releaseBindingKey = buildRemoteInputReleaseBindingKey(device, owner);
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);
8492
9019
  const sent = inputWriteOwner.replaceBinding(
8493
9020
  releaseBindingKey,
8494
9021
  buildRemoteInputResetMessage(
@@ -8497,15 +9024,24 @@ export function createRemoteHub(options = {}) {
8497
9024
  owner,
8498
9025
  reason,
8499
9026
  normalizeMonitorIndex(controlStream?.monitorIndex)));
8500
- if (!sent.ok) {
9027
+ if (!sent.ok) {
8501
9028
  closeInputSocket(device, sent.error || 'input-owner-release-write-failed');
8502
9029
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8503
- }
8504
- return {
8505
- ok: true,
8506
- queued: true,
8507
- backpressured: sent.backpressured === true
8508
- };
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
+ };
8509
9045
  }
8510
9046
 
8511
9047
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -8545,9 +9081,12 @@ export function createRemoteHub(options = {}) {
8545
9081
  if (retiredRequestError) {
8546
9082
  return { ok: false, error: retiredRequestError };
8547
9083
  }
8548
- const device = devices.get(String(deviceId || ''));
8549
- const requestedOperation = safeString(options.operation, 80);
8550
- const operation = normalizeAgentOperation(requestedOperation);
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);
8551
9090
  if (requestedOperation && !operation) {
8552
9091
  return { ok: false, error: 'unsupported-agent-operation' };
8553
9092
  }
@@ -10793,9 +11332,10 @@ export function createRemoteHub(options = {}) {
10793
11332
  retryTaskBatch,
10794
11333
  listDeviceFrames,
10795
11334
  disconnectDevice,
10796
- assignDeviceSlot,
10797
- sendCommand,
10798
- refreshDevicePolicies,
11335
+ assignDeviceSlot,
11336
+ sendCommand,
11337
+ sendCommandAwaitResult,
11338
+ refreshDevicePolicies,
10799
11339
  sendLegacyClientUpdate,
10800
11340
  sendInputControl,
10801
11341
  releaseInputOwner,
@@ -10822,8 +11362,17 @@ export function createRemoteHub(options = {}) {
10822
11362
  handleUdpFrame,
10823
11363
  seedSyntheticFleet,
10824
11364
  clearSyntheticFleet,
10825
- getPairToken: () => pairToken,
10826
- getPairingPin: () => pairingPin
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()
10827
11376
  };
10828
11377
  }
10829
11378