@livedesk/hub 0.1.47 → 0.1.49

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
@@ -3,10 +3,18 @@ import os from 'os';
3
3
  import crypto from 'crypto';
4
4
  import { createHubRelayControl } from './transport/relay-hub-control.js';
5
5
  import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
6
- import {
7
- BoundedSegmentedBuffer,
8
- createBoundedAgentBinaryIngressLane
9
- } from './transport/agent-binary-ingress.js';
6
+ import {
7
+ BoundedSegmentedBuffer,
8
+ createBoundedAgentBinaryIngressLane
9
+ } from './transport/agent-binary-ingress.js';
10
+ import {
11
+ REMOTE_CLIPBOARD_FILE_ACTIONS,
12
+ REMOTE_CLIPBOARD_LIMITS,
13
+ REMOTE_CLIPBOARD_PROTOCOL,
14
+ normalizeRemoteClipboardCommandResult,
15
+ normalizeRemoteClipboardManifest,
16
+ remoteClipboardContentNeedsFileTransfer
17
+ } from './remote-clipboard-contract.mjs';
10
18
 
11
19
  const DEFAULT_REMOTE_HUB_PORT = 5197;
12
20
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -228,12 +236,13 @@ const REMOTE_FRAME_MODE_ALIASES = new Map([
228
236
  ]);
229
237
  const MAX_SYNTHETIC_DEVICES = 1000;
230
238
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
231
- const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
232
- const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
233
- const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
234
- const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
235
- const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
236
- const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 16;
239
+ const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
240
+ const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
241
+ const DEFAULT_DEVICE_CONNECTION_STALE_MS = 20000;
242
+ const DEFAULT_DEVICE_CONNECTION_SWEEP_MS = 5000;
243
+ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
244
+ const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
245
+ const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 16;
237
246
  const AGENT_BINARY_INGRESS_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
238
247
  const AGENT_BINARY_INGRESS_QUEUE_MAX_AGE_MS = 120;
239
248
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
@@ -797,6 +806,25 @@ function normalizeIncomingFrameModeProfile(value) {
797
806
  };
798
807
  }
799
808
 
809
+ function deviceExplicitlyRejectsFrameMode(device, frameMode) {
810
+ const requestedMode = safeString(frameMode, 80).toLowerCase();
811
+ if (!requestedMode) {
812
+ return false;
813
+ }
814
+
815
+ const advertisedModes = [
816
+ ...(Array.isArray(device?.frameModes) ? device.frameModes : []),
817
+ ...(Array.isArray(device?.frameProtocol?.modes) ? device.frameProtocol.modes : []),
818
+ ...(Array.isArray(device?.capabilities?.frameModes) ? device.capabilities.frameModes : []),
819
+ ...(Array.isArray(device?.capabilities?.frameProtocol?.modes)
820
+ ? device.capabilities.frameProtocol.modes
821
+ : [])
822
+ ];
823
+ return advertisedModes.some(profile =>
824
+ safeString(profile?.mode || profile?.frameMode, 80).toLowerCase() === requestedMode
825
+ && profile?.implemented === false);
826
+ }
827
+
800
828
  function isLoopbackHost(value) {
801
829
  const host = String(value || '').trim().toLowerCase();
802
830
  return host === 'localhost'
@@ -1164,12 +1192,12 @@ function normalizeRemoteKeyboardKey(value) {
1164
1192
  return raw === ' ' ? ' ' : safeString(raw, 80);
1165
1193
  }
1166
1194
 
1167
- function normalizeRemoteKeyboardText(value) {
1168
- const raw = String(value ?? '').replace(/\0/g, '');
1169
- // Remote text is input data, not a label. Preserve leading/trailing spaces
1170
- // and IME whitespace while keeping every ordered message bounded.
1171
- return raw.slice(0, 256);
1172
- }
1195
+ function normalizeRemoteKeyboardText(value) {
1196
+ const raw = String(value ?? '').replace(/\0/g, '');
1197
+ // Remote text is input data, not a label. Preserve leading/trailing spaces
1198
+ // and IME whitespace while keeping every ordered message bounded.
1199
+ return raw.slice(0, 256);
1200
+ }
1173
1201
 
1174
1202
  function safeTaskData(value) {
1175
1203
  if (value === undefined || value === null) return undefined;
@@ -1216,9 +1244,11 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1216
1244
  'application.close',
1217
1245
  'file.read',
1218
1246
  'file.write',
1219
- 'file.delete',
1220
- 'file.list',
1221
- 'command.run',
1247
+ 'file.delete',
1248
+ 'file.list',
1249
+ 'file.search',
1250
+ 'directory.create',
1251
+ 'command.run',
1222
1252
  'script.run',
1223
1253
  'software.install',
1224
1254
  'network.status',
@@ -1250,7 +1280,7 @@ function readRawRemoteInputMonitorIndex(input) {
1250
1280
  return undefined;
1251
1281
  }
1252
1282
 
1253
- function normalizeRemoteInputEvent(value = {}) {
1283
+ function normalizeRemoteInputEvent(value = {}) {
1254
1284
  const input = value && typeof value === 'object' ? value : {};
1255
1285
  const type = safeString(input.type || input.Type, 48);
1256
1286
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1271,7 +1301,14 @@ function normalizeRemoteInputEvent(value = {}) {
1271
1301
  .filter(Boolean))]
1272
1302
  .slice(0, 64)
1273
1303
  : null;
1274
- const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
1304
+ const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
1305
+ const clipboardOperationId = safeString(
1306
+ input.clipboardOperationId || input.operationId || input.ClipboardOperationId || input.OperationId,
1307
+ 128);
1308
+ const clipboardDirection = safeString(
1309
+ input.clipboardDirection || input.ClipboardDirection,
1310
+ 16).toLowerCase();
1311
+ const clipboardManifest = input.clipboardManifest || input.manifest || input.ClipboardManifest || input.Manifest;
1275
1312
  let code = safeString(input.code ?? input.Code, 80);
1276
1313
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1277
1314
  code = 'Space';
@@ -1296,7 +1333,13 @@ function normalizeRemoteInputEvent(value = {}) {
1296
1333
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1297
1334
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1298
1335
  altKey: input.altKey === true || input.AltKey === true,
1299
- metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
1336
+ metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
1337
+ clipboardOperationId,
1338
+ operationId: clipboardOperationId,
1339
+ clipboardDirection,
1340
+ manifest: clipboardManifest && typeof clipboardManifest === 'object' && !Array.isArray(clipboardManifest)
1341
+ ? clipboardManifest
1342
+ : null,
1300
1343
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1301
1344
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1302
1345
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -1718,6 +1761,26 @@ function writeJsonLine(socket, payload) {
1718
1761
  return true;
1719
1762
  }
1720
1763
 
1764
+ function writeJsonLineAndClose(socket, payload) {
1765
+ if (!socket || socket.destroyed) {
1766
+ return false;
1767
+ }
1768
+
1769
+ if (socket.__remoteHubWebSocket === true) {
1770
+ const written = writeJsonLine(socket, payload);
1771
+ socket.destroy();
1772
+ return written;
1773
+ }
1774
+
1775
+ try {
1776
+ socket.end(`${JSON.stringify(payload)}\n`);
1777
+ return true;
1778
+ } catch {
1779
+ socket.destroy();
1780
+ return false;
1781
+ }
1782
+ }
1783
+
1721
1784
  function isRemoteHubWebSocketUpgradeStart(buffer) {
1722
1785
  if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
1723
1786
  return false;
@@ -2268,12 +2331,29 @@ export function createRemoteHub(options = {}) {
2268
2331
 
2269
2332
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2270
2333
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
2271
- const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2272
- const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2273
- const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2274
- const taskTimeoutMs = clampNumber(
2275
- options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2276
- 50,
2334
+ const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2335
+ const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2336
+ const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2337
+ const defaultDeviceConnectionStaleMs = Math.max(
2338
+ DEFAULT_DEVICE_CONNECTION_STALE_MS,
2339
+ heartbeatMs * 4);
2340
+ const deviceConnectionStaleMs = Number.isFinite(Number(options.deviceConnectionStaleMs))
2341
+ ? clampNumber(options.deviceConnectionStaleMs, 100, 10 * 60 * 1000, defaultDeviceConnectionStaleMs)
2342
+ : clampNumber(
2343
+ env.REMOTE_HUB_DEVICE_CONNECTION_STALE_MS,
2344
+ defaultDeviceConnectionStaleMs,
2345
+ 10 * 60 * 1000,
2346
+ defaultDeviceConnectionStaleMs);
2347
+ const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2348
+ ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2349
+ : clampNumber(
2350
+ env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2351
+ 1000,
2352
+ 60000,
2353
+ Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
2354
+ const taskTimeoutMs = clampNumber(
2355
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2356
+ 50,
2277
2357
  30 * 60 * 1000,
2278
2358
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2279
2359
  const inputAckTimeoutMs = clampNumber(
@@ -2336,16 +2416,18 @@ export function createRemoteHub(options = {}) {
2336
2416
  const audioSockets = new Map();
2337
2417
  const fileSockets = new Map();
2338
2418
  const allSockets = new Set();
2339
- const agentBinaryIngressStates = new Set();
2340
- const pendingCommandResultWaiters = new Map();
2419
+ const agentBinaryIngressStates = new Set();
2420
+ const pendingCommandResultWaiters = new Map();
2421
+ const clipboardOperations = new Map();
2341
2422
  const transportDiagnostics = new Map();
2342
2423
  const transportDiagnosticStartingDevices = new Set();
2343
- const duplicateDeviceLogAt = new Map();
2344
- let server = null;
2345
- let started = false;
2346
- let boundPort = requestedPort;
2347
- let lastError = '';
2348
- let hostTarget = null;
2424
+ const duplicateDeviceLogAt = new Map();
2425
+ let server = null;
2426
+ let started = false;
2427
+ let deviceConnectionSweepTimer = null;
2428
+ let boundPort = requestedPort;
2429
+ let lastError = '';
2430
+ let hostTarget = null;
2349
2431
  let publicIPv4Cache = {
2350
2432
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2351
2433
  updatedAt: 0,
@@ -2666,12 +2748,14 @@ export function createRemoteHub(options = {}) {
2666
2748
  protocol: 'tcp-jsonl',
2667
2749
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2668
2750
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2669
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2670
- frameModes: getSupportedRemoteFrameModeProfiles(),
2671
- heartbeatMs,
2672
- taskTimeoutMs,
2673
- managerPackage,
2674
- managerVersion,
2751
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2753
+ heartbeatMs,
2754
+ deviceConnectionStaleMs,
2755
+ deviceConnectionSweepMs,
2756
+ taskTimeoutMs,
2757
+ managerPackage,
2758
+ managerVersion,
2675
2759
  hostInstanceId,
2676
2760
  agentPackage: '@livedesk/client',
2677
2761
  agentEndpoint: routeInfo.endpoint,
@@ -2934,7 +3018,7 @@ export function createRemoteHub(options = {}) {
2934
3018
  return true;
2935
3019
  }
2936
3020
 
2937
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3021
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2938
3022
  if (!device?.deviceId) {
2939
3023
  return;
2940
3024
  }
@@ -2951,10 +3035,31 @@ export function createRemoteHub(options = {}) {
2951
3035
  result: null,
2952
3036
  error: safeString(error, 500) || 'command-result-unavailable'
2953
3037
  });
2954
- }
2955
- }
2956
-
2957
- function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
3038
+ }
3039
+ }
3040
+
3041
+ function failPendingFileCommandResultWaiters(device, error = 'file-channel-disconnected') {
3042
+ if (!device?.deviceId) {
3043
+ return;
3044
+ }
3045
+ for (const [waiterKey, waiter] of pendingCommandResultWaiters) {
3046
+ if (waiter.deviceId !== device.deviceId
3047
+ || waiter.sessionId !== device.sessionId
3048
+ || waiter.channel !== 'file') {
3049
+ continue;
3050
+ }
3051
+ pendingCommandResultWaiters.delete(waiterKey);
3052
+ clearPendingCommandResultWaiterDeadline(waiter);
3053
+ waiter.resolve({
3054
+ ok: false,
3055
+ commandId: waiter.commandId,
3056
+ result: null,
3057
+ error: safeString(error, 500) || 'file-channel-disconnected'
3058
+ });
3059
+ }
3060
+ }
3061
+
3062
+ function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
2958
3063
  const normalizedCommandId = safeString(commandId, 128);
2959
3064
  const waiterKey = buildPendingCommandResultWaiterKey(
2960
3065
  device?.deviceId,
@@ -2975,7 +3080,7 @@ export function createRemoteHub(options = {}) {
2975
3080
  return true;
2976
3081
  }
2977
3082
 
2978
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
3083
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
2979
3084
  const normalizedCommandId = safeString(commandId, 128);
2980
3085
  const waiterKey = buildPendingCommandResultWaiterKey(
2981
3086
  device?.deviceId,
@@ -3001,11 +3106,13 @@ export function createRemoteHub(options = {}) {
3001
3106
  error: 'command-result-wait-replaced'
3002
3107
  });
3003
3108
  }
3004
- const waiterRecord = {
3005
- deviceId: device.deviceId,
3006
- sessionId: device.sessionId,
3007
- commandId: normalizedCommandId,
3008
- timer: null,
3109
+ const waiterRecord = {
3110
+ deviceId: device.deviceId,
3111
+ sessionId: device.sessionId,
3112
+ commandId: normalizedCommandId,
3113
+ channel: safeString(metadata.channel, 24),
3114
+ operationId: safeString(metadata.operationId, 128),
3115
+ timer: null,
3009
3116
  deadlineImmediate: null,
3010
3117
  resolve
3011
3118
  };
@@ -3038,9 +3145,246 @@ export function createRemoteHub(options = {}) {
3038
3145
  });
3039
3146
  }, timeoutMs);
3040
3147
  waiterRecord.timer.unref?.();
3041
- pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3042
- });
3043
- }
3148
+ pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3149
+ });
3150
+ }
3151
+
3152
+ function buildClipboardOperationKey(deviceId, sessionId, operationId) {
3153
+ const normalizedDeviceId = safeString(deviceId, 160);
3154
+ const normalizedSessionId = safeString(sessionId, 160);
3155
+ const normalizedOperationId = safeString(operationId, 128);
3156
+ return normalizedDeviceId && normalizedSessionId && normalizedOperationId
3157
+ ? `${normalizedDeviceId.length}:${normalizedDeviceId}`
3158
+ + `${normalizedSessionId.length}:${normalizedSessionId}`
3159
+ + `${normalizedOperationId.length}:${normalizedOperationId}`
3160
+ : '';
3161
+ }
3162
+
3163
+ function queueClipboardAgentCleanup(device, operation, reason) {
3164
+ if (operation?.inFlight || !device?.fileSocket || device.fileSocket.destroyed) {
3165
+ return false;
3166
+ }
3167
+ const cleanupCommand = operation.direction === 'paste'
3168
+ ? 'clipboard.paste.cancel'
3169
+ : 'clipboard.copy.end';
3170
+ const commandId = crypto.randomUUID();
3171
+ try {
3172
+ writeJsonLine(device.fileSocket, {
3173
+ type: 'command',
3174
+ commandId,
3175
+ command: cleanupCommand,
3176
+ payload: {
3177
+ operationId: operation.operationId,
3178
+ reason: safeString(reason, 120) || 'clipboard-owner-closed'
3179
+ },
3180
+ issuedAt: new Date().toISOString()
3181
+ });
3182
+ device.counters.commandsSent += 1;
3183
+ emitRemoteEvent('RemoteCommandQueued', device, {
3184
+ commandId,
3185
+ command: cleanupCommand,
3186
+ channel: 'file',
3187
+ operationId: operation.operationId,
3188
+ cleanup: true
3189
+ });
3190
+ return true;
3191
+ } catch {
3192
+ // The exact side channel is already closing. Native session
3193
+ // teardown remains the authoritative cleanup.
3194
+ return false;
3195
+ }
3196
+ }
3197
+
3198
+ function clearClipboardOperationsForDevice(device, reason = 'clipboard-owner-closed', ownerConnectionId = '') {
3199
+ const requestedOwner = safeString(ownerConnectionId, 128);
3200
+ let cleared = 0;
3201
+ for (const [key, operation] of clipboardOperations) {
3202
+ if (operation.deviceId !== device?.deviceId
3203
+ || operation.sessionId !== device?.sessionId
3204
+ || (requestedOwner && operation.hubConnectionId !== requestedOwner)) {
3205
+ continue;
3206
+ }
3207
+ queueClipboardAgentCleanup(device, operation, reason);
3208
+ clipboardOperations.delete(key);
3209
+ cleared += 1;
3210
+ }
3211
+ if (cleared > 0) {
3212
+ emitRemoteEvent('RemoteClipboardOperationsCleared', device, {
3213
+ reason,
3214
+ ownerConnectionId: requestedOwner,
3215
+ count: cleared
3216
+ });
3217
+ }
3218
+ return cleared;
3219
+ }
3220
+
3221
+ function pruneClipboardOperations(nowMs = Date.now()) {
3222
+ for (const [key, operation] of clipboardOperations) {
3223
+ const currentDevice = devices.get(operation.deviceId);
3224
+ const expired = nowMs - Number(operation.touchedAt || 0)
3225
+ > REMOTE_CLIPBOARD_LIMITS.operationIdleTimeoutMs;
3226
+ const staleOwner = !currentDevice
3227
+ || currentDevice.sessionId !== operation.sessionId
3228
+ || safeString(currentDevice.inputOwnerConnectionId, 128) !== operation.hubConnectionId;
3229
+ if (expired || staleOwner) {
3230
+ if (currentDevice?.sessionId === operation.sessionId) {
3231
+ queueClipboardAgentCleanup(
3232
+ currentDevice,
3233
+ operation,
3234
+ expired ? 'clipboard-operation-expired' : 'clipboard-owner-stale');
3235
+ }
3236
+ clipboardOperations.delete(key);
3237
+ }
3238
+ }
3239
+ }
3240
+
3241
+ function clipboardCapabilityError(device, direction, contentKind = 'text') {
3242
+ const capabilities = device?.capabilities || {};
3243
+ if (capabilities.clipboardProtocol !== REMOTE_CLIPBOARD_PROTOCOL
3244
+ || capabilities.clipboardText !== true) {
3245
+ return 'device-clipboard-text-unavailable';
3246
+ }
3247
+ if (capabilities.sessionBoundSideChannels !== true) {
3248
+ return 'device-clipboard-session-bound-channel-unavailable';
3249
+ }
3250
+ if (direction === 'copy' && capabilities.clipboardCopy !== true) {
3251
+ return 'device-clipboard-copy-unavailable';
3252
+ }
3253
+ if (direction === 'paste' && capabilities.clipboardPaste !== true) {
3254
+ return 'device-clipboard-paste-unavailable';
3255
+ }
3256
+ if (contentKind === 'image' && capabilities.clipboardImage !== true) {
3257
+ return 'device-clipboard-image-unavailable';
3258
+ }
3259
+ if (contentKind === 'files' && capabilities.clipboardFiles !== true) {
3260
+ return 'device-clipboard-files-unavailable';
3261
+ }
3262
+ for (const key of ['clipboardMaxTextBytes', 'clipboardMaxTotalBytes', 'clipboardMaxChunkBytes']) {
3263
+ if (!Number.isSafeInteger(Number(capabilities[key])) || Number(capabilities[key]) <= 0) {
3264
+ return 'device-clipboard-limits-unavailable';
3265
+ }
3266
+ }
3267
+ if (Number(capabilities.clipboardMaxChunkBytes) < REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
3268
+ return 'device-clipboard-chunk-limit-unavailable';
3269
+ }
3270
+ if (contentKind === 'image'
3271
+ && (!Number.isSafeInteger(Number(
3272
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes))
3273
+ || Number(capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes) <= 0)) {
3274
+ return 'device-clipboard-limits-unavailable';
3275
+ }
3276
+ if (contentKind === 'files'
3277
+ && (!Number.isSafeInteger(Number(capabilities.clipboardMaxFiles))
3278
+ || Number(capabilities.clipboardMaxFiles) <= 0)) {
3279
+ return 'device-clipboard-limits-unavailable';
3280
+ }
3281
+ return '';
3282
+ }
3283
+
3284
+ function clipboardManifestCapabilityError(device, manifest) {
3285
+ if (!manifest) {
3286
+ return '';
3287
+ }
3288
+ const capabilities = device?.capabilities || {};
3289
+ if (manifest.totalBytes > Number(capabilities.clipboardMaxTotalBytes || 0)) {
3290
+ return 'device-clipboard-total-limit-exceeded';
3291
+ }
3292
+ if (manifest.contentKind === 'text'
3293
+ && manifest.totalBytes > Number(capabilities.clipboardMaxTextBytes || 0)) {
3294
+ return 'device-clipboard-text-limit-exceeded';
3295
+ }
3296
+ if (manifest.contentKind === 'image'
3297
+ && manifest.totalBytes > Number(
3298
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes ?? 0)) {
3299
+ return 'device-clipboard-image-limit-exceeded';
3300
+ }
3301
+ if (manifest.contentKind === 'files'
3302
+ && manifest.items.length > Number(capabilities.clipboardMaxFiles || 0)) {
3303
+ return 'device-clipboard-file-count-limit-exceeded';
3304
+ }
3305
+ return '';
3306
+ }
3307
+
3308
+ function validateCurrentClipboardBinding(deviceId, request = {}, contentKind = 'text', options = {}) {
3309
+ const device = devices.get(String(deviceId || ''));
3310
+ if (!device) {
3311
+ return { ok: false, error: 'device-not-found' };
3312
+ }
3313
+ if (device.synthetic === true) {
3314
+ return { ok: false, error: 'device-clipboard-unavailable' };
3315
+ }
3316
+ if (device.connected && !isDeviceConnectionFresh(device)) {
3317
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
3318
+ return { ok: false, error: 'device-not-connected' };
3319
+ }
3320
+ if (!device.connected || !device.socket || device.socket.destroyed) {
3321
+ return { ok: false, error: 'device-not-connected' };
3322
+ }
3323
+
3324
+ const policy = getDevicePolicy(device);
3325
+ if (policy.accessMode === 'block-remote-access') {
3326
+ return { ok: false, error: 'remote-access-blocked-by-settings' };
3327
+ }
3328
+ if (policy.allowControl !== true) {
3329
+ return { ok: false, error: 'allowControl-blocked-by-settings' };
3330
+ }
3331
+ if (policy.allowClipboardText !== true) {
3332
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
3333
+ }
3334
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
3335
+ && policy.allowFileTransfer !== true) {
3336
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
3337
+ }
3338
+
3339
+ const direction = request.clipboardDirection
3340
+ || (String(request.action || '').includes('.paste') ? 'paste' : 'copy');
3341
+ const capabilityError = clipboardCapabilityError(device, direction, contentKind);
3342
+ if (capabilityError) {
3343
+ return { ok: false, error: capabilityError };
3344
+ }
3345
+ const manifestCapabilityError = clipboardManifestCapabilityError(
3346
+ device,
3347
+ request.payload?.manifest || request.manifest);
3348
+ if (manifestCapabilityError) {
3349
+ return { ok: false, error: manifestCapabilityError };
3350
+ }
3351
+
3352
+ const controlStream = getActiveControlStream(device);
3353
+ if (!controlStream
3354
+ || !liveStreamHasCurrentFrame(controlStream)
3355
+ || !safeString(controlStream.commandId, 128)
3356
+ || Number(controlStream.captureGeneration || 0) <= 0) {
3357
+ return { ok: false, error: 'CONTROL_NOT_READY' };
3358
+ }
3359
+ if (getPendingLiveStreamDescriptor(controlStream)) {
3360
+ return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
3361
+ }
3362
+ const binding = request.binding || request;
3363
+ if (safeString(binding.controlSessionId || binding.sessionId, 160)
3364
+ !== safeString(device.sessionId, 160)
3365
+ || safeString(binding.controlCommandId || binding.commandId, 128)
3366
+ !== safeString(controlStream.commandId, 128)) {
3367
+ return { ok: false, error: 'STALE_CONTROL_SESSION' };
3368
+ }
3369
+ if (Number(binding.captureGeneration || 0)
3370
+ !== Number(controlStream.captureGeneration || 0)) {
3371
+ return { ok: false, error: 'STALE_CAPTURE_GENERATION' };
3372
+ }
3373
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
3374
+ if (Number(binding.monitorIndex) !== activeMonitorIndex) {
3375
+ return { ok: false, error: 'STALE_CONTROL_MONITOR' };
3376
+ }
3377
+ const hubConnectionId = safeString(request.hubConnectionId, 128);
3378
+ if (!hubConnectionId
3379
+ || hubConnectionId !== safeString(device.inputOwnerConnectionId, 128)) {
3380
+ return { ok: false, error: 'input-owner-not-current' };
3381
+ }
3382
+ if (options.requireFileSocket === true
3383
+ && (!device.fileSocket || device.fileSocket.destroyed)) {
3384
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
3385
+ }
3386
+ return { ok: true, device, controlStream, activeMonitorIndex, policy };
3387
+ }
3044
3388
 
3045
3389
  function inspectAnnexBH264(payload) {
3046
3390
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -3922,13 +4266,15 @@ export function createRemoteHub(options = {}) {
3922
4266
  }
3923
4267
  }
3924
4268
 
3925
- function closeFileSocket(device, reason = 'file-socket-closed') {
3926
- const socket = device?.fileSocket;
3927
- if (!socket) {
3928
- return;
3929
- }
3930
-
3931
- device.fileSocket = null;
4269
+ function closeFileSocket(device, reason = 'file-socket-closed') {
4270
+ const socket = device?.fileSocket;
4271
+ if (!socket) {
4272
+ return;
4273
+ }
4274
+
4275
+ clearClipboardOperationsForDevice(device, reason);
4276
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4277
+ device.fileSocket = null;
3932
4278
  fileSockets.delete(socket);
3933
4279
  try {
3934
4280
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -3942,13 +4288,14 @@ export function createRemoteHub(options = {}) {
3942
4288
  }
3943
4289
  }
3944
4290
 
3945
- function closeInputSocket(device, reason = 'input-socket-closed') {
4291
+ function closeInputSocket(device, reason = 'input-socket-closed') {
3946
4292
  const socket = device?.inputSocket;
3947
4293
  if (!socket) {
3948
4294
  return;
3949
4295
  }
3950
4296
 
3951
- const writeOwner = device.inputWriteOwner;
4297
+ const writeOwner = device.inputWriteOwner;
4298
+ clearClipboardOperationsForDevice(device, reason);
3952
4299
  device.inputSocket = null;
3953
4300
  device.inputWriteOwner = null;
3954
4301
  device.inputSocketConnectionId = '';
@@ -3968,7 +4315,7 @@ export function createRemoteHub(options = {}) {
3968
4315
  }
3969
4316
  }
3970
4317
 
3971
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4318
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
3972
4319
  const deviceId = inputSockets.get(socket);
3973
4320
  inputSockets.delete(socket);
3974
4321
  if (!deviceId) {
@@ -3980,7 +4327,8 @@ export function createRemoteHub(options = {}) {
3980
4327
  return;
3981
4328
  }
3982
4329
 
3983
- const writeOwner = device.inputWriteOwner;
4330
+ const writeOwner = device.inputWriteOwner;
4331
+ clearClipboardOperationsForDevice(device, reason);
3984
4332
  device.inputSocket = null;
3985
4333
  device.inputWriteOwner = null;
3986
4334
  device.inputSocketConnectionId = '';
@@ -4025,7 +4373,7 @@ export function createRemoteHub(options = {}) {
4025
4373
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4026
4374
  }
4027
4375
 
4028
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4376
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4029
4377
  const deviceId = fileSockets.get(socket);
4030
4378
  fileSockets.delete(socket);
4031
4379
  if (!deviceId) {
@@ -4033,38 +4381,107 @@ export function createRemoteHub(options = {}) {
4033
4381
  }
4034
4382
 
4035
4383
  const device = devices.get(deviceId);
4036
- if (!device || device.fileSocket !== socket) {
4037
- return;
4038
- }
4039
-
4040
- device.fileSocket = null;
4384
+ if (!device || device.fileSocket !== socket) {
4385
+ return;
4386
+ }
4387
+
4388
+ clearClipboardOperationsForDevice(device, reason);
4389
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4390
+ device.fileSocket = null;
4041
4391
  device.fileLastSeenAt = new Date().toISOString();
4042
- emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4043
- }
4044
-
4045
- function getDeviceActivityMs(device) {
4046
- return Math.max(
4047
- Date.parse(device?.lastSeenAt || '') || 0,
4048
- Date.parse(device?.lastStatusAt || '') || 0,
4049
- Date.parse(device?.connectedAt || '') || 0
4050
- );
4051
- }
4052
-
4053
- function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4392
+ emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4393
+ }
4394
+
4395
+ function getDeviceConnectionHeartbeatMs(device) {
4396
+ return Date.parse(device?.lastStatusAt || '')
4397
+ || Date.parse(device?.connectedAt || '')
4398
+ || 0;
4399
+ }
4400
+
4401
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4054
4402
  if (!device?.connected || !device.socket || device.socket.destroyed) {
4055
- return false;
4056
- }
4057
-
4058
- const lastActivityMs = getDeviceActivityMs(device);
4059
- if (!lastActivityMs) {
4060
- return true;
4061
- }
4062
-
4063
- const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
4064
- return nowMs - lastActivityMs <= freshnessMs;
4065
- }
4066
-
4067
- function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4403
+ return false;
4404
+ }
4405
+
4406
+ const lastHeartbeatMs = getDeviceConnectionHeartbeatMs(device);
4407
+ if (!lastHeartbeatMs) {
4408
+ return true;
4409
+ }
4410
+
4411
+ return nowMs - lastHeartbeatMs <= deviceConnectionStaleMs;
4412
+ }
4413
+
4414
+ function retireStaleDeviceConnection(device, reason = 'status-heartbeat-timeout') {
4415
+ const deviceId = safeString(device?.deviceId, 160);
4416
+ const sessionId = safeString(device?.sessionId, 160);
4417
+ const socket = device?.socket;
4418
+ if (!deviceId
4419
+ || !sessionId
4420
+ || !socket
4421
+ || socket.destroyed
4422
+ || devices.get(deviceId) !== device
4423
+ || device.socket !== socket
4424
+ || device.sessionId !== sessionId) {
4425
+ return false;
4426
+ }
4427
+
4428
+ closeInputSocket(device, reason);
4429
+ closeFrameSocket(device, reason);
4430
+ closeAudioSocket(device, reason);
4431
+ closeFileSocket(device, reason);
4432
+ try {
4433
+ writeJsonLine(socket, { type: 'disconnect', reason, sessionId });
4434
+ } catch {
4435
+ // The stale connection is retired below even if the final notice fails.
4436
+ }
4437
+ detachSocket(socket, reason);
4438
+ try {
4439
+ socket.destroy?.();
4440
+ } catch {
4441
+ // detachSocket already made the exact session terminal.
4442
+ }
4443
+ return true;
4444
+ }
4445
+
4446
+ function sweepStaleDeviceConnections(nowMs = Date.now()) {
4447
+ pruneClipboardOperations(nowMs);
4448
+ let retired = 0;
4449
+ for (const device of devices.values()) {
4450
+ if (device?.synthetic === true || isDeviceConnectionFresh(device, nowMs)) {
4451
+ continue;
4452
+ }
4453
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
4454
+ continue;
4455
+ }
4456
+ const ageMs = Math.max(0, nowMs - getDeviceConnectionHeartbeatMs(device));
4457
+ if (retireStaleDeviceConnection(device, 'status-heartbeat-timeout')) {
4458
+ retired += 1;
4459
+ logWarn(
4460
+ 'remote',
4461
+ `retired stale device session ${device.deviceName} (${device.deviceId}) `
4462
+ + `session=${device.sessionId} heartbeatAgeMs=${ageMs} `
4463
+ + `staleAfterMs=${deviceConnectionStaleMs}`);
4464
+ }
4465
+ }
4466
+ return retired;
4467
+ }
4468
+
4469
+ function scheduleDeviceConnectionSweep() {
4470
+ if (!started || deviceConnectionSweepTimer) {
4471
+ return;
4472
+ }
4473
+ deviceConnectionSweepTimer = setTimeout(() => {
4474
+ deviceConnectionSweepTimer = null;
4475
+ if (!started) {
4476
+ return;
4477
+ }
4478
+ sweepStaleDeviceConnections();
4479
+ scheduleDeviceConnectionSweep();
4480
+ }, deviceConnectionSweepMs);
4481
+ deviceConnectionSweepTimer.unref?.();
4482
+ }
4483
+
4484
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4068
4485
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4069
4486
  writeJsonLine(socket, {
4070
4487
  type: 'disconnect',
@@ -4516,16 +4933,20 @@ export function createRemoteHub(options = {}) {
4516
4933
  return device;
4517
4934
  }
4518
4935
 
4519
- function attachInputSocket(socket, hello) {
4520
- const deviceId = normalizeDeviceId(hello.deviceId);
4521
- const device = devices.get(deviceId);
4936
+ function attachInputSocket(socket, hello) {
4937
+ const deviceId = normalizeDeviceId(hello.deviceId);
4938
+ const device = devices.get(deviceId);
4522
4939
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4523
4940
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4524
4941
  socket.destroy();
4525
- return null;
4526
- }
4527
-
4528
- closeInputSocket(device, 'replaced-by-new-input-socket');
4942
+ return null;
4943
+ }
4944
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
4945
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
4946
+ return null;
4947
+ }
4948
+
4949
+ closeInputSocket(device, 'replaced-by-new-input-socket');
4529
4950
 
4530
4951
  const now = new Date().toISOString();
4531
4952
  const inputSocketConnectionId = crypto.randomUUID();
@@ -4639,16 +5060,20 @@ export function createRemoteHub(options = {}) {
4639
5060
  return device;
4640
5061
  }
4641
5062
 
4642
- function attachFileSocket(socket, hello) {
4643
- const deviceId = normalizeDeviceId(hello.deviceId);
4644
- const device = devices.get(deviceId);
5063
+ function attachFileSocket(socket, hello) {
5064
+ const deviceId = normalizeDeviceId(hello.deviceId);
5065
+ const device = devices.get(deviceId);
4645
5066
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4646
5067
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4647
5068
  socket.destroy();
4648
- return null;
4649
- }
4650
-
4651
- closeFileSocket(device, 'replaced-by-new-file-socket');
5069
+ return null;
5070
+ }
5071
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
5072
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5073
+ return null;
5074
+ }
5075
+
5076
+ closeFileSocket(device, 'replaced-by-new-file-socket');
4652
5077
  const now = new Date().toISOString();
4653
5078
  device.fileSocket = socket;
4654
5079
  device.fileConnectedAt = now;
@@ -7971,12 +8396,13 @@ export function createRemoteHub(options = {}) {
7971
8396
  await listenOnPort(requestedPort);
7972
8397
  try {
7973
8398
  await relayControl?.start?.();
7974
- } catch {
7975
- logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
7976
- }
7977
- } catch (error) {
7978
- if (error?.code === 'EADDRINUSE') {
7979
- const conflict = new Error(
8399
+ } catch {
8400
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8401
+ }
8402
+ scheduleDeviceConnectionSweep();
8403
+ } catch (error) {
8404
+ if (error?.code === 'EADDRINUSE') {
8405
+ const conflict = new Error(
7980
8406
  `LiveDesk Hub requires client endpoint TCP ${requestedPort}, but that port is already in use. Close the owning process or start Hub with an explicit --remote-port value.`);
7981
8407
  conflict.code = 'EADDRINUSE';
7982
8408
  conflict.port = requestedPort;
@@ -7988,12 +8414,16 @@ export function createRemoteHub(options = {}) {
7988
8414
  }
7989
8415
 
7990
8416
  return getStatus({ includeSecrets: false });
7991
- }
7992
-
7993
- async function close() {
7994
- for (const state of [...agentBinaryIngressStates]) {
7995
- closeAgentBinaryIngress(state, 'hub-shutdown');
7996
- }
8417
+ }
8418
+
8419
+ async function close() {
8420
+ if (deviceConnectionSweepTimer) {
8421
+ clearTimeout(deviceConnectionSweepTimer);
8422
+ deviceConnectionSweepTimer = null;
8423
+ }
8424
+ for (const state of [...agentBinaryIngressStates]) {
8425
+ closeAgentBinaryIngress(state, 'hub-shutdown');
8426
+ }
7997
8427
 
7998
8428
  for (const device of devices.values()) {
7999
8429
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8128,9 +8558,12 @@ export function createRemoteHub(options = {}) {
8128
8558
  };
8129
8559
  }
8130
8560
 
8131
- function sendCommand(deviceId, command) {
8132
- const device = devices.get(String(deviceId || ''));
8133
- const commandName = safeString(command?.command || 'ping', 80);
8561
+ function sendCommand(deviceId, command) {
8562
+ const device = devices.get(String(deviceId || ''));
8563
+ const commandName = safeString(command?.command || 'ping', 80);
8564
+ if (REMOTE_CLIPBOARD_FILE_ACTIONS.has(commandName)) {
8565
+ return { ok: false, error: 'clipboard-command-requires-awaited-file-channel' };
8566
+ }
8134
8567
  const requiredPermission = commandName === 'input.control'
8135
8568
  ? 'allowControl'
8136
8569
  : commandName === 'system.power'
@@ -8166,12 +8599,16 @@ export function createRemoteHub(options = {}) {
8166
8599
  result: { ok: true, synthetic: true },
8167
8600
  error: ''
8168
8601
  });
8169
- return { ok: true, commandId, synthetic: true };
8170
- }
8171
-
8172
- if (!device?.socket || device.socket.destroyed || !device.connected) {
8173
- return { ok: false, error: 'device-not-connected' };
8174
- }
8602
+ return { ok: true, commandId, synthetic: true };
8603
+ }
8604
+
8605
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
8606
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8607
+ return { ok: false, error: 'device-not-connected' };
8608
+ }
8609
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8610
+ return { ok: false, error: 'device-not-connected' };
8611
+ }
8175
8612
 
8176
8613
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8177
8614
  const payload = {
@@ -8184,29 +8621,300 @@ export function createRemoteHub(options = {}) {
8184
8621
 
8185
8622
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8186
8623
  && device.fileSocket
8187
- && !device.fileSocket.destroyed
8188
- ? device.fileSocket
8189
- : null;
8190
- writeJsonLine(dedicatedFileSocket || device.socket, payload);
8191
- device.counters.commandsSent += 1;
8192
- emitRemoteEvent('RemoteCommandQueued', device, {
8193
- commandId,
8624
+ && !device.fileSocket.destroyed
8625
+ ? device.fileSocket
8626
+ : null;
8627
+ try {
8628
+ writeJsonLine(dedicatedFileSocket || device.socket, payload);
8629
+ } catch {
8630
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
8631
+ return { ok: false, error: 'device-not-connected' };
8632
+ }
8633
+ device.counters.commandsSent += 1;
8634
+ emitRemoteEvent('RemoteCommandQueued', device, {
8635
+ commandId,
8194
8636
  command: payload.command,
8195
8637
  channel: dedicatedFileSocket ? 'file' : 'control'
8196
8638
  });
8197
- return { ok: true, commandId };
8198
- }
8199
-
8200
- function refreshDevicePolicies(deviceIds = undefined) {
8639
+ return { ok: true, commandId };
8640
+ }
8641
+
8642
+ async function sendClipboardCommand(deviceId, request = {}) {
8643
+ pruneClipboardOperations();
8644
+ const action = safeString(request.action, 80).toLowerCase();
8645
+ if (!REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
8646
+ return { ok: false, error: 'clipboard-file-action-invalid' };
8647
+ }
8648
+ const operationId = safeString(request.operationId, 128);
8649
+ if (!operationId) {
8650
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
8651
+ }
8652
+
8653
+ const direction = action.startsWith('clipboard.paste.')
8654
+ ? 'paste'
8655
+ : action.startsWith('clipboard.copy.')
8656
+ ? 'copy'
8657
+ : '';
8658
+ const operationKey = buildClipboardOperationKey(deviceId, request.binding?.controlSessionId, operationId);
8659
+ let operation = clipboardOperations.get(operationKey);
8660
+ const beginsOperation = action === 'clipboard.copy.begin' || action === 'clipboard.paste.begin';
8661
+ const endsOperation = action === 'clipboard.copy.end' || action === 'clipboard.paste.cancel';
8662
+ const requestedManifest = request.payload?.manifest || null;
8663
+
8664
+ if (beginsOperation) {
8665
+ if (operation) {
8666
+ return { ok: false, error: 'clipboard-operation-already-exists' };
8667
+ }
8668
+ const activeForDevice = [...clipboardOperations.values()].filter(candidate =>
8669
+ candidate.deviceId === String(deviceId || '')
8670
+ && candidate.sessionId === request.binding?.controlSessionId);
8671
+ if (activeForDevice.some(candidate => candidate.direction === direction)) {
8672
+ return { ok: false, error: 'clipboard-operation-direction-busy' };
8673
+ }
8674
+ if (activeForDevice.length >= REMOTE_CLIPBOARD_LIMITS.maxOperationsPerDevice) {
8675
+ return { ok: false, error: 'clipboard-operation-capacity-reached' };
8676
+ }
8677
+ operation = {
8678
+ deviceId: String(deviceId || ''),
8679
+ sessionId: safeString(request.binding?.controlSessionId, 160),
8680
+ operationId,
8681
+ direction,
8682
+ hubConnectionId: safeString(request.hubConnectionId, 128),
8683
+ controlCommandId: safeString(request.binding?.controlCommandId, 128),
8684
+ captureGeneration: Number(request.binding?.captureGeneration || 0),
8685
+ monitorIndex: Number(request.binding?.monitorIndex),
8686
+ contentKind: requestedManifest?.contentKind || 'text',
8687
+ manifest: requestedManifest,
8688
+ offsets: new Map(),
8689
+ status: action === 'clipboard.paste.begin' ? 'preparing' : 'copying',
8690
+ inFlight: false,
8691
+ touchedAt: Date.now()
8692
+ };
8693
+ clipboardOperations.set(operationKey, operation);
8694
+ } else {
8695
+ if (!operation) {
8696
+ return { ok: false, error: 'clipboard-operation-not-found' };
8697
+ }
8698
+ const requestedDirection = direction || operation.direction;
8699
+ if (operation.direction !== requestedDirection
8700
+ || operation.hubConnectionId !== safeString(request.hubConnectionId, 128)
8701
+ || operation.controlCommandId !== safeString(request.binding?.controlCommandId, 128)
8702
+ || operation.captureGeneration !== Number(request.binding?.captureGeneration || 0)
8703
+ || operation.monitorIndex !== Number(request.binding?.monitorIndex)) {
8704
+ return { ok: false, error: 'clipboard-operation-owner-stale' };
8705
+ }
8706
+ }
8707
+
8708
+ if (operation.inFlight) {
8709
+ return { ok: false, error: 'clipboard-operation-busy' };
8710
+ }
8711
+
8712
+ const contentKind = operation.contentKind || requestedManifest?.contentKind || 'text';
8713
+ const bindingResult = validateCurrentClipboardBinding(
8714
+ deviceId,
8715
+ { ...request, clipboardDirection: operation.direction },
8716
+ contentKind,
8717
+ { requireFileSocket: true });
8718
+ if (!bindingResult.ok) {
8719
+ if (beginsOperation || bindingResult.error?.startsWith('STALE_') || bindingResult.error === 'input-owner-not-current') {
8720
+ clipboardOperations.delete(operationKey);
8721
+ }
8722
+ return bindingResult;
8723
+ }
8724
+
8725
+ const { device } = bindingResult;
8726
+ const fileSocket = device.fileSocket;
8727
+ const itemIndex = request.payload?.itemIndex;
8728
+ const expectedOffset = Number.isInteger(itemIndex)
8729
+ ? Number(operation.offsets.get(itemIndex) || 0)
8730
+ : 0;
8731
+ let outgoingChunkBytes = 0;
8732
+ if (action === 'clipboard.copy.chunk' || action === 'clipboard.paste.chunk') {
8733
+ const item = operation.manifest?.items?.[itemIndex];
8734
+ if (!item) {
8735
+ return { ok: false, error: 'clipboard-item-not-found' };
8736
+ }
8737
+ if (request.payload.offset !== expectedOffset) {
8738
+ return { ok: false, error: 'clipboard-chunk-offset-stale', expectedOffset };
8739
+ }
8740
+ if (action === 'clipboard.copy.chunk'
8741
+ && request.payload.maxBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8742
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8743
+ }
8744
+ if (action === 'clipboard.paste.chunk') {
8745
+ outgoingChunkBytes = Buffer.from(request.payload.dataBase64, 'base64').length;
8746
+ if (outgoingChunkBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8747
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8748
+ }
8749
+ const nextOffset = expectedOffset + outgoingChunkBytes;
8750
+ if (outgoingChunkBytes === 0
8751
+ && !(item.size === 0 && expectedOffset === 0 && request.payload.final === true)) {
8752
+ return { ok: false, error: 'clipboard-empty-chunk-invalid' };
8753
+ }
8754
+ if (nextOffset > item.size
8755
+ || request.payload.final !== (nextOffset === item.size)) {
8756
+ return { ok: false, error: 'clipboard-chunk-boundary-invalid' };
8757
+ }
8758
+ }
8759
+ }
8760
+
8761
+ operation.inFlight = true;
8762
+ operation.touchedAt = Date.now();
8763
+ let commandCompleted = false;
8764
+ const commandId = crypto.randomUUID();
8765
+ const resultPromise = waitForCommandResult(
8766
+ device,
8767
+ commandId,
8768
+ REMOTE_CLIPBOARD_LIMITS.commandTimeoutMs,
8769
+ { channel: 'file', operationId });
8770
+ const commandPayload = {
8771
+ type: 'command',
8772
+ commandId,
8773
+ command: action,
8774
+ payload: {
8775
+ operationId,
8776
+ controlSessionId: operation.sessionId,
8777
+ controlCommandId: operation.controlCommandId,
8778
+ captureGeneration: operation.captureGeneration,
8779
+ monitorIndex: operation.monitorIndex,
8780
+ hubConnectionId: operation.hubConnectionId,
8781
+ ...(request.payload || {})
8782
+ },
8783
+ issuedAt: new Date().toISOString()
8784
+ };
8785
+
8786
+ try {
8787
+ try {
8788
+ // Clipboard payloads are never allowed onto the concurrent
8789
+ // main command socket. The dedicated file side channel owns
8790
+ // the one bounded command and its exact-session waiter.
8791
+ writeJsonLine(fileSocket, commandPayload);
8792
+ } catch {
8793
+ failPendingCommandResultWaiter(device, commandId, 'clipboard-file-channel-write-failed');
8794
+ if (device.fileSocket === fileSocket) {
8795
+ closeFileSocket(device, 'clipboard-file-channel-write-failed');
8796
+ }
8797
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
8798
+ }
8799
+ device.counters.commandsSent += 1;
8800
+ emitRemoteEvent('RemoteCommandQueued', device, {
8801
+ commandId,
8802
+ command: action,
8803
+ channel: 'file',
8804
+ operationId
8805
+ });
8806
+
8807
+ const awaited = await resultPromise;
8808
+ if (!awaited.ok) {
8809
+ return {
8810
+ ok: false,
8811
+ error: awaited.error || 'clipboard-command-failed',
8812
+ commandId
8813
+ };
8814
+ }
8815
+ if (device.fileSocket !== fileSocket) {
8816
+ return { ok: false, error: 'clipboard-file-channel-owner-changed' };
8817
+ }
8818
+ const currentBinding = validateCurrentClipboardBinding(
8819
+ deviceId,
8820
+ { ...request, clipboardDirection: operation.direction },
8821
+ operation.contentKind || 'text',
8822
+ { requireFileSocket: true });
8823
+ if (!currentBinding.ok) {
8824
+ return currentBinding;
8825
+ }
8826
+
8827
+ let response;
8828
+ try {
8829
+ response = normalizeRemoteClipboardCommandResult(action, operationId, awaited.result);
8830
+ } catch (error) {
8831
+ return {
8832
+ ok: false,
8833
+ error: safeString(error?.code || error?.message, 160) || 'clipboard-response-invalid'
8834
+ };
8835
+ }
8836
+ if (response.manifest) {
8837
+ const responseManifestLimitError = clipboardManifestCapabilityError(device, response.manifest);
8838
+ if (responseManifestLimitError) {
8839
+ clipboardOperations.delete(operationKey);
8840
+ return { ok: false, error: responseManifestLimitError };
8841
+ }
8842
+ const responsePolicy = validateCurrentClipboardBinding(
8843
+ deviceId,
8844
+ { ...request, clipboardDirection: operation.direction },
8845
+ response.manifest.contentKind,
8846
+ { requireFileSocket: true });
8847
+ if (!responsePolicy.ok) {
8848
+ clipboardOperations.delete(operationKey);
8849
+ return responsePolicy;
8850
+ }
8851
+ operation.manifest = response.manifest;
8852
+ operation.contentKind = response.manifest.contentKind;
8853
+ }
8854
+ if (action === 'clipboard.copy.chunk') {
8855
+ const item = operation.manifest?.items?.[response.itemIndex];
8856
+ const nextOffset = response.offset + response.bytes;
8857
+ if (!item
8858
+ || response.itemIndex !== itemIndex
8859
+ || response.offset !== expectedOffset
8860
+ || (response.bytes === 0
8861
+ && !(item.size === 0 && response.offset === 0 && response.final === true))
8862
+ || nextOffset > item.size
8863
+ || response.final !== (nextOffset === item.size)) {
8864
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8865
+ }
8866
+ if (response.bytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8867
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8868
+ }
8869
+ operation.offsets.set(response.itemIndex, nextOffset);
8870
+ } else if (action === 'clipboard.paste.chunk') {
8871
+ if (response.itemIndex !== itemIndex
8872
+ || response.offset !== expectedOffset
8873
+ || response.bytes !== outgoingChunkBytes
8874
+ || response.totalBytes !== operation.manifest.items[itemIndex].size
8875
+ || response.final !== request.payload.final) {
8876
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8877
+ }
8878
+ operation.offsets.set(itemIndex, expectedOffset + outgoingChunkBytes);
8879
+ }
8880
+ operation.status = response.status || operation.status;
8881
+ operation.touchedAt = Date.now();
8882
+ if (action === 'clipboard.operation.status' && operation.status === 'not-found') {
8883
+ clipboardOperations.delete(operationKey);
8884
+ return { ok: false, error: 'clipboard-operation-not-found' };
8885
+ }
8886
+ if (action === 'clipboard.operation.status'
8887
+ && ['completed', 'complete', 'applied', 'pasted', 'cancelled', 'cleaned', 'failed', 'expired', 'already-ended']
8888
+ .includes(operation.status)) {
8889
+ clipboardOperations.delete(operationKey);
8890
+ }
8891
+ commandCompleted = true;
8892
+ return response;
8893
+ } finally {
8894
+ const currentOperation = clipboardOperations.get(operationKey);
8895
+ if (currentOperation === operation) {
8896
+ operation.inFlight = false;
8897
+ if (endsOperation || (beginsOperation && !commandCompleted)) {
8898
+ clipboardOperations.delete(operationKey);
8899
+ }
8900
+ }
8901
+ }
8902
+ }
8903
+
8904
+ function refreshDevicePolicies(deviceIds = undefined) {
8201
8905
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8202
8906
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8203
8907
  : null;
8204
8908
  let updated = 0;
8205
8909
  let total = 0;
8206
- for (const device of devices.values()) {
8207
- if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8208
- if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8209
- total += 1;
8910
+ for (const device of devices.values()) {
8911
+ if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8912
+ if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8913
+ if (device.synthetic !== true && !isDeviceConnectionFresh(device)) {
8914
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8915
+ continue;
8916
+ }
8917
+ total += 1;
8210
8918
  const effectivePolicy = getDevicePolicy(device);
8211
8919
  if (writeJsonLine(device.socket, {
8212
8920
  type: 'policy.update',
@@ -8225,8 +8933,14 @@ export function createRemoteHub(options = {}) {
8225
8933
  const device = devices.get(String(deviceId || ''));
8226
8934
  const denied = policyError(device);
8227
8935
  if (denied) return { ok: false, error: denied };
8228
- if (!device) return { ok: false, error: 'device-not-found' };
8229
- if (!device.socket || device.socket.destroyed || !device.connected) {
8936
+ if (!device) return { ok: false, error: 'device-not-found' };
8937
+ if (device.synthetic !== true
8938
+ && device.connected
8939
+ && !isDeviceConnectionFresh(device)) {
8940
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8941
+ return { ok: false, error: 'device-not-connected' };
8942
+ }
8943
+ if (!device.socket || device.socket.destroyed || !device.connected) {
8230
8944
  return { ok: false, error: 'device-not-connected' };
8231
8945
  }
8232
8946
 
@@ -8322,15 +9036,21 @@ export function createRemoteHub(options = {}) {
8322
9036
  return device.inputWriteOwner;
8323
9037
  }
8324
9038
 
8325
- function sendInputControl(deviceId, input = {}) {
8326
- const device = devices.get(String(deviceId || ''));
8327
- if (!device) {
8328
- return { ok: false, error: 'device-not-found' };
8329
- }
8330
-
8331
- if (!device.connected) {
8332
- return { ok: false, error: 'device-not-connected' };
8333
- }
9039
+ function sendInputControl(deviceId, input = {}) {
9040
+ const device = devices.get(String(deviceId || ''));
9041
+ if (!device) {
9042
+ return { ok: false, error: 'device-not-found' };
9043
+ }
9044
+
9045
+ if (device.synthetic !== true
9046
+ && device.connected
9047
+ && !isDeviceConnectionFresh(device)) {
9048
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9049
+ return { ok: false, error: 'device-not-connected' };
9050
+ }
9051
+ if (!device.connected) {
9052
+ return { ok: false, error: 'device-not-connected' };
9053
+ }
8334
9054
 
8335
9055
  const normalized = normalizeRemoteInputEvent(input);
8336
9056
  if (!normalized.type) {
@@ -8396,9 +9116,51 @@ export function createRemoteHub(options = {}) {
8396
9116
  || device.capabilities?.accessibilityPermission === false) {
8397
9117
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
8398
9118
  }
8399
- if (!readCapabilityFlag(device.capabilities, 'control')) {
8400
- return { ok: false, error: 'device-input-control-unavailable' };
8401
- }
9119
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
9120
+ return { ok: false, error: 'device-input-control-unavailable' };
9121
+ }
9122
+
9123
+ const normalizedInputType = normalized.type.toLowerCase();
9124
+ const isClipboardInput = normalizedInputType === 'clipboard.prepare'
9125
+ || normalizedInputType === 'clipboard.copy'
9126
+ || normalizedInputType === 'clipboard.paste';
9127
+ let clipboardManifest = null;
9128
+ if (isClipboardInput) {
9129
+ if (!normalized.clipboardOperationId) {
9130
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
9131
+ }
9132
+ if (!['copy', 'paste'].includes(normalized.clipboardDirection)) {
9133
+ return { ok: false, error: 'clipboard-direction-required' };
9134
+ }
9135
+ if ((normalizedInputType === 'clipboard.copy' && normalized.clipboardDirection !== 'copy')
9136
+ || (normalizedInputType === 'clipboard.paste' && normalized.clipboardDirection !== 'paste')) {
9137
+ return { ok: false, error: 'clipboard-direction-mismatch' };
9138
+ }
9139
+ try {
9140
+ clipboardManifest = normalized.manifest
9141
+ ? normalizeRemoteClipboardManifest(normalized.manifest, normalized.clipboardOperationId)
9142
+ : null;
9143
+ } catch (error) {
9144
+ return { ok: false, error: safeString(error?.code || error?.message, 160) || 'clipboard-manifest-invalid' };
9145
+ }
9146
+ const clipboardPolicy = getDevicePolicy(device);
9147
+ if (clipboardPolicy.allowClipboardText !== true) {
9148
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
9149
+ }
9150
+ const contentKind = clipboardManifest?.contentKind || normalized.contentKind || 'text';
9151
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
9152
+ && clipboardPolicy.allowFileTransfer !== true) {
9153
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
9154
+ }
9155
+ const clipboardCapabilityDenied = clipboardCapabilityError(
9156
+ device,
9157
+ normalized.clipboardDirection,
9158
+ contentKind);
9159
+ if (clipboardCapabilityDenied) {
9160
+ return { ok: false, error: clipboardCapabilityDenied };
9161
+ }
9162
+ normalized.manifest = clipboardManifest;
9163
+ }
8402
9164
 
8403
9165
  const controlStream = getActiveControlStream(device);
8404
9166
  if (!controlStream
@@ -8434,14 +9196,32 @@ export function createRemoteHub(options = {}) {
8434
9196
  };
8435
9197
  }
8436
9198
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
8437
- if (!Number.isInteger(normalized.monitorIndex)
8438
- || normalized.monitorIndex !== activeMonitorIndex) {
9199
+ if (!Number.isInteger(normalized.monitorIndex)
9200
+ || normalized.monitorIndex !== activeMonitorIndex) {
8439
9201
  return {
8440
9202
  ok: false,
8441
9203
  error: 'STALE_CONTROL_MONITOR',
8442
- activeMonitorIndex
8443
- };
8444
- }
9204
+ activeMonitorIndex
9205
+ };
9206
+ }
9207
+
9208
+ let clipboardOperationKey = '';
9209
+ if (normalizedInputType === 'clipboard.paste') {
9210
+ clipboardOperationKey = buildClipboardOperationKey(
9211
+ device.deviceId,
9212
+ device.sessionId,
9213
+ normalized.clipboardOperationId);
9214
+ const operation = clipboardOperations.get(clipboardOperationKey);
9215
+ if (!operation
9216
+ || operation.direction !== 'paste'
9217
+ || operation.hubConnectionId !== normalized.hubConnectionId
9218
+ || operation.controlCommandId !== normalized.controlCommandId
9219
+ || operation.captureGeneration !== normalized.captureGeneration
9220
+ || operation.monitorIndex !== normalized.monitorIndex
9221
+ || operation.status !== 'ready') {
9222
+ return { ok: false, error: 'clipboard-paste-not-ready' };
9223
+ }
9224
+ }
8445
9225
 
8446
9226
  const inputSocket = device.inputSocket;
8447
9227
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8458,9 +9238,17 @@ export function createRemoteHub(options = {}) {
8458
9238
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8459
9239
  const bindingChanged = previousInputBindingKey
8460
9240
  && previousInputBindingKey !== activeInputBindingKey;
8461
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8462
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8463
- const resetSent = inputWriteOwner.replaceBinding(
9241
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9242
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
9243
+ if (ownerChanged) {
9244
+ clearClipboardOperationsForDevice(
9245
+ device,
9246
+ 'browser-input-owner-changed',
9247
+ previousOwnerConnectionId);
9248
+ } else if (bindingChanged || writeBindingChanged) {
9249
+ clearClipboardOperationsForDevice(device, 'control-input-binding-changed');
9250
+ }
9251
+ const resetSent = inputWriteOwner.replaceBinding(
8464
9252
  activeInputBindingKey,
8465
9253
  buildRemoteInputResetMessage(
8466
9254
  device,
@@ -8490,9 +9278,16 @@ export function createRemoteHub(options = {}) {
8490
9278
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8491
9279
  }
8492
9280
  device.inputOwnerBindingKey = activeInputBindingKey;
8493
- device.counters.commandsSent += 1;
8494
- device.inputLastSeenAt = new Date().toISOString();
8495
- return {
9281
+ device.counters.commandsSent += 1;
9282
+ device.inputLastSeenAt = new Date().toISOString();
9283
+ if (clipboardOperationKey) {
9284
+ const operation = clipboardOperations.get(clipboardOperationKey);
9285
+ if (operation) {
9286
+ operation.status = 'pasting';
9287
+ operation.touchedAt = Date.now();
9288
+ }
9289
+ }
9290
+ return {
8496
9291
  ok: true,
8497
9292
  inputSocket: true,
8498
9293
  sessionId: device.sessionId,
@@ -8527,7 +9322,7 @@ export function createRemoteHub(options = {}) {
8527
9322
  issuedAt: normalized.issuedAt || new Date().toISOString()
8528
9323
  }
8529
9324
  });
8530
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9325
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8531
9326
  schedulePendingInputFallback(device, {
8532
9327
  commandId: fallback.commandId,
8533
9328
  inputSeq: normalized.inputSeq,
@@ -8539,9 +9334,16 @@ export function createRemoteHub(options = {}) {
8539
9334
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8540
9335
  hubForwardedAtEpochMs,
8541
9336
  fallback: true,
8542
- timeoutTimer: null
8543
- });
8544
- }
9337
+ timeoutTimer: null
9338
+ });
9339
+ }
9340
+ if (fallback.ok && clipboardOperationKey) {
9341
+ const operation = clipboardOperations.get(clipboardOperationKey);
9342
+ if (operation) {
9343
+ operation.status = 'pasting';
9344
+ operation.touchedAt = Date.now();
9345
+ }
9346
+ }
8545
9347
  return fallback.ok
8546
9348
  ? {
8547
9349
  ...fallback,
@@ -8561,14 +9363,15 @@ export function createRemoteHub(options = {}) {
8561
9363
  };
8562
9364
  }
8563
9365
 
8564
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9366
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
8565
9367
  const device = devices.get(String(deviceId || ''));
8566
9368
  const owner = safeString(ownerConnectionId, 128);
8567
- if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
8568
- return { ok: false, error: 'input-owner-not-current' };
8569
- }
8570
-
8571
- device.inputOwnerConnectionId = '';
9369
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
9370
+ return { ok: false, error: 'input-owner-not-current' };
9371
+ }
9372
+
9373
+ clearClipboardOperationsForDevice(device, reason, owner);
9374
+ device.inputOwnerConnectionId = '';
8572
9375
  device.inputOwnerBindingKey = '';
8573
9376
  const inputSocket = device.inputSocket;
8574
9377
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8612,8 +9415,12 @@ export function createRemoteHub(options = {}) {
8612
9415
  };
8613
9416
  const results = targets.map(deviceId => {
8614
9417
  const device = devices.get(deviceId);
8615
- if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
8616
- if (!device?.connected || !device.socket || device.socket.destroyed) {
9418
+ if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
9419
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
9420
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9421
+ return { deviceId, ok: false, error: 'device-not-connected' };
9422
+ }
9423
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
8617
9424
  return { deviceId, ok: false, error: 'device-not-connected' };
8618
9425
  }
8619
9426
  return writeJsonLine(device.socket, payload)
@@ -8645,7 +9452,7 @@ export function createRemoteHub(options = {}) {
8645
9452
  const denied = policyError(device, 'allowAgent');
8646
9453
  if (denied) return { ok: false, error: denied };
8647
9454
 
8648
- if (device?.synthetic === true && device.connected) {
9455
+ if (device?.synthetic === true && device.connected) {
8649
9456
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
8650
9457
  if (!instruction) {
8651
9458
  return { ok: false, error: 'missing-instruction' };
@@ -8705,10 +9512,14 @@ export function createRemoteHub(options = {}) {
8705
9512
  error: '',
8706
9513
  synthetic: true
8707
9514
  });
8708
- return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
8709
- }
8710
-
8711
- if (!device?.socket || device.socket.destroyed || !device.connected) {
9515
+ return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
9516
+ }
9517
+
9518
+ if (device.connected && !isDeviceConnectionFresh(device)) {
9519
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9520
+ return { ok: false, error: 'device-not-connected' };
9521
+ }
9522
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8712
9523
  return { ok: false, error: 'device-not-connected' };
8713
9524
  }
8714
9525
 
@@ -9528,11 +10339,58 @@ export function createRemoteHub(options = {}) {
9528
10339
  === normalized.streamPurpose;
9529
10340
  }
9530
10341
 
9531
- function startLiveStream(deviceId, options = {}) {
9532
- const device = devices.get(String(deviceId || ''));
9533
- const denied = policyError(device);
9534
- if (denied) return { ok: false, error: denied };
9535
- if (device?.liveCapturePause?.token) {
10342
+ function liveStreamMatchesOwnerIdentity(activeLiveStream, normalized) {
10343
+ if (!activeLiveStream) {
10344
+ return false;
10345
+ }
10346
+ return activeLiveStream.frameMode === normalized.transfer.frameMode
10347
+ && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex
10348
+ && (safeString(activeLiveStream.streamPurpose, 24).toLowerCase() || 'wall')
10349
+ === normalized.streamPurpose;
10350
+ }
10351
+
10352
+ function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
10353
+ return {
10354
+ ok: true,
10355
+ commandId: activeLiveStream.commandId,
10356
+ sessionId: device.sessionId,
10357
+ streamId: activeLiveStream.streamId,
10358
+ streamPurpose: safeString(activeLiveStream.streamPurpose, 24) || normalized.streamPurpose,
10359
+ fps: Number(activeLiveStream.fps || normalized.fps),
10360
+ mode: activeLiveStream.mode || normalized.transfer.mode,
10361
+ frameMode: activeLiveStream.frameMode || normalized.transfer.frameMode,
10362
+ monitorIndex: Number(activeLiveStream.monitorIndex ?? normalized.monitorIndex),
10363
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10364
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
10365
+ reused: true,
10366
+ sharedProfileReused: true,
10367
+ requestedProfile: {
10368
+ fps: normalized.fps,
10369
+ maxWidth: normalized.maxWidth,
10370
+ maxHeight: normalized.maxHeight,
10371
+ quality: normalized.quality
10372
+ },
10373
+ effectiveProfile: {
10374
+ fps: Number(activeLiveStream.fps || normalized.fps),
10375
+ maxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
10376
+ maxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight),
10377
+ quality: Number(activeLiveStream.quality || normalized.quality)
10378
+ },
10379
+ ...extra
10380
+ };
10381
+ }
10382
+
10383
+ function startLiveStream(deviceId, options = {}) {
10384
+ const device = devices.get(String(deviceId || ''));
10385
+ const denied = policyError(device);
10386
+ if (denied) return { ok: false, error: denied };
10387
+ if (device?.synthetic !== true
10388
+ && device?.connected
10389
+ && !isDeviceConnectionFresh(device)) {
10390
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10391
+ return { ok: false, error: 'device-not-connected' };
10392
+ }
10393
+ if (device?.liveCapturePause?.token) {
9536
10394
  return {
9537
10395
  ok: false,
9538
10396
  error: 'LIVE_CAPTURE_PAUSED',
@@ -9664,6 +10522,13 @@ export function createRemoteHub(options = {}) {
9664
10522
  const modePolicyError = liveStreamModePolicyError(normalized);
9665
10523
  if (modePolicyError) return { ok: false, error: modePolicyError };
9666
10524
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10525
+ if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
10526
+ return {
10527
+ ok: false,
10528
+ error: 'device-frame-mode-unavailable',
10529
+ frameMode: transfer.frameMode
10530
+ };
10531
+ }
9667
10532
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
9668
10533
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
9669
10534
  return {
@@ -9678,13 +10543,24 @@ export function createRemoteHub(options = {}) {
9678
10543
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
9679
10544
  if (pendingDescriptor?.commandId) {
9680
10545
  const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
9681
- if (pendingMatchesRequest && liveStreamReplacementStillPending(activeLiveStream)) {
10546
+ const pendingMatchesSharedOwner = options.forceRestart !== true
10547
+ && options.reuseExisting === true
10548
+ && options.reuseSharedExisting === true
10549
+ && liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized);
10550
+ if ((pendingMatchesRequest || pendingMatchesSharedOwner)
10551
+ && liveStreamReplacementStillPending(activeLiveStream)) {
9682
10552
  emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
9683
10553
  streamId,
9684
10554
  commandId: pendingDescriptor.commandId,
9685
10555
  activeCommandId: activeLiveStream.commandId,
9686
10556
  reason: safeString(options.restartReason || 'duplicate-restart', 128)
9687
10557
  });
10558
+ if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
10559
+ return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
10560
+ ready: false,
10561
+ pending: true
10562
+ });
10563
+ }
9688
10564
  return {
9689
10565
  ok: true,
9690
10566
  commandId: pendingDescriptor.commandId,
@@ -9758,6 +10634,25 @@ export function createRemoteHub(options = {}) {
9758
10634
  reused: true
9759
10635
  };
9760
10636
  }
10637
+ if (activeLiveStream
10638
+ && options.forceRestart !== true
10639
+ && options.reuseExisting === true
10640
+ && options.reuseSharedExisting === true
10641
+ && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
10642
+ && liveStreamIsReusable(activeLiveStream)) {
10643
+ emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
10644
+ streamId,
10645
+ commandId: activeLiveStream.commandId,
10646
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10647
+ requestedFps: normalized.fps,
10648
+ effectiveFps: Number(activeLiveStream.fps || normalized.fps),
10649
+ requestedMaxWidth: normalized.maxWidth,
10650
+ requestedMaxHeight: normalized.maxHeight,
10651
+ effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
10652
+ effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
10653
+ });
10654
+ return sharedLiveProfileResult(device, activeLiveStream, normalized);
10655
+ }
9761
10656
  if (activeLiveStream
9762
10657
  && options.forceRestart !== true
9763
10658
  && liveStreamMatchesOptions(activeLiveStream, normalized)
@@ -9926,9 +10821,15 @@ export function createRemoteHub(options = {}) {
9926
10821
  };
9927
10822
  }
9928
10823
 
9929
- function stopLiveStream(deviceId, options = {}) {
9930
- const device = devices.get(String(deviceId || ''));
9931
- const stopAll = options.stopAll === true;
10824
+ function stopLiveStream(deviceId, options = {}) {
10825
+ const device = devices.get(String(deviceId || ''));
10826
+ if (device?.synthetic !== true
10827
+ && device?.connected
10828
+ && !isDeviceConnectionFresh(device)) {
10829
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10830
+ return { ok: false, error: 'device-not-connected' };
10831
+ }
10832
+ const stopAll = options.stopAll === true;
9932
10833
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
9933
10834
  const requestedStreamId = safeString(options.streamId, 128);
9934
10835
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -10529,12 +11430,18 @@ export function createRemoteHub(options = {}) {
10529
11430
  };
10530
11431
  }
10531
11432
 
10532
- function startAudioStream(deviceId, options = {}) {
10533
- const device = devices.get(String(deviceId || ''));
10534
- if (!device) {
10535
- return { ok: false, error: 'device-not-found' };
10536
- }
10537
- if (!device.connected) {
11433
+ function startAudioStream(deviceId, options = {}) {
11434
+ const device = devices.get(String(deviceId || ''));
11435
+ if (!device) {
11436
+ return { ok: false, error: 'device-not-found' };
11437
+ }
11438
+ if (device.synthetic !== true
11439
+ && device.connected
11440
+ && !isDeviceConnectionFresh(device)) {
11441
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11442
+ return { ok: false, error: 'device-not-connected' };
11443
+ }
11444
+ if (!device.connected) {
10538
11445
  return { ok: false, error: 'device-not-connected' };
10539
11446
  }
10540
11447
  const denied = policyError(device, 'allowRemoteAudio');
@@ -10611,12 +11518,18 @@ export function createRemoteHub(options = {}) {
10611
11518
  return { ok: true, commandId, streamId };
10612
11519
  }
10613
11520
 
10614
- function stopAudioStream(deviceId, options = {}) {
10615
- const device = devices.get(String(deviceId || ''));
10616
- if (!device) {
10617
- return { ok: false, error: 'device-not-found' };
10618
- }
10619
- if (!device.connected) {
11521
+ function stopAudioStream(deviceId, options = {}) {
11522
+ const device = devices.get(String(deviceId || ''));
11523
+ if (!device) {
11524
+ return { ok: false, error: 'device-not-found' };
11525
+ }
11526
+ if (device.synthetic !== true
11527
+ && device.connected
11528
+ && !isDeviceConnectionFresh(device)) {
11529
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11530
+ return { ok: false, error: 'device-not-connected' };
11531
+ }
11532
+ if (!device.connected) {
10620
11533
  return { ok: false, error: 'device-not-connected' };
10621
11534
  }
10622
11535
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -10829,10 +11742,14 @@ export function createRemoteHub(options = {}) {
10829
11742
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
10830
11743
  ? device.liveStreamReplacementTimers.size
10831
11744
  : 0,
10832
- commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
10833
- .filter(waiter => waiter.deviceId === device.deviceId
10834
- && waiter.sessionId === device.sessionId)
10835
- .length,
11745
+ commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
11746
+ .filter(waiter => waiter.deviceId === device.deviceId
11747
+ && waiter.sessionId === device.sessionId)
11748
+ .length,
11749
+ clipboardOperationCount: [...clipboardOperations.values()]
11750
+ .filter(operation => operation.deviceId === device.deviceId
11751
+ && operation.sessionId === device.sessionId)
11752
+ .length,
10836
11753
  retainedPayloadCount: retainedPayloads.size,
10837
11754
  retainedPayloadBytes: [...retainedPayloads].reduce(
10838
11755
  (total, payload) => total + payload.length,
@@ -10842,8 +11759,9 @@ export function createRemoteHub(options = {}) {
10842
11759
  && snapshot.stopPendingCount === 0
10843
11760
  && snapshot.pendingStartCount === 0
10844
11761
  && snapshot.pendingStopPromiseCount === 0
10845
- && snapshot.replacementTimerCount === 0
10846
- && snapshot.commandResultWaiterCount === 0
11762
+ && snapshot.replacementTimerCount === 0
11763
+ && snapshot.commandResultWaiterCount === 0
11764
+ && snapshot.clipboardOperationCount === 0
10847
11765
  && snapshot.retainedPayloadCount === 0
10848
11766
  && snapshot.retainedPayloadBytes === 0;
10849
11767
  return snapshot;
@@ -10855,8 +11773,9 @@ export function createRemoteHub(options = {}) {
10855
11773
  'stopPendingCount',
10856
11774
  'pendingStartCount',
10857
11775
  'pendingStopPromiseCount',
10858
- 'replacementTimerCount',
10859
- 'commandResultWaiterCount',
11776
+ 'replacementTimerCount',
11777
+ 'commandResultWaiterCount',
11778
+ 'clipboardOperationCount',
10860
11779
  'retainedPayloadCount',
10861
11780
  'retainedPayloadBytes'
10862
11781
  ]) {
@@ -10869,9 +11788,10 @@ export function createRemoteHub(options = {}) {
10869
11788
  stopPendingCount: 0,
10870
11789
  pendingStartCount: 0,
10871
11790
  pendingStopPromiseCount: 0,
10872
- replacementTimerCount: 0,
10873
- commandResultWaiterCount: 0,
10874
- retainedPayloadCount: 0,
11791
+ replacementTimerCount: 0,
11792
+ commandResultWaiterCount: 0,
11793
+ clipboardOperationCount: 0,
11794
+ retainedPayloadCount: 0,
10875
11795
  retainedPayloadBytes: 0
10876
11796
  });
10877
11797
  return {
@@ -10911,8 +11831,9 @@ export function createRemoteHub(options = {}) {
10911
11831
  listDeviceFrames,
10912
11832
  disconnectDevice,
10913
11833
  assignDeviceSlot,
10914
- sendCommand,
10915
- refreshDevicePolicies,
11834
+ sendCommand,
11835
+ sendClipboardCommand,
11836
+ refreshDevicePolicies,
10916
11837
  sendLegacyClientUpdate,
10917
11838
  sendInputControl,
10918
11839
  releaseInputOwner,