@livedesk/hub 0.1.30 → 0.1.31

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,82 @@ 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, ttlMs }) => {
2335
+ const identity = getSecurityIdentity() || {};
2336
+ return deviceCredentialAuthority.issueRendezvousProof({
2337
+ roomId,
2338
+ deviceId,
2339
+ ttlMs,
2340
+ accountId: safeString(identity.accountId, 128)
2341
+ });
2342
+ });
2343
+ let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
2244
2344
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2245
2345
  : generatePairingPin();
2246
- let pairingPinUpdatedAt = new Date().toISOString();
2346
+ let pairingPinUpdatedAt = new Date().toISOString();
2347
+ const consumeEnrollmentToken = expected => {
2348
+ if (!timingSafeStringEqual(expected, pairToken)) return false;
2349
+ pairToken = crypto.randomBytes(32).toString('base64url');
2350
+ pairingPin = generatePairingPin();
2351
+ pairingPinUpdatedAt = new Date().toISOString();
2352
+ emitRemoteEvent('RemoteEnrollmentTokenRotated', null, {
2353
+ pairingPinUpdatedAt,
2354
+ hubId: deviceCredentialAuthority.hubId
2355
+ });
2356
+ return true;
2357
+ };
2358
+ const relayControl = options.relayControl && typeof options.relayControl === 'object'
2359
+ ? options.relayControl
2360
+ : allowLegacyPlaintextForTests
2361
+ ? createHubRelayControl({
2362
+ env,
2363
+ pairToken,
2364
+ logEvent,
2365
+ logWarn,
2366
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2367
+ })
2368
+ : createSecureHubRelayControl({
2369
+ env,
2370
+ authority: deviceCredentialAuthority,
2371
+ getIdentity: () => getSecurityIdentity() || {},
2372
+ getEnrollmentToken: () => pairToken,
2373
+ consumeEnrollmentToken,
2374
+ logEvent,
2375
+ logWarn,
2376
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2377
+ });
2378
+ const secureDirectAcceptor = createSecureDirectAcceptor({
2379
+ authority: deviceCredentialAuthority,
2380
+ getIdentity: () => getSecurityIdentity() || {},
2381
+ getEnrollmentToken: () => pairToken,
2382
+ consumeEnrollmentToken,
2383
+ onSecureSocket: socket => handleTcpAgentSocket(socket),
2384
+ onAudit: audit => emitEvent('RemoteSecurityHandshakeAudit', {
2385
+ ...audit,
2386
+ remoteHub: getStatus({ includeSecrets: false })
2387
+ }),
2388
+ logWarn
2389
+ });
2247
2390
  const duplicateDeviceLogThrottleMs = clampNumber(
2248
2391
  env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
2249
2392
  5000,
@@ -2268,7 +2411,8 @@ export function createRemoteHub(options = {}) {
2268
2411
  const transportDiagnostics = new Map();
2269
2412
  const transportDiagnosticStartingDevices = new Set();
2270
2413
  const duplicateDeviceLogAt = new Map();
2271
- let server = null;
2414
+ let server = null;
2415
+ let controlIdleSweepTimer = null;
2272
2416
  let started = false;
2273
2417
  let boundPort = requestedPort;
2274
2418
  let lastError = '';
@@ -2590,8 +2734,8 @@ export function createRemoteHub(options = {}) {
2590
2734
  host,
2591
2735
  agentHost: routeInfo.host || getAnnouncedHost(),
2592
2736
  port: boundPort || requestedPort,
2593
- protocol: 'tcp-jsonl',
2594
- protocolVersion: REMOTE_PROTOCOL_VERSION,
2737
+ protocol: 'secure-record-v1',
2738
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2595
2739
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2596
2740
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2597
2741
  frameModes: getSupportedRemoteFrameModeProfiles(),
@@ -2607,9 +2751,18 @@ export function createRemoteHub(options = {}) {
2607
2751
  agentEndpointCandidates: routeInfo.candidates,
2608
2752
  agentEndpointCandidateDetails: routeInfo.candidateDetails,
2609
2753
  pairToken: includeSecrets ? pairToken : undefined,
2610
- pairTokenPreview: maskToken(pairToken),
2611
- pairingPin: includeSecrets ? pairingPin : '',
2612
- pairingPinUpdatedAt,
2754
+ pairTokenPreview: maskToken(pairToken),
2755
+ pairingPin: includeSecrets ? pairingPin : '',
2756
+ pairingPinUpdatedAt,
2757
+ security: {
2758
+ direct: secureDirectAcceptor.getStatus(),
2759
+ hubId: deviceCredentialAuthority.hubId,
2760
+ hubPublicKeyFingerprint: crypto.createHash('sha256')
2761
+ .update(Buffer.from(deviceCredentialAuthority.hubPublicKey, 'base64url'))
2762
+ .digest('hex'),
2763
+ enrolledDeviceCount: deviceCredentialAuthority.listDevices().filter(item => !item.revokedAt).length,
2764
+ legacyPlaintextForTests: allowLegacyPlaintextForTests
2765
+ },
2613
2766
  deviceCount: devices.size,
2614
2767
  connectedDeviceCount: connectedDevices,
2615
2768
  canvasDeviceListMode: 'all-devices',
@@ -3869,14 +4022,22 @@ export function createRemoteHub(options = {}) {
3869
4022
  }
3870
4023
  }
3871
4024
 
3872
- function closeInputSocket(device, reason = 'input-socket-closed') {
4025
+ function closeInputSocket(device, reason = 'input-socket-closed') {
3873
4026
  const socket = device?.inputSocket;
3874
4027
  if (!socket) {
3875
4028
  return;
3876
4029
  }
3877
-
3878
- const writeOwner = device.inputWriteOwner;
3879
- device.inputSocket = null;
4030
+
4031
+ const writeOwner = device.inputWriteOwner;
4032
+ if (device.controlOwnerConnectionId) {
4033
+ endControlSession(
4034
+ device,
4035
+ device.controlOwnerConnectionId,
4036
+ reason,
4037
+ writeOwner,
4038
+ writeOwner?.getBindingKey?.() || '');
4039
+ }
4040
+ device.inputSocket = null;
3880
4041
  device.inputWriteOwner = null;
3881
4042
  device.inputSocketConnectionId = '';
3882
4043
  device.inputOwnerConnectionId = '';
@@ -3906,9 +4067,17 @@ export function createRemoteHub(options = {}) {
3906
4067
  if (!device || device.inputSocket !== socket) {
3907
4068
  return;
3908
4069
  }
3909
-
3910
- const writeOwner = device.inputWriteOwner;
3911
- device.inputSocket = null;
4070
+
4071
+ const writeOwner = device.inputWriteOwner;
4072
+ if (device.controlOwnerConnectionId) {
4073
+ endControlSession(
4074
+ device,
4075
+ device.controlOwnerConnectionId,
4076
+ reason,
4077
+ null,
4078
+ '');
4079
+ }
4080
+ device.inputSocket = null;
3912
4081
  device.inputWriteOwner = null;
3913
4082
  device.inputSocketConnectionId = '';
3914
4083
  device.inputOwnerConnectionId = '';
@@ -4340,7 +4509,11 @@ export function createRemoteHub(options = {}) {
4340
4509
  inputWriteOwner: null,
4341
4510
  inputSocketConnectionId: '',
4342
4511
  inputOwnerConnectionId: '',
4343
- inputOwnerBindingKey: '',
4512
+ inputOwnerBindingKey: '',
4513
+ controlOwnerSessionId: '',
4514
+ controlOwnerConnectionId: '',
4515
+ controlOwnerStartedAtMs: 0,
4516
+ controlOwnerLastInputAtMs: 0,
4344
4517
  frameSocket: null,
4345
4518
  audioSocket: null,
4346
4519
  fileSocket: null,
@@ -7261,8 +7434,14 @@ export function createRemoteHub(options = {}) {
7261
7434
  return;
7262
7435
  }
7263
7436
 
7264
- if (!state.authenticated) {
7265
- if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
7437
+ if (!state.authenticated) {
7438
+ const securityContext = socket.__liveDeskSecurityContext;
7439
+ const secureDeviceAuthenticated = securityContext?.authenticated === true
7440
+ && securityContext?.encrypted === true
7441
+ && safeString(securityContext.accountId, 128)
7442
+ && safeString(securityContext.hubId, 128)
7443
+ && safeString(securityContext.deviceId, 128);
7444
+ if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
7266
7445
  message = {
7267
7446
  ...message,
7268
7447
  capabilities: {
@@ -7272,13 +7451,14 @@ export function createRemoteHub(options = {}) {
7272
7451
  dedicatedInputChannel: false
7273
7452
  }
7274
7453
  };
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
- }
7454
+ }
7455
+ if (message.type === 'slot.assign') {
7456
+ if (!secureDeviceAuthenticated
7457
+ || safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7458
+ writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'authenticated-device-credential-required' });
7459
+ socket.end();
7460
+ return;
7461
+ }
7282
7462
  const result = assignDeviceSlot(
7283
7463
  message.deviceId || message.DeviceId,
7284
7464
  message.slotNumber ?? message.slot ?? message.SlotNumber);
@@ -7292,13 +7472,54 @@ export function createRemoteHub(options = {}) {
7292
7472
  return;
7293
7473
  }
7294
7474
 
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();
7475
+ if (secureDeviceAuthenticated) {
7476
+ if (safeString(message.deviceId || message.DeviceId, 128) !== securityContext.deviceId) {
7477
+ writeJsonLine(socket, { type: 'error', error: 'device-credential-binding-invalid' });
7478
+ socket.destroy();
7479
+ return;
7480
+ }
7481
+ const helloChannel = safeString(message.channel || message.Channel || 'control', 40).toLowerCase() || 'control';
7482
+ if (helloChannel !== securityContext.channel) {
7483
+ writeJsonLine(socket, { type: 'error', error: 'secure-channel-binding-invalid' });
7484
+ socket.destroy();
7485
+ return;
7486
+ }
7487
+ } else {
7488
+ const legacyAllowed = socket.__liveDeskRelayControl === true
7489
+ || socket.__liveDeskLegacyPlaintext === true && allowLegacyPlaintextForTests;
7490
+ if (!legacyAllowed || !timingSafeStringEqual(message.pairToken, pairToken)) {
7491
+ writeJsonLine(socket, { type: 'error', error: 'authenticated-device-credential-required' });
7492
+ socket.destroy();
7493
+ return;
7494
+ }
7495
+ }
7496
+
7497
+ const incomingDeviceId = safeString(message.deviceId || message.DeviceId, 128);
7498
+ const incomingCapabilities = message.capabilities && typeof message.capabilities === 'object'
7499
+ ? message.capabilities
7500
+ : {};
7501
+ const incomingPolicy = getWelcomeDevicePolicy({
7502
+ deviceId: incomingDeviceId,
7503
+ capabilities: incomingCapabilities
7504
+ }) || {};
7505
+ const connectionDenied = connectionPolicyError(socket, incomingPolicy);
7506
+ if (connectionDenied) {
7507
+ writeJsonLine(socket, { type: 'error', error: connectionDenied });
7508
+ emitEvent('RemoteSecurityPolicyAudit', {
7509
+ result: 'rejected',
7510
+ reason: connectionDenied,
7511
+ deviceId: incomingDeviceId,
7512
+ sessionId: safeString(socket.__liveDeskSecurityContext?.sessionId, 160),
7513
+ accountId: safeString(socket.__liveDeskSecurityContext?.accountId, 128),
7514
+ hubId: safeString(socket.__liveDeskSecurityContext?.hubId, 128),
7515
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7516
+ remoteAddress: safeString(socket.remoteAddress, 256)
7517
+ });
7518
+ socket.destroy();
7519
+ return;
7520
+ }
7521
+
7522
+ const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
7302
7523
  if (channel === 'input') {
7303
7524
  const device = attachInputSocket(socket, message);
7304
7525
  if (!device) {
@@ -7465,7 +7686,27 @@ export function createRemoteHub(options = {}) {
7465
7686
  }
7466
7687
  }
7467
7688
 
7468
- function handleWebSocketAgentSocket(socket) {
7689
+ function enforceAgentMessageRate(socket, state, channel = 'control') {
7690
+ if (state.messageRateGate.consume()) return true;
7691
+ const device = state.device;
7692
+ emitEvent('RemoteAbuseDefense', {
7693
+ result: 'rejected',
7694
+ reason: 'authenticated-message-rate-exceeded',
7695
+ deviceId: device?.deviceId || '',
7696
+ sessionId: device?.sessionId || '',
7697
+ accountId: socket.__liveDeskSecurityContext?.accountId || '',
7698
+ hubId: socket.__liveDeskSecurityContext?.hubId || '',
7699
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7700
+ channel,
7701
+ remoteAddress: safeString(socket.remoteAddress, 256),
7702
+ rate: state.messageRateGate.getStatus()
7703
+ });
7704
+ writeJsonLine(socket, { type: 'error', error: 'message-rate-exceeded' });
7705
+ socket.destroy();
7706
+ return false;
7707
+ }
7708
+
7709
+ function handleWebSocketAgentSocket(socket) {
7469
7710
  allSockets.add(socket);
7470
7711
  socket.setNoDelay(true);
7471
7712
  socket.setKeepAlive(true, heartbeatMs);
@@ -7479,10 +7720,11 @@ export function createRemoteHub(options = {}) {
7479
7720
  audioOnly: false,
7480
7721
  fileOnly: false,
7481
7722
  parserBuffer: socket.buffer,
7482
- binaryIngress: null,
7483
- binaryQueueDrops: 0,
7484
- binaryQueueEpoch: crypto.randomUUID(),
7485
- closed: false
7723
+ binaryIngress: null,
7724
+ binaryQueueDrops: 0,
7725
+ binaryQueueEpoch: crypto.randomUUID(),
7726
+ messageRateGate: createRemoteMessageRateGate(),
7727
+ closed: false
7486
7728
  };
7487
7729
  attachAgentBinaryIngress(socket, state);
7488
7730
 
@@ -7493,8 +7735,9 @@ export function createRemoteHub(options = {}) {
7493
7735
  }
7494
7736
  }, 10000);
7495
7737
 
7496
- socket.onTextMessage = text => {
7497
- try {
7738
+ socket.onTextMessage = text => {
7739
+ if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7740
+ try {
7498
7741
  handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
7499
7742
  } catch (err) {
7500
7743
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
@@ -7502,8 +7745,9 @@ export function createRemoteHub(options = {}) {
7502
7745
  }
7503
7746
  };
7504
7747
 
7505
- socket.onBinaryMessage = payload => {
7506
- try {
7748
+ socket.onBinaryMessage = payload => {
7749
+ if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7750
+ try {
7507
7751
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
7508
7752
  if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
7509
7753
  writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
@@ -7658,10 +7902,11 @@ export function createRemoteHub(options = {}) {
7658
7902
  buffer: new BoundedSegmentedBuffer(MAX_AGENT_SOCKET_ACCUMULATOR_BYTES),
7659
7903
  lineScanOffset: 0,
7660
7904
  pendingBinaryFrame: null,
7661
- binaryIngress: null,
7662
- binaryQueueDrops: 0,
7663
- binaryQueueEpoch: crypto.randomUUID(),
7664
- closed: false
7905
+ binaryIngress: null,
7906
+ binaryQueueDrops: 0,
7907
+ binaryQueueEpoch: crypto.randomUUID(),
7908
+ messageRateGate: createRemoteMessageRateGate(),
7909
+ closed: false
7665
7910
  };
7666
7911
  attachAgentBinaryIngress(socket, state);
7667
7912
 
@@ -7776,9 +8021,18 @@ export function createRemoteHub(options = {}) {
7776
8021
  return;
7777
8022
  }
7778
8023
 
7779
- try {
7780
- const message = parseJsonLine(lineBuffer.toString('utf8'));
7781
- if (message?.type === 'frame.binary') {
8024
+ try {
8025
+ const message = parseJsonLine(lineBuffer.toString('utf8'));
8026
+ if (!enforceAgentMessageRate(socket, state, state.frameOnly
8027
+ ? 'frame'
8028
+ : state.audioOnly
8029
+ ? 'audio'
8030
+ : state.inputOnly
8031
+ ? 'input'
8032
+ : state.fileOnly ? 'file' : 'control')) {
8033
+ return;
8034
+ }
8035
+ if (message?.type === 'frame.binary') {
7782
8036
  const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
7783
8037
  const maxBytes = frameKind === 'thumbnail'
7784
8038
  ? MAX_THUMBNAIL_BINARY_BYTES
@@ -7832,16 +8086,35 @@ export function createRemoteHub(options = {}) {
7832
8086
  }
7833
8087
  }
7834
8088
 
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
- });
8089
+ function handleSocket(socket) {
8090
+ if (!allowLegacyPlaintextForTests) {
8091
+ secureDirectAcceptor.accept(socket);
8092
+ return;
8093
+ }
8094
+ socket.once('data', chunk => {
8095
+ const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8096
+ if (isSecureHandshakeStart(firstChunk)) {
8097
+ secureDirectAcceptor.accept(socket, firstChunk);
8098
+ return;
8099
+ }
8100
+ if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
8101
+ if (!allowLegacyPlaintextForTests) {
8102
+ socket.write('HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n');
8103
+ socket.destroy();
8104
+ return;
8105
+ }
8106
+ socket.__liveDeskLegacyPlaintext = true;
8107
+ handleWebSocketUpgradeSocket(socket, firstChunk);
8108
+ return;
8109
+ }
8110
+ if (!allowLegacyPlaintextForTests) {
8111
+ writeJsonLine(socket, { type: 'error', error: 'secure-direct-required' });
8112
+ socket.destroy();
8113
+ return;
8114
+ }
8115
+ socket.__liveDeskLegacyPlaintext = true;
8116
+ handleTcpAgentSocket(socket, firstChunk);
8117
+ });
7845
8118
 
7846
8119
  socket.once('error', () => {
7847
8120
  // The transport-specific handler owns logging after the first byte.
@@ -7863,10 +8136,10 @@ export function createRemoteHub(options = {}) {
7863
8136
  started = true;
7864
8137
  boundPort = candidateServer.address()?.port || port;
7865
8138
  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
- }
8139
+ logEvent('remote', `LiveDesk Hub client endpoint listening with authenticated encryption on tcp://${host}:${boundPort}`, 'success');
8140
+ if (host === '0.0.0.0' || host === '::') {
8141
+ logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable; Secure Direct device credentials are required.');
8142
+ }
7870
8143
  emitRemoteEvent('RemoteHubStarted', null);
7871
8144
  resolve();
7872
8145
  });
@@ -7878,10 +8151,14 @@ export function createRemoteHub(options = {}) {
7878
8151
  return getStatus({ includeSecrets: false });
7879
8152
  }
7880
8153
 
7881
- try {
7882
- await udpTransport?.start?.();
7883
- await listenOnPort(requestedPort);
7884
- try {
8154
+ try {
8155
+ await udpTransport?.start?.();
8156
+ await listenOnPort(requestedPort);
8157
+ if (!controlIdleSweepTimer) {
8158
+ controlIdleSweepTimer = setInterval(sweepIdleControlSessions, 1_000);
8159
+ controlIdleSweepTimer.unref?.();
8160
+ }
8161
+ try {
7885
8162
  await relayControl?.start?.();
7886
8163
  } catch {
7887
8164
  logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
@@ -7902,8 +8179,12 @@ export function createRemoteHub(options = {}) {
7902
8179
  return getStatus({ includeSecrets: false });
7903
8180
  }
7904
8181
 
7905
- async function close() {
7906
- for (const state of [...agentBinaryIngressStates]) {
8182
+ async function close() {
8183
+ if (controlIdleSweepTimer) {
8184
+ clearInterval(controlIdleSweepTimer);
8185
+ controlIdleSweepTimer = null;
8186
+ }
8187
+ for (const state of [...agentBinaryIngressStates]) {
7907
8188
  closeAgentBinaryIngress(state, 'hub-shutdown');
7908
8189
  }
7909
8190
 
@@ -8109,7 +8390,7 @@ export function createRemoteHub(options = {}) {
8109
8390
  return { ok: true, commandId };
8110
8391
  }
8111
8392
 
8112
- function refreshDevicePolicies(deviceIds = undefined) {
8393
+ function refreshDevicePolicies(deviceIds = undefined) {
8113
8394
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8114
8395
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8115
8396
  : null;
@@ -8118,9 +8399,31 @@ export function createRemoteHub(options = {}) {
8118
8399
  for (const device of devices.values()) {
8119
8400
  if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8120
8401
  if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8121
- total += 1;
8122
- const effectivePolicy = getDevicePolicy(device);
8123
- if (writeJsonLine(device.socket, {
8402
+ total += 1;
8403
+ const effectivePolicy = getDevicePolicy(device);
8404
+ const connectionDenied = connectionPolicyError(device.socket, effectivePolicy);
8405
+ if (connectionDenied) {
8406
+ emitEvent('RemoteSecurityPolicyAudit', {
8407
+ result: 'rejected',
8408
+ reason: connectionDenied,
8409
+ deviceId: device.deviceId,
8410
+ sessionId: device.sessionId,
8411
+ transport: device.controlTransport,
8412
+ remoteAddress: device.remoteAddress
8413
+ });
8414
+ disconnectDevice(device.deviceId, connectionDenied);
8415
+ continue;
8416
+ }
8417
+ if (effectivePolicy.allowControl !== true && device.controlOwnerConnectionId) {
8418
+ releaseInputOwner(
8419
+ device.deviceId,
8420
+ device.controlOwnerConnectionId,
8421
+ 'control-disabled-by-policy');
8422
+ }
8423
+ if (effectivePolicy.allowRemoteAudio !== true && device.activeAudioStream) {
8424
+ stopAudioStream(device.deviceId, { reason: 'remote-audio-disabled-by-policy' });
8425
+ }
8426
+ if (writeJsonLine(device.socket, {
8124
8427
  type: 'policy.update',
8125
8428
  effectivePolicy,
8126
8429
  updatedAt: new Date().toISOString()
@@ -8199,7 +8502,7 @@ export function createRemoteHub(options = {}) {
8199
8502
  ]);
8200
8503
  }
8201
8504
 
8202
- function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8505
+ function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8203
8506
  return {
8204
8507
  type: 'input.control',
8205
8508
  payload: {
@@ -8221,8 +8524,114 @@ export function createRemoteHub(options = {}) {
8221
8524
  hubForwardedAtEpochMs: Date.now(),
8222
8525
  issuedAt: new Date().toISOString()
8223
8526
  }
8224
- };
8225
- }
8527
+ };
8528
+ }
8529
+
8530
+ function controlSessionMessage(device, ownerConnectionId, type, reason = '') {
8531
+ const policy = getDevicePolicy(device);
8532
+ return {
8533
+ type,
8534
+ payload: {
8535
+ controlOwnerSessionId: safeString(device?.controlOwnerSessionId, 160),
8536
+ hubConnectionId: safeString(ownerConnectionId, 128),
8537
+ deviceId: safeString(device?.deviceId, 160),
8538
+ visibleIndicator: true,
8539
+ lockOnEnd: type === 'control.session.stop' && policy.lockOnControlEnd === true,
8540
+ idleControlMinutes: clampNumber(policy.idleControlMinutes, 5, 1440, 30),
8541
+ reason: safeString(reason, 160),
8542
+ issuedAt: new Date().toISOString()
8543
+ }
8544
+ };
8545
+ }
8546
+
8547
+ function sendControlSessionMessage(device, ownerConnectionId, type, reason, inputWriteOwner, bindingKey) {
8548
+ const message = controlSessionMessage(device, ownerConnectionId, type, reason);
8549
+ if (inputWriteOwner && bindingKey) {
8550
+ return inputWriteOwner.enqueue(message, bindingKey);
8551
+ }
8552
+ if (!device?.socket || device.socket.destroyed) {
8553
+ return { ok: false, error: 'device-not-connected' };
8554
+ }
8555
+ const sent = writeJsonLine(device.socket, {
8556
+ type: 'command',
8557
+ commandId: crypto.randomUUID(),
8558
+ command: type,
8559
+ payload: message.payload,
8560
+ issuedAt: new Date().toISOString()
8561
+ });
8562
+ return sent ? { ok: true, inputSocket: false } : { ok: false, error: 'device-not-connected' };
8563
+ }
8564
+
8565
+ function beginControlSession(device, ownerConnectionId, inputWriteOwner, bindingKey) {
8566
+ const owner = safeString(ownerConnectionId, 128);
8567
+ if (!device || !owner) return { ok: false, error: 'control-owner-required' };
8568
+ if (device.controlOwnerConnectionId === owner && device.controlOwnerSessionId) {
8569
+ return { ok: true, alreadyActive: true };
8570
+ }
8571
+ device.controlOwnerSessionId = crypto.randomUUID();
8572
+ device.controlOwnerConnectionId = owner;
8573
+ device.controlOwnerStartedAtMs = Date.now();
8574
+ device.controlOwnerLastInputAtMs = Date.now();
8575
+ const sent = sendControlSessionMessage(
8576
+ device,
8577
+ owner,
8578
+ 'control.session.start',
8579
+ 'browser-control-owner-active',
8580
+ inputWriteOwner,
8581
+ bindingKey);
8582
+ if (!sent.ok) {
8583
+ device.controlOwnerSessionId = '';
8584
+ device.controlOwnerConnectionId = '';
8585
+ device.controlOwnerStartedAtMs = 0;
8586
+ device.controlOwnerLastInputAtMs = 0;
8587
+ return sent;
8588
+ }
8589
+ emitRemoteEvent('RemoteControlSessionStarted', device, {
8590
+ controlOwnerSessionId: device.controlOwnerSessionId,
8591
+ hubConnectionId: owner
8592
+ });
8593
+ return sent;
8594
+ }
8595
+
8596
+ function endControlSession(device, ownerConnectionId, reason, inputWriteOwner, bindingKey) {
8597
+ const owner = safeString(ownerConnectionId, 128);
8598
+ if (!device?.controlOwnerSessionId
8599
+ || !owner
8600
+ || device.controlOwnerConnectionId !== owner) {
8601
+ return { ok: false, error: 'control-owner-not-current' };
8602
+ }
8603
+ const controlOwnerSessionId = device.controlOwnerSessionId;
8604
+ const sent = sendControlSessionMessage(
8605
+ device,
8606
+ owner,
8607
+ 'control.session.stop',
8608
+ reason,
8609
+ inputWriteOwner,
8610
+ bindingKey);
8611
+ device.controlOwnerSessionId = '';
8612
+ device.controlOwnerConnectionId = '';
8613
+ device.controlOwnerStartedAtMs = 0;
8614
+ device.controlOwnerLastInputAtMs = 0;
8615
+ emitRemoteEvent('RemoteControlSessionEnded', device, {
8616
+ controlOwnerSessionId,
8617
+ hubConnectionId: owner,
8618
+ reason,
8619
+ delivered: sent.ok === true
8620
+ });
8621
+ return sent;
8622
+ }
8623
+
8624
+ function sweepIdleControlSessions() {
8625
+ const nowMs = Date.now();
8626
+ for (const device of devices.values()) {
8627
+ if (!device?.connected || !device.controlOwnerConnectionId) continue;
8628
+ const policy = getDevicePolicy(device);
8629
+ if (policy.disconnectIdleControlSessions !== true) continue;
8630
+ const idleMs = clampNumber(policy.idleControlMinutes, 5, 1440, 30) * 60 * 1000;
8631
+ if (nowMs - Number(device.controlOwnerLastInputAtMs || 0) < idleMs) continue;
8632
+ releaseInputOwner(device.deviceId, device.controlOwnerConnectionId, 'control-idle-timeout');
8633
+ }
8634
+ }
8226
8635
 
8227
8636
  function getCurrentInputWriteOwner(device) {
8228
8637
  if (!device?.inputSocket
@@ -8365,13 +8774,16 @@ export function createRemoteHub(options = {}) {
8365
8774
  normalized,
8366
8775
  activeMonitorIndex);
8367
8776
  const previousInputBindingKey = String(device.inputOwnerBindingKey || '');
8368
- const ownerChanged = normalized.hubConnectionId
8777
+ const ownerChanged = normalized.hubConnectionId
8369
8778
  && previousOwnerConnectionId
8370
8779
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8371
8780
  const bindingChanged = previousInputBindingKey
8372
8781
  && previousInputBindingKey !== activeInputBindingKey;
8373
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8374
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8782
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8783
+ const startingControlOwner = !!normalized.hubConnectionId
8784
+ && (!device.controlOwnerConnectionId
8785
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8786
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
8375
8787
  const resetSent = inputWriteOwner.replaceBinding(
8376
8788
  activeInputBindingKey,
8377
8789
  buildRemoteInputResetMessage(
@@ -8386,9 +8798,28 @@ export function createRemoteHub(options = {}) {
8386
8798
  activeMonitorIndex));
8387
8799
  if (!resetSent.ok) {
8388
8800
  closeInputSocket(device, resetSent.error || 'input-owner-reset-write-failed');
8389
- return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8390
- }
8391
- }
8801
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8802
+ }
8803
+ if (startingControlOwner) {
8804
+ if (device.controlOwnerConnectionId) {
8805
+ endControlSession(
8806
+ device,
8807
+ device.controlOwnerConnectionId,
8808
+ 'browser-input-owner-changed',
8809
+ inputWriteOwner,
8810
+ activeInputBindingKey);
8811
+ }
8812
+ const started = beginControlSession(
8813
+ device,
8814
+ normalized.hubConnectionId,
8815
+ inputWriteOwner,
8816
+ activeInputBindingKey);
8817
+ if (!started.ok) {
8818
+ closeInputSocket(device, started.error || 'control-session-indicator-start-failed');
8819
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8820
+ }
8821
+ }
8822
+ }
8392
8823
  const sent = inputWriteOwner.enqueue({
8393
8824
  type: 'input.control',
8394
8825
  payload: {
@@ -8401,7 +8832,8 @@ export function createRemoteHub(options = {}) {
8401
8832
  if (normalized.hubConnectionId) {
8402
8833
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8403
8834
  }
8404
- device.inputOwnerBindingKey = activeInputBindingKey;
8835
+ device.inputOwnerBindingKey = activeInputBindingKey;
8836
+ device.controlOwnerLastInputAtMs = Date.now();
8405
8837
  device.counters.commandsSent += 1;
8406
8838
  device.inputLastSeenAt = new Date().toISOString();
8407
8839
  return {
@@ -8430,8 +8862,27 @@ export function createRemoteHub(options = {}) {
8430
8862
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8431
8863
  }
8432
8864
 
8433
- const hubForwardedAtEpochMs = Date.now();
8434
- const fallback = sendCommand(deviceId, {
8865
+ const hubForwardedAtEpochMs = Date.now();
8866
+ const startingFallbackOwner = !!normalized.hubConnectionId
8867
+ && (!device.controlOwnerConnectionId
8868
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8869
+ if (startingFallbackOwner) {
8870
+ if (device.controlOwnerConnectionId) {
8871
+ endControlSession(
8872
+ device,
8873
+ device.controlOwnerConnectionId,
8874
+ 'browser-input-owner-changed',
8875
+ null,
8876
+ '');
8877
+ }
8878
+ const started = beginControlSession(
8879
+ device,
8880
+ normalized.hubConnectionId,
8881
+ null,
8882
+ '');
8883
+ if (!started.ok) return started;
8884
+ }
8885
+ const fallback = sendCommand(deviceId, {
8435
8886
  command: 'input.control',
8436
8887
  payload: {
8437
8888
  ...normalized,
@@ -8439,7 +8890,7 @@ export function createRemoteHub(options = {}) {
8439
8890
  issuedAt: normalized.issuedAt || new Date().toISOString()
8440
8891
  }
8441
8892
  });
8442
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8893
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8443
8894
  schedulePendingInputFallback(device, {
8444
8895
  commandId: fallback.commandId,
8445
8896
  inputSeq: normalized.inputSeq,
@@ -8451,10 +8902,26 @@ export function createRemoteHub(options = {}) {
8451
8902
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8452
8903
  hubForwardedAtEpochMs,
8453
8904
  fallback: true,
8454
- timeoutTimer: null
8455
- });
8456
- }
8457
- return fallback.ok
8905
+ timeoutTimer: null
8906
+ });
8907
+ }
8908
+ if (fallback.ok) {
8909
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
8910
+ device.inputOwnerBindingKey = buildRemoteInputBindingKey(
8911
+ device,
8912
+ controlStream,
8913
+ normalized,
8914
+ activeMonitorIndex);
8915
+ device.controlOwnerLastInputAtMs = Date.now();
8916
+ } else if (startingFallbackOwner) {
8917
+ endControlSession(
8918
+ device,
8919
+ normalized.hubConnectionId,
8920
+ 'input-control-delivery-failed',
8921
+ null,
8922
+ '');
8923
+ }
8924
+ return fallback.ok
8458
8925
  ? {
8459
8926
  ...fallback,
8460
8927
  inputSocket: false,
@@ -8480,15 +8947,20 @@ export function createRemoteHub(options = {}) {
8480
8947
  return { ok: false, error: 'input-owner-not-current' };
8481
8948
  }
8482
8949
 
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);
8950
+ const inputSocket = device.inputSocket;
8951
+ const inputWriteOwner = getCurrentInputWriteOwner(device);
8952
+ if (!inputSocket || !inputWriteOwner) {
8953
+ const ended = endControlSession(device, owner, reason, null, '');
8954
+ device.inputOwnerConnectionId = '';
8955
+ device.inputOwnerBindingKey = '';
8956
+ return {
8957
+ ok: ended.ok !== false,
8958
+ queued: false,
8959
+ controlSessionEnded: ended.ok === true
8960
+ };
8961
+ }
8962
+ const controlStream = getActiveControlStream(device);
8963
+ const releaseBindingKey = buildRemoteInputReleaseBindingKey(device, owner);
8492
8964
  const sent = inputWriteOwner.replaceBinding(
8493
8965
  releaseBindingKey,
8494
8966
  buildRemoteInputResetMessage(
@@ -8497,15 +8969,24 @@ export function createRemoteHub(options = {}) {
8497
8969
  owner,
8498
8970
  reason,
8499
8971
  normalizeMonitorIndex(controlStream?.monitorIndex)));
8500
- if (!sent.ok) {
8972
+ if (!sent.ok) {
8501
8973
  closeInputSocket(device, sent.error || 'input-owner-release-write-failed');
8502
8974
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8503
- }
8504
- return {
8505
- ok: true,
8506
- queued: true,
8507
- backpressured: sent.backpressured === true
8508
- };
8975
+ }
8976
+ const ended = endControlSession(
8977
+ device,
8978
+ owner,
8979
+ reason,
8980
+ inputWriteOwner,
8981
+ releaseBindingKey);
8982
+ device.inputOwnerConnectionId = '';
8983
+ device.inputOwnerBindingKey = '';
8984
+ return {
8985
+ ok: ended.ok !== false,
8986
+ queued: true,
8987
+ backpressured: sent.backpressured === true || ended.backpressured === true,
8988
+ controlSessionEnded: ended.ok === true
8989
+ };
8509
8990
  }
8510
8991
 
8511
8992
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -8545,9 +9026,12 @@ export function createRemoteHub(options = {}) {
8545
9026
  if (retiredRequestError) {
8546
9027
  return { ok: false, error: retiredRequestError };
8547
9028
  }
8548
- const device = devices.get(String(deviceId || ''));
8549
- const requestedOperation = safeString(options.operation, 80);
8550
- const operation = normalizeAgentOperation(requestedOperation);
9029
+ const device = devices.get(String(deviceId || ''));
9030
+ const requestedOperation = safeString(options.operation, 80);
9031
+ if (RETIRED_AGENT_MUTATING_OPERATIONS.has(requestedOperation.toLowerCase())) {
9032
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
9033
+ }
9034
+ const operation = normalizeAgentOperation(requestedOperation);
8551
9035
  if (requestedOperation && !operation) {
8552
9036
  return { ok: false, error: 'unsupported-agent-operation' };
8553
9037
  }
@@ -10822,8 +11306,16 @@ export function createRemoteHub(options = {}) {
10822
11306
  handleUdpFrame,
10823
11307
  seedSyntheticFleet,
10824
11308
  clearSyntheticFleet,
10825
- getPairToken: () => pairToken,
10826
- getPairingPin: () => pairingPin
11309
+ getPairToken: () => pairToken,
11310
+ getPairingPin: () => pairingPin,
11311
+ getSecurityStatus: () => ({
11312
+ hubId: deviceCredentialAuthority.hubId,
11313
+ hubPublicKey: deviceCredentialAuthority.hubPublicKey,
11314
+ direct: secureDirectAcceptor.getStatus(),
11315
+ devices: deviceCredentialAuthority.listDevices()
11316
+ }),
11317
+ revokeDeviceCredential: (deviceId, reason) => deviceCredentialAuthority.revokeDevice(deviceId, reason),
11318
+ clearDeviceCredentials: () => deviceCredentialAuthority.clearDevices()
10827
11319
  };
10828
11320
  }
10829
11321