@livedesk/hub 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/remote-hub.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import net from 'net';
2
2
  import os from 'os';
3
- import crypto from 'crypto';
4
- import { isSecureHandshakeStart } from '@livedesk/runtime-core';
5
- import { createDeviceCredentialAuthority } from './security/device-credential-authority.js';
6
- import { createHubRelayControl, createSecureHubRelayControl } from './transport/relay-hub-control.js';
7
- import { createSecureDirectAcceptor } from './transport/secure-direct-acceptor.js';
3
+ import crypto from 'crypto';
4
+ import { isSecureHandshakeStart } from '@livedesk/runtime-core';
5
+ import { createDeviceCredentialAuthority } from './security/device-credential-authority.js';
6
+ import { createHubRelayControl, createSecureHubRelayControl } from './transport/relay-hub-control.js';
7
+ import { createSecureDirectAcceptor } from './transport/secure-direct-acceptor.js';
8
8
  import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
9
9
  import {
10
10
  BoundedSegmentedBuffer,
@@ -35,10 +35,10 @@ const MAX_AGENT_TCP_BINARY_PACKET_BYTES = TCP_BINARY_FRAME_HEADER_BYTES
35
35
  const MAX_AGENT_WEBSOCKET_MESSAGE_BYTES = 4
36
36
  + MAX_AGENT_BINARY_META_BYTES
37
37
  + MAX_AGENT_BINARY_PAYLOAD_BYTES;
38
- const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
39
- + MAX_AGENT_TCP_BINARY_PACKET_BYTES
40
- + (256 * 1024);
41
- const MAX_AGENT_MESSAGES_PER_SECOND = 240;
38
+ const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
39
+ + MAX_AGENT_TCP_BINARY_PACKET_BYTES
40
+ + (256 * 1024);
41
+ const MAX_AGENT_MESSAGES_PER_SECOND = 240;
42
42
  const HTTP_HEADER_TERMINATOR = Buffer.from('\r\n\r\n', 'ascii');
43
43
  const MAX_AGENT_TASK_CHARS = 4000;
44
44
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
@@ -110,7 +110,7 @@ const MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS = Object.freeze([
110
110
  'handoffOldestFrameAgeMs'
111
111
  ]);
112
112
 
113
- export function hasCompleteMacNativeResourceTelemetry(frame) {
113
+ export function hasCompleteMacNativeResourceTelemetry(frame) {
114
114
  return Number(frame?.nativeCaptureTelemetryVersion) >= MAC_NATIVE_RESOURCE_TELEMETRY_VERSION
115
115
  && MAC_NATIVE_RESOURCE_TELEMETRY_FIELDS.every(field =>
116
116
  frame?.[field] !== null
@@ -828,61 +828,61 @@ function isPrivateIPv4(value) {
828
828
  || (parts[0] === 192 && parts[1] === 168);
829
829
  }
830
830
 
831
- function isPrivateNetworkAddress(value) {
831
+ function isPrivateNetworkAddress(value) {
832
832
  const address = String(value || '').replace(/^::ffff:/i, '').trim().toLowerCase();
833
833
  return isLoopbackHost(address)
834
834
  || isPrivateIPv4(address)
835
835
  || address.startsWith('fe80:')
836
836
  || address.startsWith('fc')
837
- || address.startsWith('fd');
838
- }
839
-
840
- export function createRemoteMessageRateGate({
841
- limit = MAX_AGENT_MESSAGES_PER_SECOND,
842
- windowMs = 1_000,
843
- now = () => Date.now()
844
- } = {}) {
845
- const boundedLimit = Math.max(1, Math.min(10_000, Math.floor(Number(limit) || MAX_AGENT_MESSAGES_PER_SECOND)));
846
- const boundedWindowMs = Math.max(100, Math.min(60_000, Math.floor(Number(windowMs) || 1_000)));
847
- let windowStartedAt = Number(now());
848
- let count = 0;
849
- return Object.freeze({
850
- consume() {
851
- const current = Number(now());
852
- if (!Number.isFinite(current) || current - windowStartedAt >= boundedWindowMs || current < windowStartedAt) {
853
- windowStartedAt = current;
854
- count = 0;
855
- }
856
- count += 1;
857
- return count <= boundedLimit;
858
- },
859
- getStatus: () => ({ count, limit: boundedLimit, windowMs: boundedWindowMs, windowStartedAt })
860
- });
861
- }
862
-
863
- export function evaluateRemoteConnectionPolicy({
864
- remoteAddress = '',
865
- relayConnection = false,
866
- encrypted = false,
867
- allowLegacyPlaintextForTests = false,
868
- policy = {}
869
- } = {}) {
870
- const address = String(remoteAddress || '').replace(/^::ffff:/i, '').trim().toLowerCase();
871
- if (policy.requireEncryptedConnections === true
872
- && encrypted !== true
873
- && allowLegacyPlaintextForTests !== true) {
874
- return 'encrypted-connection-required';
875
- }
876
- if (relayConnection === true || !isPrivateNetworkAddress(address)) {
877
- return policy.allowInternetConnections === false
878
- ? 'internet-connections-blocked-by-settings'
879
- : '';
880
- }
881
- if (!isLoopbackHost(address) && policy.allowLanConnections === false) {
882
- return 'lan-connections-blocked-by-settings';
883
- }
884
- return '';
885
- }
837
+ || address.startsWith('fd');
838
+ }
839
+
840
+ export function createRemoteMessageRateGate({
841
+ limit = MAX_AGENT_MESSAGES_PER_SECOND,
842
+ windowMs = 1_000,
843
+ now = () => Date.now()
844
+ } = {}) {
845
+ const boundedLimit = Math.max(1, Math.min(10_000, Math.floor(Number(limit) || MAX_AGENT_MESSAGES_PER_SECOND)));
846
+ const boundedWindowMs = Math.max(100, Math.min(60_000, Math.floor(Number(windowMs) || 1_000)));
847
+ let windowStartedAt = Number(now());
848
+ let count = 0;
849
+ return Object.freeze({
850
+ consume() {
851
+ const current = Number(now());
852
+ if (!Number.isFinite(current) || current - windowStartedAt >= boundedWindowMs || current < windowStartedAt) {
853
+ windowStartedAt = current;
854
+ count = 0;
855
+ }
856
+ count += 1;
857
+ return count <= boundedLimit;
858
+ },
859
+ getStatus: () => ({ count, limit: boundedLimit, windowMs: boundedWindowMs, windowStartedAt })
860
+ });
861
+ }
862
+
863
+ export function evaluateRemoteConnectionPolicy({
864
+ remoteAddress = '',
865
+ relayConnection = false,
866
+ encrypted = false,
867
+ allowLegacyPlaintextForTests = false,
868
+ policy = {}
869
+ } = {}) {
870
+ const address = String(remoteAddress || '').replace(/^::ffff:/i, '').trim().toLowerCase();
871
+ if (policy.requireEncryptedConnections === true
872
+ && encrypted !== true
873
+ && allowLegacyPlaintextForTests !== true) {
874
+ return 'encrypted-connection-required';
875
+ }
876
+ if (relayConnection === true || !isPrivateNetworkAddress(address)) {
877
+ return policy.allowInternetConnections === false
878
+ ? 'internet-connections-blocked-by-settings'
879
+ : '';
880
+ }
881
+ if (!isLoopbackHost(address) && policy.allowLanConnections === false) {
882
+ return 'lan-connections-blocked-by-settings';
883
+ }
884
+ return '';
885
+ }
886
886
 
887
887
  function readCpuTimes() {
888
888
  let idle = 0;
@@ -1206,37 +1206,37 @@ function getRetiredAgentTaskError(options = {}) {
1206
1206
  : '';
1207
1207
  }
1208
1208
 
1209
- const SUPPORTED_AGENT_OPERATIONS = new Set([
1210
- 'system.health',
1211
- 'gpu.status',
1212
- 'disk.status',
1213
- 'process.list',
1214
- 'service.status',
1215
- 'diagnostics.collect',
1216
- 'file.read',
1217
- 'file.list',
1218
- 'network.status',
1219
- 'logs.collect'
1220
- ]);
1221
-
1222
- const RETIRED_AGENT_MUTATING_OPERATIONS = new Set([
1223
- 'process.control',
1224
- 'service.control',
1225
- 'application.launch',
1226
- 'application.close',
1227
- 'file.write',
1228
- 'file.delete',
1229
- 'command.run',
1230
- 'script.run',
1231
- 'software.install',
1232
- 'system.power',
1233
- 'system.configure'
1234
- ]);
1235
-
1236
- function normalizeAgentOperation(value) {
1237
- const operation = safeString(value, 80).toLowerCase();
1238
- return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1239
- }
1209
+ const SUPPORTED_AGENT_OPERATIONS = new Set([
1210
+ 'system.health',
1211
+ 'gpu.status',
1212
+ 'disk.status',
1213
+ 'process.list',
1214
+ 'service.status',
1215
+ 'diagnostics.collect',
1216
+ 'file.read',
1217
+ 'file.list',
1218
+ 'network.status',
1219
+ 'logs.collect'
1220
+ ]);
1221
+
1222
+ const RETIRED_AGENT_MUTATING_OPERATIONS = new Set([
1223
+ 'process.control',
1224
+ 'service.control',
1225
+ 'application.launch',
1226
+ 'application.close',
1227
+ 'file.write',
1228
+ 'file.delete',
1229
+ 'command.run',
1230
+ 'script.run',
1231
+ 'software.install',
1232
+ 'system.power',
1233
+ 'system.configure'
1234
+ ]);
1235
+
1236
+ function normalizeAgentOperation(value) {
1237
+ const operation = safeString(value, 80).toLowerCase();
1238
+ return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1239
+ }
1240
1240
 
1241
1241
  const REMOTE_INPUT_MONITOR_KEYS = Object.freeze([
1242
1242
  'monitorIndex',
@@ -1402,7 +1402,7 @@ function buildRemoteFramePath(deviceId, frameKind, frame) {
1402
1402
  return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
1403
1403
  }
1404
1404
 
1405
- function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1405
+ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1406
1406
  if (!frame) {
1407
1407
  return null;
1408
1408
  }
@@ -1424,27 +1424,27 @@ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
1424
1424
  serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
1425
1425
  }
1426
1426
 
1427
- return serialized;
1428
- }
1429
-
1430
- function serializeActiveLiveStream(activeLiveStream, deviceId) {
1431
- if (!activeLiveStream) {
1432
- return null;
1433
- }
1434
-
1435
- return {
1436
- ...activeLiveStream,
1437
- // The active descriptor is status metadata. Keep its internal frame
1438
- // owner intact for immediate delivery, but never duplicate encoded
1439
- // bytes or its bearer token into React/status snapshots.
1440
- latestFrame: serializeRemoteFrame(
1441
- activeLiveStream.latestFrame,
1442
- deviceId,
1443
- 'live',
1444
- { includeDataUrl: false }
1445
- )
1446
- };
1447
- }
1427
+ return serialized;
1428
+ }
1429
+
1430
+ function serializeActiveLiveStream(activeLiveStream, deviceId) {
1431
+ if (!activeLiveStream) {
1432
+ return null;
1433
+ }
1434
+
1435
+ return {
1436
+ ...activeLiveStream,
1437
+ // The active descriptor is status metadata. Keep its internal frame
1438
+ // owner intact for immediate delivery, but never duplicate encoded
1439
+ // bytes or its bearer token into React/status snapshots.
1440
+ latestFrame: serializeRemoteFrame(
1441
+ activeLiveStream.latestFrame,
1442
+ deviceId,
1443
+ 'live',
1444
+ { includeDataUrl: false }
1445
+ )
1446
+ };
1447
+ }
1448
1448
 
1449
1449
  function getRecentFrameCache(device, frameKind) {
1450
1450
  if (!device) {
@@ -1635,7 +1635,7 @@ function serializeDevice(device, options = {}) {
1635
1635
  },
1636
1636
  latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1637
1637
  latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1638
- activeLiveStream: serializeActiveLiveStream(device.activeLiveStream, device.deviceId),
1638
+ activeLiveStream: serializeActiveLiveStream(device.activeLiveStream, device.deviceId),
1639
1639
  liveCapturePause: device.liveCapturePause
1640
1640
  ? {
1641
1641
  token: device.liveCapturePause.token,
@@ -2242,7 +2242,7 @@ export function createRemoteHub(options = {}) {
2242
2242
  : null;
2243
2243
  const sampleHubRuntimeDiagnostics = createHubRuntimeDiagnosticsSampler();
2244
2244
 
2245
- function getDevicePolicy(device) {
2245
+ function getDevicePolicy(device) {
2246
2246
  try {
2247
2247
  return getEffectiveDevicePolicy({
2248
2248
  deviceId: device?.deviceId || '',
@@ -2250,18 +2250,18 @@ export function createRemoteHub(options = {}) {
2250
2250
  }) || {};
2251
2251
  } catch {
2252
2252
  return {};
2253
- }
2254
- }
2255
-
2256
- function connectionPolicyError(socket, policy = {}) {
2257
- return evaluateRemoteConnectionPolicy({
2258
- remoteAddress: socket?.remoteAddress || '',
2259
- relayConnection: socket?.__liveDeskRelayControl === true,
2260
- encrypted: socket?.__liveDeskSecurityContext?.encrypted === true,
2261
- allowLegacyPlaintextForTests,
2262
- policy
2263
- });
2264
- }
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
+ }
2265
2265
 
2266
2266
  function policyError(device, permission = '', command = '') {
2267
2267
  const policy = getDevicePolicy(device);
@@ -2311,82 +2311,83 @@ export function createRemoteHub(options = {}) {
2311
2311
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
2312
2312
  const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
2313
2313
  const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
2314
- const configuredEnrollmentToken = safeString(
2315
- options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN,
2316
- 256);
2317
- const allowLegacyPlaintextForTests = options.allowLegacyPlaintextForTests === true
2318
- || isEnabledValue(env.LIVEDESK_TEST_ALLOW_LEGACY_PLAINTEXT, false)
2319
- || env.LIVEDESK_TEST_MODE === '1';
2320
- const canonicalEnrollmentToken = /^[A-Za-z0-9_-]{43}$/.test(configuredEnrollmentToken)
2321
- && Buffer.from(configuredEnrollmentToken, 'base64url').length === 32
2322
- && Buffer.from(configuredEnrollmentToken, 'base64url').toString('base64url') === configuredEnrollmentToken
2323
- ? configuredEnrollmentToken
2324
- : '';
2325
- let pairToken = allowLegacyPlaintextForTests && configuredEnrollmentToken
2326
- ? configuredEnrollmentToken
2327
- : canonicalEnrollmentToken || crypto.randomBytes(32).toString('base64url');
2328
- const deviceCredentialAuthority = options.deviceCredentialAuthority || createDeviceCredentialAuthority({
2329
- dataDir: options.dataDir || env.LIVEDESK_DATA_DIR || undefined
2330
- });
2331
- const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
2332
- ? options.getSecurityIdentity
2333
- : () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
2334
- udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, 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())
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())
2344
2345
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2345
2346
  : generatePairingPin();
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
- });
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
+ });
2390
2391
  const duplicateDeviceLogThrottleMs = clampNumber(
2391
2392
  env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
2392
2393
  5000,
@@ -2411,8 +2412,8 @@ export function createRemoteHub(options = {}) {
2411
2412
  const transportDiagnostics = new Map();
2412
2413
  const transportDiagnosticStartingDevices = new Set();
2413
2414
  const duplicateDeviceLogAt = new Map();
2414
- let server = null;
2415
- let controlIdleSweepTimer = null;
2415
+ let server = null;
2416
+ let controlIdleSweepTimer = null;
2416
2417
  let started = false;
2417
2418
  let boundPort = requestedPort;
2418
2419
  let lastError = '';
@@ -2734,8 +2735,8 @@ export function createRemoteHub(options = {}) {
2734
2735
  host,
2735
2736
  agentHost: routeInfo.host || getAnnouncedHost(),
2736
2737
  port: boundPort || requestedPort,
2737
- protocol: 'secure-record-v1',
2738
- protocolVersion: REMOTE_PROTOCOL_VERSION,
2738
+ protocol: 'secure-record-v1',
2739
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2739
2740
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2740
2741
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2741
2742
  frameModes: getSupportedRemoteFrameModeProfiles(),
@@ -2751,18 +2752,18 @@ export function createRemoteHub(options = {}) {
2751
2752
  agentEndpointCandidates: routeInfo.candidates,
2752
2753
  agentEndpointCandidateDetails: routeInfo.candidateDetails,
2753
2754
  pairToken: includeSecrets ? pairToken : undefined,
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
- },
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
+ },
2766
2767
  deviceCount: devices.size,
2767
2768
  connectedDeviceCount: connectedDevices,
2768
2769
  canvasDeviceListMode: 'all-devices',
@@ -4022,22 +4023,22 @@ export function createRemoteHub(options = {}) {
4022
4023
  }
4023
4024
  }
4024
4025
 
4025
- function closeInputSocket(device, reason = 'input-socket-closed') {
4026
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4026
4027
  const socket = device?.inputSocket;
4027
4028
  if (!socket) {
4028
4029
  return;
4029
4030
  }
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;
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;
4041
4042
  device.inputWriteOwner = null;
4042
4043
  device.inputSocketConnectionId = '';
4043
4044
  device.inputOwnerConnectionId = '';
@@ -4067,17 +4068,17 @@ export function createRemoteHub(options = {}) {
4067
4068
  if (!device || device.inputSocket !== socket) {
4068
4069
  return;
4069
4070
  }
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;
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;
4081
4082
  device.inputWriteOwner = null;
4082
4083
  device.inputSocketConnectionId = '';
4083
4084
  device.inputOwnerConnectionId = '';
@@ -4509,11 +4510,11 @@ export function createRemoteHub(options = {}) {
4509
4510
  inputWriteOwner: null,
4510
4511
  inputSocketConnectionId: '',
4511
4512
  inputOwnerConnectionId: '',
4512
- inputOwnerBindingKey: '',
4513
- controlOwnerSessionId: '',
4514
- controlOwnerConnectionId: '',
4515
- controlOwnerStartedAtMs: 0,
4516
- controlOwnerLastInputAtMs: 0,
4513
+ inputOwnerBindingKey: '',
4514
+ controlOwnerSessionId: '',
4515
+ controlOwnerConnectionId: '',
4516
+ controlOwnerStartedAtMs: 0,
4517
+ controlOwnerLastInputAtMs: 0,
4517
4518
  frameSocket: null,
4518
4519
  audioSocket: null,
4519
4520
  fileSocket: null,
@@ -7434,14 +7435,14 @@ export function createRemoteHub(options = {}) {
7434
7435
  return;
7435
7436
  }
7436
7437
 
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) {
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) {
7445
7446
  message = {
7446
7447
  ...message,
7447
7448
  capabilities: {
@@ -7451,14 +7452,14 @@ export function createRemoteHub(options = {}) {
7451
7452
  dedicatedInputChannel: false
7452
7453
  }
7453
7454
  };
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
- }
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
+ }
7462
7463
  const result = assignDeviceSlot(
7463
7464
  message.deviceId || message.DeviceId,
7464
7465
  message.slotNumber ?? message.slot ?? message.SlotNumber);
@@ -7472,54 +7473,57 @@ export function createRemoteHub(options = {}) {
7472
7473
  return;
7473
7474
  }
7474
7475
 
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();
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
+ const deniedTransport = socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp';
7509
+ const deniedRemoteAddress = safeString(socket.remoteAddress, 256) || 'unknown';
7510
+ logWarn('security', 'Remote connection rejected reason=' + connectionDenied + ' transport=' + deniedTransport + ' remote=' + deniedRemoteAddress);
7511
+ writeJsonLine(socket, { type: 'error', error: connectionDenied });
7512
+ emitEvent('RemoteSecurityPolicyAudit', {
7513
+ result: 'rejected',
7514
+ reason: connectionDenied,
7515
+ deviceId: incomingDeviceId,
7516
+ sessionId: safeString(socket.__liveDeskSecurityContext?.sessionId, 160),
7517
+ accountId: safeString(socket.__liveDeskSecurityContext?.accountId, 128),
7518
+ hubId: safeString(socket.__liveDeskSecurityContext?.hubId, 128),
7519
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7520
+ remoteAddress: safeString(socket.remoteAddress, 256)
7521
+ });
7522
+ socket.destroy();
7523
+ return;
7524
+ }
7525
+
7526
+ const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
7523
7527
  if (channel === 'input') {
7524
7528
  const device = attachInputSocket(socket, message);
7525
7529
  if (!device) {
@@ -7686,27 +7690,27 @@ export function createRemoteHub(options = {}) {
7686
7690
  }
7687
7691
  }
7688
7692
 
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) {
7693
+ function enforceAgentMessageRate(socket, state, channel = 'control') {
7694
+ if (state.messageRateGate.consume()) return true;
7695
+ const device = state.device;
7696
+ emitEvent('RemoteAbuseDefense', {
7697
+ result: 'rejected',
7698
+ reason: 'authenticated-message-rate-exceeded',
7699
+ deviceId: device?.deviceId || '',
7700
+ sessionId: device?.sessionId || '',
7701
+ accountId: socket.__liveDeskSecurityContext?.accountId || '',
7702
+ hubId: socket.__liveDeskSecurityContext?.hubId || '',
7703
+ transport: socket.__liveDeskRelayControl === true ? 'relay' : 'direct-tcp',
7704
+ channel,
7705
+ remoteAddress: safeString(socket.remoteAddress, 256),
7706
+ rate: state.messageRateGate.getStatus()
7707
+ });
7708
+ writeJsonLine(socket, { type: 'error', error: 'message-rate-exceeded' });
7709
+ socket.destroy();
7710
+ return false;
7711
+ }
7712
+
7713
+ function handleWebSocketAgentSocket(socket) {
7710
7714
  allSockets.add(socket);
7711
7715
  socket.setNoDelay(true);
7712
7716
  socket.setKeepAlive(true, heartbeatMs);
@@ -7720,11 +7724,11 @@ export function createRemoteHub(options = {}) {
7720
7724
  audioOnly: false,
7721
7725
  fileOnly: false,
7722
7726
  parserBuffer: socket.buffer,
7723
- binaryIngress: null,
7724
- binaryQueueDrops: 0,
7725
- binaryQueueEpoch: crypto.randomUUID(),
7726
- messageRateGate: createRemoteMessageRateGate(),
7727
- closed: false
7727
+ binaryIngress: null,
7728
+ binaryQueueDrops: 0,
7729
+ binaryQueueEpoch: crypto.randomUUID(),
7730
+ messageRateGate: createRemoteMessageRateGate(),
7731
+ closed: false
7728
7732
  };
7729
7733
  attachAgentBinaryIngress(socket, state);
7730
7734
 
@@ -7735,9 +7739,9 @@ export function createRemoteHub(options = {}) {
7735
7739
  }
7736
7740
  }, 10000);
7737
7741
 
7738
- socket.onTextMessage = text => {
7739
- if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7740
- try {
7742
+ socket.onTextMessage = text => {
7743
+ if (!enforceAgentMessageRate(socket, state, 'websocket-text')) return;
7744
+ try {
7741
7745
  handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
7742
7746
  } catch (err) {
7743
7747
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
@@ -7745,9 +7749,9 @@ export function createRemoteHub(options = {}) {
7745
7749
  }
7746
7750
  };
7747
7751
 
7748
- socket.onBinaryMessage = payload => {
7749
- if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7750
- try {
7752
+ socket.onBinaryMessage = payload => {
7753
+ if (!enforceAgentMessageRate(socket, state, 'websocket-binary')) return;
7754
+ try {
7751
7755
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
7752
7756
  if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
7753
7757
  writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
@@ -7902,11 +7906,11 @@ export function createRemoteHub(options = {}) {
7902
7906
  buffer: new BoundedSegmentedBuffer(MAX_AGENT_SOCKET_ACCUMULATOR_BYTES),
7903
7907
  lineScanOffset: 0,
7904
7908
  pendingBinaryFrame: null,
7905
- binaryIngress: null,
7906
- binaryQueueDrops: 0,
7907
- binaryQueueEpoch: crypto.randomUUID(),
7908
- messageRateGate: createRemoteMessageRateGate(),
7909
- closed: false
7909
+ binaryIngress: null,
7910
+ binaryQueueDrops: 0,
7911
+ binaryQueueEpoch: crypto.randomUUID(),
7912
+ messageRateGate: createRemoteMessageRateGate(),
7913
+ closed: false
7910
7914
  };
7911
7915
  attachAgentBinaryIngress(socket, state);
7912
7916
 
@@ -8021,18 +8025,18 @@ export function createRemoteHub(options = {}) {
8021
8025
  return;
8022
8026
  }
8023
8027
 
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') {
8028
+ try {
8029
+ const message = parseJsonLine(lineBuffer.toString('utf8'));
8030
+ if (!enforceAgentMessageRate(socket, state, state.frameOnly
8031
+ ? 'frame'
8032
+ : state.audioOnly
8033
+ ? 'audio'
8034
+ : state.inputOnly
8035
+ ? 'input'
8036
+ : state.fileOnly ? 'file' : 'control')) {
8037
+ return;
8038
+ }
8039
+ if (message?.type === 'frame.binary') {
8036
8040
  const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
8037
8041
  const maxBytes = frameKind === 'thumbnail'
8038
8042
  ? MAX_THUMBNAIL_BINARY_BYTES
@@ -8086,35 +8090,35 @@ export function createRemoteHub(options = {}) {
8086
8090
  }
8087
8091
  }
8088
8092
 
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
- });
8093
+ function handleSocket(socket) {
8094
+ if (!allowLegacyPlaintextForTests) {
8095
+ secureDirectAcceptor.accept(socket);
8096
+ return;
8097
+ }
8098
+ socket.once('data', chunk => {
8099
+ const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8100
+ if (isSecureHandshakeStart(firstChunk)) {
8101
+ secureDirectAcceptor.accept(socket, firstChunk);
8102
+ return;
8103
+ }
8104
+ if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
8105
+ if (!allowLegacyPlaintextForTests) {
8106
+ socket.write('HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n');
8107
+ socket.destroy();
8108
+ return;
8109
+ }
8110
+ socket.__liveDeskLegacyPlaintext = true;
8111
+ handleWebSocketUpgradeSocket(socket, firstChunk);
8112
+ return;
8113
+ }
8114
+ if (!allowLegacyPlaintextForTests) {
8115
+ writeJsonLine(socket, { type: 'error', error: 'secure-direct-required' });
8116
+ socket.destroy();
8117
+ return;
8118
+ }
8119
+ socket.__liveDeskLegacyPlaintext = true;
8120
+ handleTcpAgentSocket(socket, firstChunk);
8121
+ });
8118
8122
 
8119
8123
  socket.once('error', () => {
8120
8124
  // The transport-specific handler owns logging after the first byte.
@@ -8136,10 +8140,10 @@ export function createRemoteHub(options = {}) {
8136
8140
  started = true;
8137
8141
  boundPort = candidateServer.address()?.port || port;
8138
8142
  lastError = '';
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
- }
8143
+ logEvent('remote', `LiveDesk Hub client endpoint listening with authenticated encryption on tcp://${host}:${boundPort}`, 'success');
8144
+ if (host === '0.0.0.0' || host === '::') {
8145
+ logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable; Secure Direct device credentials are required.');
8146
+ }
8143
8147
  emitRemoteEvent('RemoteHubStarted', null);
8144
8148
  resolve();
8145
8149
  });
@@ -8151,14 +8155,14 @@ export function createRemoteHub(options = {}) {
8151
8155
  return getStatus({ includeSecrets: false });
8152
8156
  }
8153
8157
 
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 {
8158
+ try {
8159
+ await udpTransport?.start?.();
8160
+ await listenOnPort(requestedPort);
8161
+ if (!controlIdleSweepTimer) {
8162
+ controlIdleSweepTimer = setInterval(sweepIdleControlSessions, 1_000);
8163
+ controlIdleSweepTimer.unref?.();
8164
+ }
8165
+ try {
8162
8166
  await relayControl?.start?.();
8163
8167
  } catch {
8164
8168
  logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
@@ -8179,12 +8183,12 @@ export function createRemoteHub(options = {}) {
8179
8183
  return getStatus({ includeSecrets: false });
8180
8184
  }
8181
8185
 
8182
- async function close() {
8183
- if (controlIdleSweepTimer) {
8184
- clearInterval(controlIdleSweepTimer);
8185
- controlIdleSweepTimer = null;
8186
- }
8187
- for (const state of [...agentBinaryIngressStates]) {
8186
+ async function close() {
8187
+ if (controlIdleSweepTimer) {
8188
+ clearInterval(controlIdleSweepTimer);
8189
+ controlIdleSweepTimer = null;
8190
+ }
8191
+ for (const state of [...agentBinaryIngressStates]) {
8188
8192
  closeAgentBinaryIngress(state, 'hub-shutdown');
8189
8193
  }
8190
8194
 
@@ -8390,7 +8394,61 @@ export function createRemoteHub(options = {}) {
8390
8394
  return { ok: true, commandId };
8391
8395
  }
8392
8396
 
8393
- function refreshDevicePolicies(deviceIds = undefined) {
8397
+ async function sendCommandAwaitResult(deviceId, command, options = {}) {
8398
+ const normalizedDeviceId = safeString(deviceId, 160);
8399
+ const device = devices.get(normalizedDeviceId);
8400
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8401
+ const commandWithId = { ...(command || {}), commandId };
8402
+
8403
+ if (device?.synthetic === true && device.connected) {
8404
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8405
+ return {
8406
+ ...sent,
8407
+ queued: sent.ok === true,
8408
+ acknowledged: sent.ok === true,
8409
+ acknowledgement: sent.ok === true
8410
+ ? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
8411
+ : { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
8412
+ };
8413
+ }
8414
+
8415
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8416
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8417
+ return {
8418
+ ...sent,
8419
+ queued: false,
8420
+ acknowledged: false,
8421
+ acknowledgement: {
8422
+ ok: false,
8423
+ commandId,
8424
+ result: null,
8425
+ error: sent.error || 'device-not-connected'
8426
+ }
8427
+ };
8428
+ }
8429
+
8430
+ const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
8431
+ // Register before writing: a loopback Agent can return command.result in
8432
+ // the same event-loop turn as the command write.
8433
+ const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
8434
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8435
+ if (!sent.ok) {
8436
+ failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
8437
+ }
8438
+ const acknowledgement = await acknowledgementPromise;
8439
+ return {
8440
+ ...sent,
8441
+ ok: sent.ok === true && acknowledgement.ok === true,
8442
+ queued: sent.ok === true,
8443
+ acknowledged: acknowledgement.ok === true,
8444
+ acknowledgement,
8445
+ error: acknowledgement.ok === true
8446
+ ? undefined
8447
+ : acknowledgement.error || sent.error || 'command-result-failed'
8448
+ };
8449
+ }
8450
+
8451
+ function refreshDevicePolicies(deviceIds = undefined) {
8394
8452
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8395
8453
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8396
8454
  : null;
@@ -8399,31 +8457,34 @@ export function createRemoteHub(options = {}) {
8399
8457
  for (const device of devices.values()) {
8400
8458
  if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8401
8459
  if (!device?.socket || device.socket.destroyed || !device.connected) continue;
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, {
8460
+ total += 1;
8461
+ const effectivePolicy = getDevicePolicy(device);
8462
+ const connectionDenied = connectionPolicyError(device.socket, effectivePolicy);
8463
+ if (connectionDenied) {
8464
+ const deniedTransport = device.controlTransport || 'direct-tcp';
8465
+ const deniedRemoteAddress = safeString(device.remoteAddress, 256) || 'unknown';
8466
+ logWarn('security', 'Connected device rejected by refreshed policy reason=' + connectionDenied + ' transport=' + deniedTransport + ' remote=' + deniedRemoteAddress + ' device=' + device.deviceId);
8467
+ emitEvent('RemoteSecurityPolicyAudit', {
8468
+ result: 'rejected',
8469
+ reason: connectionDenied,
8470
+ deviceId: device.deviceId,
8471
+ sessionId: device.sessionId,
8472
+ transport: device.controlTransport,
8473
+ remoteAddress: device.remoteAddress
8474
+ });
8475
+ disconnectDevice(device.deviceId, connectionDenied);
8476
+ continue;
8477
+ }
8478
+ if (effectivePolicy.allowControl !== true && device.controlOwnerConnectionId) {
8479
+ releaseInputOwner(
8480
+ device.deviceId,
8481
+ device.controlOwnerConnectionId,
8482
+ 'control-disabled-by-policy');
8483
+ }
8484
+ if (effectivePolicy.allowRemoteAudio !== true && device.activeAudioStream) {
8485
+ stopAudioStream(device.deviceId, { reason: 'remote-audio-disabled-by-policy' });
8486
+ }
8487
+ if (writeJsonLine(device.socket, {
8427
8488
  type: 'policy.update',
8428
8489
  effectivePolicy,
8429
8490
  updatedAt: new Date().toISOString()
@@ -8502,7 +8563,7 @@ export function createRemoteHub(options = {}) {
8502
8563
  ]);
8503
8564
  }
8504
8565
 
8505
- function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8566
+ function buildRemoteInputResetMessage(device, controlStream, ownerConnectionId, reason, activeMonitorIndex) {
8506
8567
  return {
8507
8568
  type: 'input.control',
8508
8569
  payload: {
@@ -8524,114 +8585,114 @@ export function createRemoteHub(options = {}) {
8524
8585
  hubForwardedAtEpochMs: Date.now(),
8525
8586
  issuedAt: new Date().toISOString()
8526
8587
  }
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
- }
8588
+ };
8589
+ }
8590
+
8591
+ function controlSessionMessage(device, ownerConnectionId, type, reason = '') {
8592
+ const policy = getDevicePolicy(device);
8593
+ return {
8594
+ type,
8595
+ payload: {
8596
+ controlOwnerSessionId: safeString(device?.controlOwnerSessionId, 160),
8597
+ hubConnectionId: safeString(ownerConnectionId, 128),
8598
+ deviceId: safeString(device?.deviceId, 160),
8599
+ visibleIndicator: true,
8600
+ lockOnEnd: type === 'control.session.stop' && policy.lockOnControlEnd === true,
8601
+ idleControlMinutes: clampNumber(policy.idleControlMinutes, 5, 1440, 30),
8602
+ reason: safeString(reason, 160),
8603
+ issuedAt: new Date().toISOString()
8604
+ }
8605
+ };
8606
+ }
8607
+
8608
+ function sendControlSessionMessage(device, ownerConnectionId, type, reason, inputWriteOwner, bindingKey) {
8609
+ const message = controlSessionMessage(device, ownerConnectionId, type, reason);
8610
+ if (inputWriteOwner && bindingKey) {
8611
+ return inputWriteOwner.enqueue(message, bindingKey);
8612
+ }
8613
+ if (!device?.socket || device.socket.destroyed) {
8614
+ return { ok: false, error: 'device-not-connected' };
8615
+ }
8616
+ const sent = writeJsonLine(device.socket, {
8617
+ type: 'command',
8618
+ commandId: crypto.randomUUID(),
8619
+ command: type,
8620
+ payload: message.payload,
8621
+ issuedAt: new Date().toISOString()
8622
+ });
8623
+ return sent ? { ok: true, inputSocket: false } : { ok: false, error: 'device-not-connected' };
8624
+ }
8625
+
8626
+ function beginControlSession(device, ownerConnectionId, inputWriteOwner, bindingKey) {
8627
+ const owner = safeString(ownerConnectionId, 128);
8628
+ if (!device || !owner) return { ok: false, error: 'control-owner-required' };
8629
+ if (device.controlOwnerConnectionId === owner && device.controlOwnerSessionId) {
8630
+ return { ok: true, alreadyActive: true };
8631
+ }
8632
+ device.controlOwnerSessionId = crypto.randomUUID();
8633
+ device.controlOwnerConnectionId = owner;
8634
+ device.controlOwnerStartedAtMs = Date.now();
8635
+ device.controlOwnerLastInputAtMs = Date.now();
8636
+ const sent = sendControlSessionMessage(
8637
+ device,
8638
+ owner,
8639
+ 'control.session.start',
8640
+ 'browser-control-owner-active',
8641
+ inputWriteOwner,
8642
+ bindingKey);
8643
+ if (!sent.ok) {
8644
+ device.controlOwnerSessionId = '';
8645
+ device.controlOwnerConnectionId = '';
8646
+ device.controlOwnerStartedAtMs = 0;
8647
+ device.controlOwnerLastInputAtMs = 0;
8648
+ return sent;
8649
+ }
8650
+ emitRemoteEvent('RemoteControlSessionStarted', device, {
8651
+ controlOwnerSessionId: device.controlOwnerSessionId,
8652
+ hubConnectionId: owner
8653
+ });
8654
+ return sent;
8655
+ }
8656
+
8657
+ function endControlSession(device, ownerConnectionId, reason, inputWriteOwner, bindingKey) {
8658
+ const owner = safeString(ownerConnectionId, 128);
8659
+ if (!device?.controlOwnerSessionId
8660
+ || !owner
8661
+ || device.controlOwnerConnectionId !== owner) {
8662
+ return { ok: false, error: 'control-owner-not-current' };
8663
+ }
8664
+ const controlOwnerSessionId = device.controlOwnerSessionId;
8665
+ const sent = sendControlSessionMessage(
8666
+ device,
8667
+ owner,
8668
+ 'control.session.stop',
8669
+ reason,
8670
+ inputWriteOwner,
8671
+ bindingKey);
8672
+ device.controlOwnerSessionId = '';
8673
+ device.controlOwnerConnectionId = '';
8674
+ device.controlOwnerStartedAtMs = 0;
8675
+ device.controlOwnerLastInputAtMs = 0;
8676
+ emitRemoteEvent('RemoteControlSessionEnded', device, {
8677
+ controlOwnerSessionId,
8678
+ hubConnectionId: owner,
8679
+ reason,
8680
+ delivered: sent.ok === true
8681
+ });
8682
+ return sent;
8683
+ }
8684
+
8685
+ function sweepIdleControlSessions() {
8686
+ const nowMs = Date.now();
8687
+ for (const device of devices.values()) {
8688
+ if (!device?.connected || !device.controlOwnerConnectionId) continue;
8689
+ const policy = getDevicePolicy(device);
8690
+ if (policy.disconnectIdleControlSessions !== true) continue;
8691
+ const idleMs = clampNumber(policy.idleControlMinutes, 5, 1440, 30) * 60 * 1000;
8692
+ if (nowMs - Number(device.controlOwnerLastInputAtMs || 0) < idleMs) continue;
8693
+ releaseInputOwner(device.deviceId, device.controlOwnerConnectionId, 'control-idle-timeout');
8694
+ }
8695
+ }
8635
8696
 
8636
8697
  function getCurrentInputWriteOwner(device) {
8637
8698
  if (!device?.inputSocket
@@ -8774,16 +8835,16 @@ export function createRemoteHub(options = {}) {
8774
8835
  normalized,
8775
8836
  activeMonitorIndex);
8776
8837
  const previousInputBindingKey = String(device.inputOwnerBindingKey || '');
8777
- const ownerChanged = normalized.hubConnectionId
8838
+ const ownerChanged = normalized.hubConnectionId
8778
8839
  && previousOwnerConnectionId
8779
8840
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8780
8841
  const bindingChanged = previousInputBindingKey
8781
8842
  && previousInputBindingKey !== activeInputBindingKey;
8782
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8783
- const startingControlOwner = !!normalized.hubConnectionId
8784
- && (!device.controlOwnerConnectionId
8785
- || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8786
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8843
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8844
+ const startingControlOwner = !!normalized.hubConnectionId
8845
+ && (!device.controlOwnerConnectionId
8846
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8847
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
8787
8848
  const resetSent = inputWriteOwner.replaceBinding(
8788
8849
  activeInputBindingKey,
8789
8850
  buildRemoteInputResetMessage(
@@ -8798,28 +8859,28 @@ export function createRemoteHub(options = {}) {
8798
8859
  activeMonitorIndex));
8799
8860
  if (!resetSent.ok) {
8800
8861
  closeInputSocket(device, resetSent.error || 'input-owner-reset-write-failed');
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
- }
8862
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8863
+ }
8864
+ if (startingControlOwner) {
8865
+ if (device.controlOwnerConnectionId) {
8866
+ endControlSession(
8867
+ device,
8868
+ device.controlOwnerConnectionId,
8869
+ 'browser-input-owner-changed',
8870
+ inputWriteOwner,
8871
+ activeInputBindingKey);
8872
+ }
8873
+ const started = beginControlSession(
8874
+ device,
8875
+ normalized.hubConnectionId,
8876
+ inputWriteOwner,
8877
+ activeInputBindingKey);
8878
+ if (!started.ok) {
8879
+ closeInputSocket(device, started.error || 'control-session-indicator-start-failed');
8880
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8881
+ }
8882
+ }
8883
+ }
8823
8884
  const sent = inputWriteOwner.enqueue({
8824
8885
  type: 'input.control',
8825
8886
  payload: {
@@ -8832,8 +8893,8 @@ export function createRemoteHub(options = {}) {
8832
8893
  if (normalized.hubConnectionId) {
8833
8894
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8834
8895
  }
8835
- device.inputOwnerBindingKey = activeInputBindingKey;
8836
- device.controlOwnerLastInputAtMs = Date.now();
8896
+ device.inputOwnerBindingKey = activeInputBindingKey;
8897
+ device.controlOwnerLastInputAtMs = Date.now();
8837
8898
  device.counters.commandsSent += 1;
8838
8899
  device.inputLastSeenAt = new Date().toISOString();
8839
8900
  return {
@@ -8862,27 +8923,27 @@ export function createRemoteHub(options = {}) {
8862
8923
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
8863
8924
  }
8864
8925
 
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, {
8926
+ const hubForwardedAtEpochMs = Date.now();
8927
+ const startingFallbackOwner = !!normalized.hubConnectionId
8928
+ && (!device.controlOwnerConnectionId
8929
+ || device.controlOwnerConnectionId !== normalized.hubConnectionId);
8930
+ if (startingFallbackOwner) {
8931
+ if (device.controlOwnerConnectionId) {
8932
+ endControlSession(
8933
+ device,
8934
+ device.controlOwnerConnectionId,
8935
+ 'browser-input-owner-changed',
8936
+ null,
8937
+ '');
8938
+ }
8939
+ const started = beginControlSession(
8940
+ device,
8941
+ normalized.hubConnectionId,
8942
+ null,
8943
+ '');
8944
+ if (!started.ok) return started;
8945
+ }
8946
+ const fallback = sendCommand(deviceId, {
8886
8947
  command: 'input.control',
8887
8948
  payload: {
8888
8949
  ...normalized,
@@ -8890,7 +8951,7 @@ export function createRemoteHub(options = {}) {
8890
8951
  issuedAt: normalized.issuedAt || new Date().toISOString()
8891
8952
  }
8892
8953
  });
8893
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8954
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8894
8955
  schedulePendingInputFallback(device, {
8895
8956
  commandId: fallback.commandId,
8896
8957
  inputSeq: normalized.inputSeq,
@@ -8902,26 +8963,26 @@ export function createRemoteHub(options = {}) {
8902
8963
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8903
8964
  hubForwardedAtEpochMs,
8904
8965
  fallback: true,
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
8966
+ timeoutTimer: null
8967
+ });
8968
+ }
8969
+ if (fallback.ok) {
8970
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
8971
+ device.inputOwnerBindingKey = buildRemoteInputBindingKey(
8972
+ device,
8973
+ controlStream,
8974
+ normalized,
8975
+ activeMonitorIndex);
8976
+ device.controlOwnerLastInputAtMs = Date.now();
8977
+ } else if (startingFallbackOwner) {
8978
+ endControlSession(
8979
+ device,
8980
+ normalized.hubConnectionId,
8981
+ 'input-control-delivery-failed',
8982
+ null,
8983
+ '');
8984
+ }
8985
+ return fallback.ok
8925
8986
  ? {
8926
8987
  ...fallback,
8927
8988
  inputSocket: false,
@@ -8947,20 +9008,20 @@ export function createRemoteHub(options = {}) {
8947
9008
  return { ok: false, error: 'input-owner-not-current' };
8948
9009
  }
8949
9010
 
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);
9011
+ const inputSocket = device.inputSocket;
9012
+ const inputWriteOwner = getCurrentInputWriteOwner(device);
9013
+ if (!inputSocket || !inputWriteOwner) {
9014
+ const ended = endControlSession(device, owner, reason, null, '');
9015
+ device.inputOwnerConnectionId = '';
9016
+ device.inputOwnerBindingKey = '';
9017
+ return {
9018
+ ok: ended.ok !== false,
9019
+ queued: false,
9020
+ controlSessionEnded: ended.ok === true
9021
+ };
9022
+ }
9023
+ const controlStream = getActiveControlStream(device);
9024
+ const releaseBindingKey = buildRemoteInputReleaseBindingKey(device, owner);
8964
9025
  const sent = inputWriteOwner.replaceBinding(
8965
9026
  releaseBindingKey,
8966
9027
  buildRemoteInputResetMessage(
@@ -8969,24 +9030,24 @@ export function createRemoteHub(options = {}) {
8969
9030
  owner,
8970
9031
  reason,
8971
9032
  normalizeMonitorIndex(controlStream?.monitorIndex)));
8972
- if (!sent.ok) {
9033
+ if (!sent.ok) {
8973
9034
  closeInputSocket(device, sent.error || 'input-owner-release-write-failed');
8974
9035
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
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
- };
9036
+ }
9037
+ const ended = endControlSession(
9038
+ device,
9039
+ owner,
9040
+ reason,
9041
+ inputWriteOwner,
9042
+ releaseBindingKey);
9043
+ device.inputOwnerConnectionId = '';
9044
+ device.inputOwnerBindingKey = '';
9045
+ return {
9046
+ ok: ended.ok !== false,
9047
+ queued: true,
9048
+ backpressured: sent.backpressured === true || ended.backpressured === true,
9049
+ controlSessionEnded: ended.ok === true
9050
+ };
8990
9051
  }
8991
9052
 
8992
9053
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -9026,12 +9087,12 @@ export function createRemoteHub(options = {}) {
9026
9087
  if (retiredRequestError) {
9027
9088
  return { ok: false, error: retiredRequestError };
9028
9089
  }
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);
9090
+ const device = devices.get(String(deviceId || ''));
9091
+ const requestedOperation = safeString(options.operation, 80);
9092
+ if (RETIRED_AGENT_MUTATING_OPERATIONS.has(requestedOperation.toLowerCase())) {
9093
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
9094
+ }
9095
+ const operation = normalizeAgentOperation(requestedOperation);
9035
9096
  if (requestedOperation && !operation) {
9036
9097
  return { ok: false, error: 'unsupported-agent-operation' };
9037
9098
  }
@@ -11279,6 +11340,7 @@ export function createRemoteHub(options = {}) {
11279
11340
  disconnectDevice,
11280
11341
  assignDeviceSlot,
11281
11342
  sendCommand,
11343
+ sendCommandAwaitResult,
11282
11344
  refreshDevicePolicies,
11283
11345
  sendLegacyClientUpdate,
11284
11346
  sendInputControl,
@@ -11306,16 +11368,17 @@ export function createRemoteHub(options = {}) {
11306
11368
  handleUdpFrame,
11307
11369
  seedSyntheticFleet,
11308
11370
  clearSyntheticFleet,
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()
11371
+ getPairToken: () => pairToken,
11372
+ getPairingPin: () => pairingPin,
11373
+ getSecurityStatus: () => ({
11374
+ hubId: deviceCredentialAuthority.hubId,
11375
+ hubPublicKey: deviceCredentialAuthority.hubPublicKey,
11376
+ hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
11377
+ direct: secureDirectAcceptor.getStatus(),
11378
+ devices: deviceCredentialAuthority.listDevices()
11379
+ }),
11380
+ revokeDeviceCredential: (deviceId, reason) => deviceCredentialAuthority.revokeDevice(deviceId, reason),
11381
+ clearDeviceCredentials: () => deviceCredentialAuthority.clearDevices()
11319
11382
  };
11320
11383
  }
11321
11384